a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
527 lines
16 KiB
Go
527 lines
16 KiB
Go
package languages
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
"github.com/zzet/gortex/internal/parser"
|
|
)
|
|
|
|
// extractKubernetesYAML scans a YAML stream for Kubernetes manifests
|
|
// (documents that have both `apiVersion:` and `kind:` keys) and emits
|
|
// KindResource / KindImage / KindConfigKey nodes plus the
|
|
// infrastructure edges (EdgeConfigures, EdgeMounts, EdgeExposes,
|
|
// EdgeDependsOn, EdgeUsesEnv).
|
|
//
|
|
// Returns true when at least one manifest was successfully extracted.
|
|
// The caller (YAMLExtractor.Extract) uses this signal to skip the
|
|
// generic top-level-keys fallback for K8s files — those keys would
|
|
// pollute search results without adding information beyond what the
|
|
// dedicated graph already captures. When the file has no manifests
|
|
// (or yaml parsing fails) the function returns false so the caller
|
|
// can run the generic path.
|
|
func extractKubernetesYAML(filePath, fileID string, src []byte, result *parser.ExtractionResult) bool {
|
|
dec := yaml.NewDecoder(strings.NewReader(string(src)))
|
|
emitted := false
|
|
for {
|
|
var doc yaml.Node
|
|
if err := dec.Decode(&doc); err != nil {
|
|
break
|
|
}
|
|
root := documentMapping(&doc)
|
|
if root == nil {
|
|
continue
|
|
}
|
|
apiVersion := scalarOf(mappingGet(root, "apiVersion"))
|
|
kind := scalarOf(mappingGet(root, "kind"))
|
|
if apiVersion == "" || kind == "" {
|
|
continue
|
|
}
|
|
extractK8sDoc(filePath, fileID, root, apiVersion, kind, result)
|
|
emitted = true
|
|
}
|
|
return emitted
|
|
}
|
|
|
|
// extractK8sDoc emits the graph fragment for a single K8s document.
|
|
func extractK8sDoc(filePath, fileID string, root *yaml.Node, apiVersion, kind string, result *parser.ExtractionResult) {
|
|
meta := mappingGet(root, "metadata")
|
|
name := scalarOf(mappingGet(meta, "name"))
|
|
namespace := scalarOf(mappingGet(meta, "namespace"))
|
|
if namespace == "" {
|
|
namespace = "_default"
|
|
}
|
|
if name == "" {
|
|
// Some manifests (e.g. List, Kustomization-as-resource)
|
|
// genuinely lack a metadata.name. Synthesise a stable
|
|
// fallback so the node ID stays unique.
|
|
name = fmt.Sprintf("__noname_%d", root.Line)
|
|
}
|
|
resID := k8sResourceID(kind, namespace, name)
|
|
resLine := root.Line
|
|
if resLine <= 0 {
|
|
resLine = 1
|
|
}
|
|
resMeta := map[string]any{
|
|
"api_version": apiVersion,
|
|
"k8s_kind": kind,
|
|
"namespace": namespace,
|
|
}
|
|
if labels := mappingGet(meta, "labels"); labels != nil && labels.Kind == yaml.MappingNode {
|
|
labelMap := make(map[string]string, len(labels.Content)/2)
|
|
for i := 0; i+1 < len(labels.Content); i += 2 {
|
|
k := labels.Content[i].Value
|
|
v := labels.Content[i+1].Value
|
|
if k != "" {
|
|
labelMap[k] = v
|
|
}
|
|
}
|
|
if len(labelMap) > 0 {
|
|
resMeta["labels"] = labelMap
|
|
}
|
|
}
|
|
result.Nodes = append(result.Nodes, &graph.Node{
|
|
ID: resID, Kind: graph.KindResource, Name: name,
|
|
QualName: kind + "/" + name,
|
|
FilePath: filePath, StartLine: resLine, EndLine: resLine,
|
|
Language: "yaml",
|
|
Meta: resMeta,
|
|
})
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: fileID, To: resID, Kind: graph.EdgeDefines,
|
|
FilePath: filePath, Line: resLine,
|
|
})
|
|
|
|
switch kind {
|
|
case "Deployment", "StatefulSet", "DaemonSet", "ReplicaSet", "Job", "Pod":
|
|
extractK8sPodSpec(filePath, resID, namespace, podSpecOf(root, kind), result)
|
|
case "CronJob":
|
|
// CronJob: spec.jobTemplate.spec.template.spec
|
|
spec := mappingGet(root, "spec")
|
|
jt := mappingGet(spec, "jobTemplate")
|
|
jtSpec := mappingGet(jt, "spec")
|
|
template := mappingGet(jtSpec, "template")
|
|
extractK8sPodSpec(filePath, resID, namespace, mappingGet(template, "spec"), result)
|
|
case "Service":
|
|
extractK8sServicePorts(filePath, resID, mappingGet(root, "spec"), result)
|
|
case "Ingress":
|
|
extractK8sIngressBackends(filePath, resID, namespace, mappingGet(root, "spec"), result)
|
|
case "ConfigMap":
|
|
extractK8sConfigMapData(filePath, resID, name, mappingGet(root, "data"), "k8s_cm", result)
|
|
case "Secret":
|
|
extractK8sConfigMapData(filePath, resID, name, mappingGet(root, "data"), "k8s_secret", result)
|
|
extractK8sConfigMapData(filePath, resID, name, mappingGet(root, "stringData"), "k8s_secret", result)
|
|
}
|
|
}
|
|
|
|
// podSpecOf returns the PodSpec node for a workload manifest. For Pod
|
|
// the spec lives at `.spec`; for the rest it's at `.spec.template.spec`.
|
|
func podSpecOf(root *yaml.Node, kind string) *yaml.Node {
|
|
spec := mappingGet(root, "spec")
|
|
if spec == nil {
|
|
return nil
|
|
}
|
|
if kind == "Pod" {
|
|
return spec
|
|
}
|
|
template := mappingGet(spec, "template")
|
|
return mappingGet(template, "spec")
|
|
}
|
|
|
|
// extractK8sPodSpec walks containers and volumes of a PodSpec.
|
|
// resID is the workload Resource node ID. namespace is propagated to
|
|
// referenced ConfigMap/Secret/PVC names so we can build cross-resource
|
|
// IDs.
|
|
func extractK8sPodSpec(filePath, resID, namespace string, podSpec *yaml.Node, result *parser.ExtractionResult) {
|
|
if podSpec == nil {
|
|
return
|
|
}
|
|
containers := append(sequenceItems(mappingGet(podSpec, "containers")),
|
|
sequenceItems(mappingGet(podSpec, "initContainers"))...)
|
|
volumes := mappingGet(podSpec, "volumes")
|
|
for _, c := range containers {
|
|
extractK8sContainer(filePath, resID, namespace, c, result)
|
|
}
|
|
if volumes != nil {
|
|
for _, v := range sequenceItems(volumes) {
|
|
extractK8sVolume(filePath, resID, namespace, v, result)
|
|
}
|
|
}
|
|
}
|
|
|
|
func extractK8sContainer(filePath, resID, namespace string, container *yaml.Node, result *parser.ExtractionResult) {
|
|
if container == nil {
|
|
return
|
|
}
|
|
line := container.Line
|
|
if line <= 0 {
|
|
line = 1
|
|
}
|
|
// container.image — link Resource → Image.
|
|
if image := scalarOf(mappingGet(container, "image")); image != "" {
|
|
imageID := imageNodeID(image)
|
|
ref, tag := splitImageRef(image)
|
|
result.Nodes = append(result.Nodes, &graph.Node{
|
|
ID: imageID, Kind: graph.KindImage, Name: image,
|
|
FilePath: filePath, StartLine: line, EndLine: line,
|
|
Language: "yaml",
|
|
Meta: map[string]any{
|
|
"role": "base",
|
|
"ref": ref,
|
|
"tag": tag,
|
|
},
|
|
})
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: resID, To: imageID, Kind: graph.EdgeDependsOn,
|
|
FilePath: filePath, Line: line,
|
|
})
|
|
}
|
|
|
|
// container.env[] — direct env vars.
|
|
for _, env := range sequenceItems(mappingGet(container, "env")) {
|
|
envName := scalarOf(mappingGet(env, "name"))
|
|
if envName == "" {
|
|
continue
|
|
}
|
|
envLine := env.Line
|
|
if envLine <= 0 {
|
|
envLine = line
|
|
}
|
|
keyID := configKeyEnvID(envName)
|
|
emitConfigKeyNode(result, keyID, envName, "env", "k8s",
|
|
filePath, envLine)
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: resID, To: keyID, Kind: graph.EdgeUsesEnv,
|
|
FilePath: filePath, Line: envLine,
|
|
Meta: map[string]any{"scope": "runtime"},
|
|
})
|
|
// valueFrom: configMapKeyRef / secretKeyRef → EdgeConfigures
|
|
// to the source resource AND a typed config_key node for
|
|
// the source key.
|
|
if vf := mappingGet(env, "valueFrom"); vf != nil {
|
|
if cmRef := mappingGet(vf, "configMapKeyRef"); cmRef != nil {
|
|
cmName := scalarOf(mappingGet(cmRef, "name"))
|
|
cmKey := scalarOf(mappingGet(cmRef, "key"))
|
|
if cmName != "" {
|
|
target := k8sResourceID("ConfigMap", namespace, cmName)
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: resID, To: target, Kind: graph.EdgeConfigures,
|
|
FilePath: filePath, Line: envLine,
|
|
Meta: map[string]any{
|
|
"via": "configMapKeyRef",
|
|
"key": cmKey,
|
|
"into": envName,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
if secRef := mappingGet(vf, "secretKeyRef"); secRef != nil {
|
|
secName := scalarOf(mappingGet(secRef, "name"))
|
|
secKey := scalarOf(mappingGet(secRef, "key"))
|
|
if secName != "" {
|
|
target := k8sResourceID("Secret", namespace, secName)
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: resID, To: target, Kind: graph.EdgeConfigures,
|
|
FilePath: filePath, Line: envLine,
|
|
Meta: map[string]any{
|
|
"via": "secretKeyRef",
|
|
"key": secKey,
|
|
"into": envName,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// container.envFrom[] — bulk env from ConfigMap or Secret.
|
|
for _, envFrom := range sequenceItems(mappingGet(container, "envFrom")) {
|
|
envLine := envFrom.Line
|
|
if envLine <= 0 {
|
|
envLine = line
|
|
}
|
|
if cmRef := mappingGet(envFrom, "configMapRef"); cmRef != nil {
|
|
if cmName := scalarOf(mappingGet(cmRef, "name")); cmName != "" {
|
|
target := k8sResourceID("ConfigMap", namespace, cmName)
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: resID, To: target, Kind: graph.EdgeConfigures,
|
|
FilePath: filePath, Line: envLine,
|
|
Meta: map[string]any{"via": "envFrom.configMapRef"},
|
|
})
|
|
}
|
|
}
|
|
if secRef := mappingGet(envFrom, "secretRef"); secRef != nil {
|
|
if secName := scalarOf(mappingGet(secRef, "name")); secName != "" {
|
|
target := k8sResourceID("Secret", namespace, secName)
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: resID, To: target, Kind: graph.EdgeConfigures,
|
|
FilePath: filePath, Line: envLine,
|
|
Meta: map[string]any{"via": "envFrom.secretRef"},
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// container.ports[] — EdgeExposes.
|
|
for _, p := range sequenceItems(mappingGet(container, "ports")) {
|
|
port := mappingScalarInt(mappingGet(p, "containerPort"))
|
|
if port == 0 {
|
|
continue
|
|
}
|
|
proto := strings.ToLower(scalarOf(mappingGet(p, "protocol")))
|
|
if proto == "" {
|
|
proto = "tcp"
|
|
}
|
|
pLine := p.Line
|
|
if pLine <= 0 {
|
|
pLine = line
|
|
}
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: resID, To: portTargetID(proto, port),
|
|
Kind: graph.EdgeExposes,
|
|
FilePath: filePath, Line: pLine,
|
|
Meta: map[string]any{"proto": proto, "port": port},
|
|
})
|
|
}
|
|
}
|
|
|
|
// extractK8sVolume walks a single PodSpec.spec.volumes[] entry and
|
|
// emits EdgeMounts from the Resource to the volume source's
|
|
// referenced ConfigMap / Secret / PVC.
|
|
func extractK8sVolume(filePath, resID, namespace string, vol *yaml.Node, result *parser.ExtractionResult) {
|
|
if vol == nil {
|
|
return
|
|
}
|
|
line := vol.Line
|
|
if line <= 0 {
|
|
line = 1
|
|
}
|
|
if cm := mappingGet(vol, "configMap"); cm != nil {
|
|
if cmName := scalarOf(mappingGet(cm, "name")); cmName != "" {
|
|
target := k8sResourceID("ConfigMap", namespace, cmName)
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: resID, To: target, Kind: graph.EdgeMounts,
|
|
FilePath: filePath, Line: line,
|
|
Meta: map[string]any{"via": "volume.configMap"},
|
|
})
|
|
}
|
|
}
|
|
if sec := mappingGet(vol, "secret"); sec != nil {
|
|
if secName := scalarOf(mappingGet(sec, "secretName")); secName != "" {
|
|
target := k8sResourceID("Secret", namespace, secName)
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: resID, To: target, Kind: graph.EdgeMounts,
|
|
FilePath: filePath, Line: line,
|
|
Meta: map[string]any{"via": "volume.secret"},
|
|
})
|
|
}
|
|
}
|
|
if pvc := mappingGet(vol, "persistentVolumeClaim"); pvc != nil {
|
|
if pvcName := scalarOf(mappingGet(pvc, "claimName")); pvcName != "" {
|
|
target := k8sResourceID("PersistentVolumeClaim", namespace, pvcName)
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: resID, To: target, Kind: graph.EdgeMounts,
|
|
FilePath: filePath, Line: line,
|
|
Meta: map[string]any{"via": "volume.pvc"},
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func extractK8sServicePorts(filePath, resID string, spec *yaml.Node, result *parser.ExtractionResult) {
|
|
if spec == nil {
|
|
return
|
|
}
|
|
for _, p := range sequenceItems(mappingGet(spec, "ports")) {
|
|
port := mappingScalarInt(mappingGet(p, "port"))
|
|
if port == 0 {
|
|
continue
|
|
}
|
|
proto := strings.ToLower(scalarOf(mappingGet(p, "protocol")))
|
|
if proto == "" {
|
|
proto = "tcp"
|
|
}
|
|
pLine := p.Line
|
|
if pLine <= 0 {
|
|
pLine = 1
|
|
}
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: resID, To: portTargetID(proto, port),
|
|
Kind: graph.EdgeExposes,
|
|
FilePath: filePath, Line: pLine,
|
|
Meta: map[string]any{"proto": proto, "port": port},
|
|
})
|
|
}
|
|
}
|
|
|
|
// extractK8sIngressBackends links an Ingress to each backing Service
|
|
// referenced from its rules. Both v1 (.backend.service.name) and v1
|
|
// path entries are supported; older v1beta1 shapes
|
|
// (.backend.serviceName) are handled too.
|
|
func extractK8sIngressBackends(filePath, resID, namespace string, spec *yaml.Node, result *parser.ExtractionResult) {
|
|
if spec == nil {
|
|
return
|
|
}
|
|
emitBackend := func(backend *yaml.Node, line int) {
|
|
if backend == nil {
|
|
return
|
|
}
|
|
// v1: backend.service.name
|
|
if svc := mappingGet(backend, "service"); svc != nil {
|
|
if svcName := scalarOf(mappingGet(svc, "name")); svcName != "" {
|
|
target := k8sResourceID("Service", namespace, svcName)
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: resID, To: target, Kind: graph.EdgeDependsOn,
|
|
FilePath: filePath, Line: line,
|
|
Meta: map[string]any{"via": "ingress.service"},
|
|
})
|
|
}
|
|
}
|
|
// v1beta1: backend.serviceName
|
|
if svcName := scalarOf(mappingGet(backend, "serviceName")); svcName != "" {
|
|
target := k8sResourceID("Service", namespace, svcName)
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: resID, To: target, Kind: graph.EdgeDependsOn,
|
|
FilePath: filePath, Line: line,
|
|
Meta: map[string]any{"via": "ingress.serviceName"},
|
|
})
|
|
}
|
|
}
|
|
// Default backend.
|
|
if def := mappingGet(spec, "defaultBackend"); def != nil {
|
|
emitBackend(def, def.Line)
|
|
}
|
|
for _, rule := range sequenceItems(mappingGet(spec, "rules")) {
|
|
http := mappingGet(rule, "http")
|
|
for _, p := range sequenceItems(mappingGet(http, "paths")) {
|
|
emitBackend(mappingGet(p, "backend"), p.Line)
|
|
}
|
|
}
|
|
}
|
|
|
|
// extractK8sConfigMapData materialises one KindConfigKey per data-map
|
|
// key. source ∈ "k8s_cm" | "k8s_secret".
|
|
func extractK8sConfigMapData(filePath, resID, resName string, data *yaml.Node, source string, result *parser.ExtractionResult) {
|
|
if data == nil || data.Kind != yaml.MappingNode {
|
|
return
|
|
}
|
|
for i := 0; i+1 < len(data.Content); i += 2 {
|
|
k := data.Content[i]
|
|
if k == nil {
|
|
continue
|
|
}
|
|
key := k.Value
|
|
if key == "" {
|
|
continue
|
|
}
|
|
line := k.Line
|
|
if line <= 0 {
|
|
line = 1
|
|
}
|
|
id := "cfg::" + source + "::" + resName + "::" + key
|
|
emitConfigKeyNode(result, id, key, source, "k8s",
|
|
filePath, line)
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: resID, To: id, Kind: graph.EdgeDefines,
|
|
FilePath: filePath, Line: line,
|
|
})
|
|
}
|
|
}
|
|
|
|
func emitConfigKeyNode(result *parser.ExtractionResult, id, name, source, origin string, filePath string, line int) {
|
|
result.Nodes = append(result.Nodes, &graph.Node{
|
|
ID: id, Kind: graph.KindConfigKey, Name: name,
|
|
FilePath: filePath, StartLine: line, EndLine: line,
|
|
Language: "yaml",
|
|
Meta: map[string]any{
|
|
"source": source,
|
|
"origin": origin,
|
|
},
|
|
})
|
|
}
|
|
|
|
// k8sResourceID is the canonical ID for a Kubernetes Resource node.
|
|
// It is shared by every site that needs to refer to the same
|
|
// resource across documents — the workload pointing at a ConfigMap
|
|
// (envFrom) and the ConfigMap document itself produce the same
|
|
// target ID, so the cross-doc edge always lands on a single node.
|
|
func k8sResourceID(kind, namespace, name string) string {
|
|
if namespace == "" {
|
|
namespace = "_default"
|
|
}
|
|
return "k8s::" + kind + "::" + namespace + "::" + name
|
|
}
|
|
|
|
// ----- yaml.Node helpers ------------------------------------------------
|
|
|
|
// documentMapping returns the top-level MappingNode for a parsed YAML
|
|
// document. yaml.v3 wraps each Decode result in a DocumentNode whose
|
|
// single content child is the actual root.
|
|
func documentMapping(doc *yaml.Node) *yaml.Node {
|
|
if doc == nil {
|
|
return nil
|
|
}
|
|
if doc.Kind == yaml.DocumentNode && len(doc.Content) > 0 {
|
|
doc = doc.Content[0]
|
|
}
|
|
if doc == nil || doc.Kind != yaml.MappingNode {
|
|
return nil
|
|
}
|
|
return doc
|
|
}
|
|
|
|
// mappingGet returns the value node for the given key inside a
|
|
// MappingNode. Returns nil when the parent isn't a mapping or the
|
|
// key is absent.
|
|
func mappingGet(parent *yaml.Node, key string) *yaml.Node {
|
|
if parent == nil || parent.Kind != yaml.MappingNode {
|
|
return nil
|
|
}
|
|
for i := 0; i+1 < len(parent.Content); i += 2 {
|
|
k := parent.Content[i]
|
|
if k != nil && k.Value == key {
|
|
return parent.Content[i+1]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// scalarOf returns the .Value of a scalar node, or "" when nil/non-scalar.
|
|
func scalarOf(n *yaml.Node) string {
|
|
if n == nil || n.Kind != yaml.ScalarNode {
|
|
return ""
|
|
}
|
|
return n.Value
|
|
}
|
|
|
|
// sequenceItems returns the items of a SequenceNode, or nil for non-sequences.
|
|
func sequenceItems(n *yaml.Node) []*yaml.Node {
|
|
if n == nil || n.Kind != yaml.SequenceNode {
|
|
return nil
|
|
}
|
|
out := make([]*yaml.Node, 0, len(n.Content))
|
|
for _, c := range n.Content {
|
|
if c != nil {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// mappingScalarInt parses a scalar yaml.Node as an integer. Returns 0
|
|
// when the node is missing, non-scalar, or non-numeric. Used for
|
|
// parsing port numbers, replica counts, etc.
|
|
func mappingScalarInt(n *yaml.Node) int {
|
|
if n == nil || n.Kind != yaml.ScalarNode {
|
|
return 0
|
|
}
|
|
if v, err := strconv.Atoi(strings.TrimSpace(n.Value)); err == nil {
|
|
return v
|
|
}
|
|
return 0
|
|
}
|