From 9c256afc2762594a2ecd90522c969c198b26ad80 Mon Sep 17 00:00:00 2001 From: Eric Pickard Date: Fri, 6 Feb 2026 17:56:41 -0500 Subject: [PATCH 1/4] add AggregatePodMetadata to track metadata of pod and its owners, add runtime risk tracking --- .gitignore | 1 + README.md | 12 ++++ deploy/manifest.yaml | 11 +-- internal/controller/controller.go | 107 ++++++++++++++++++++++++++++++ pkg/deploymentrecord/record.go | 42 +++++++++--- 5 files changed, 160 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 9fd4deb..ab8defe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ *~ /deployment-tracker +.idea/ diff --git a/README.md b/README.md index ac39a2c..d7f2ee6 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ deployment records to GitHub's artifact metadata API. - **Real-time tracking**: Sends deployment records when pods are created or deleted - **Graceful shutdown**: Properly drains work queue before terminating +- **Runtime risks**: Track runtime risks through annotations ## How It Works @@ -82,6 +83,17 @@ The `DN_TEMPLATE` supports the following placeholders: - `{{deploymentName}}` - Name of the owning Deployment - `{{containerName}}` - Container name +## Runtime Risks + +You can track runtime risks through annotations. Add the annotation `github.com/runtime-risks`, with a comma-separated list of supported runtime risk values. Annotations are aggregated from the pod and its owner reference objects. + +Currently supported runtime risks: +- `critical-resource` +- `lateral-movement` +- `internet-exposed` +- `sensitive-data` + + ## Kubernetes Deployment A complete deployment manifest is provided in `deploy/manifest.yaml` diff --git a/deploy/manifest.yaml b/deploy/manifest.yaml index cd86ce9..a8f2c95 100644 --- a/deploy/manifest.yaml +++ b/deploy/manifest.yaml @@ -17,9 +17,12 @@ rules: - apiGroups: [""] resources: ["pods"] verbs: ["get", "list", "watch"] - - apiGroups: ["apps"] - resources: ["deployments"] - verbs: ["get"] + - apiGroups: [ "apps" ] + resources: [ "deployments" ] + verbs: [ "get" ] + - apiGroups: [ "apps" ] + resources: [ "replicasets" ] + verbs: [ "get" ] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding @@ -66,7 +69,7 @@ spec: - name: PHYSICAL_ENVIRONMENT value: "iad-moda1" - name: CLUSTER - value: "kommendorkapten" + value: "test-cluster" - name: BASE_URL value: "http://artifact-registry.artifact-registry.svc.cluster.local:9090" resources: diff --git a/internal/controller/controller.go b/internal/controller/controller.go index 2319110..eba7d9c 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -29,6 +29,8 @@ const ( EventCreated = "CREATED" // EventDeleted indicates that a pod has been deleted. EventDeleted = "DELETED" + // RuntimeRiskAnnotationKey represents the annotation key for runtime risks. + RuntimeRiskAnnotationKey = "github.com/runtime-risks" ) // PodEvent represents a pod event to be processed. @@ -38,6 +40,11 @@ type PodEvent struct { DeletedPod *corev1.Pod // Only populated for delete events } +// AggregatePodMetadata represents combined metadata for a pod and its ownership hierarchy. +type AggregatePodMetadata struct { + RuntimeRisks map[deploymentrecord.RuntimeRisk]bool +} + // Controller is the Kubernetes controller for tracking deployments. type Controller struct { clientset kubernetes.Interface @@ -414,6 +421,15 @@ func (c *Controller) recordContainer(ctx context.Context, pod *corev1.Pod, conta // Extract image name and tag imageName, version := image.ExtractName(container.Image) + // Gather aggregate metadata + metadata := c.aggregateMetadata(ctx, pod) + var runtimeRisks []deploymentrecord.RuntimeRisk + if status != deploymentrecord.StatusDecommissioned { + for risk := range metadata.RuntimeRisks { + runtimeRisks = append(runtimeRisks, risk) + } + } + // Create deployment record record := deploymentrecord.NewDeploymentRecord( imageName, @@ -424,6 +440,7 @@ func (c *Controller) recordContainer(ctx context.Context, pod *corev1.Pod, conta c.cfg.Cluster, status, dn, + runtimeRisks, ) if err := c.apiClient.PostOne(ctx, record); err != nil { @@ -457,6 +474,7 @@ func (c *Controller) recordContainer(ctx context.Context, pod *corev1.Pod, conta "name", record.Name, "deployment_name", record.DeploymentName, "status", record.Status, + "runtime_risks", record.RuntimeRisks, "digest", record.Digest, ) @@ -473,6 +491,82 @@ func (c *Controller) recordContainer(ctx context.Context, pod *corev1.Pod, conta return nil } +// aggregateRuntimeRisks aggregates metadata for a pod and its owners. +func (c *Controller) aggregateMetadata(ctx context.Context, obj metav1.Object) AggregatePodMetadata { + metadata := AggregatePodMetadata{ + RuntimeRisks: make(map[deploymentrecord.RuntimeRisk]bool), + } + visited := make(map[string]bool) + + getMetadataFromObject(obj, metadata) + c.getMetadataFromOwners(ctx, obj, metadata, visited) + + return metadata +} + +// collectRuntimeRisksFromOwners recursively collects metadata from owner references +// in the ownership chain +// Visited map prevents infinite recursion on circular ownership references. +func (c *Controller) getMetadataFromOwners(ctx context.Context, obj metav1.Object, metadata AggregatePodMetadata, visited map[string]bool) { + ownerRefs := obj.GetOwnerReferences() + + for _, owner := range ownerRefs { + ownerKey := fmt.Sprintf("%s/%s/%s", obj.GetNamespace(), owner.Kind, owner.Name) + if visited[ownerKey] { + slog.Debug("Cycle detected in ownership chain, skipping", + "namespace", obj.GetNamespace(), + "owner_kind", owner.Kind, + "owner_name", owner.Name, + ) + continue + } + visited[ownerKey] = true + + ownerObj, err := c.getOwnerObject(ctx, obj.GetNamespace(), owner) + if err != nil { + slog.Warn("Failed to get owner object for metadata collection", + "namespace", obj.GetNamespace(), + "owner_kind", owner.Kind, + "owner_name", owner.Name, + "error", err, + ) + continue + } + + if ownerObj == nil { + continue + } + + getMetadataFromObject(ownerObj, metadata) + c.getMetadataFromOwners(ctx, ownerObj, metadata, visited) + } +} + +// getOwnerObject retrieves the owner object based on its kind, namespace, and name. +func (c *Controller) getOwnerObject(ctx context.Context, namespace string, owner metav1.OwnerReference) (metav1.Object, error) { + switch owner.Kind { + case "ReplicaSet": + rs, err := c.clientset.AppsV1().ReplicaSets(namespace).Get(ctx, owner.Name, metav1.GetOptions{}) + if err != nil { + return nil, err + } + return rs, nil + case "Deployment": + deployment, err := c.clientset.AppsV1().Deployments(namespace).Get(ctx, owner.Name, metav1.GetOptions{}) + if err != nil { + return nil, err + } + return deployment, nil + default: + // Unsupported kinds + slog.Debug("Unsupported owner kind for runtime risk collection", + "kind", owner.Kind, + "name", owner.Name, + ) + return nil, nil + } +} + func getCacheKey(dn, digest string) string { return dn + "||" + digest } @@ -580,3 +674,16 @@ func getDeploymentName(pod *corev1.Pod) string { } return "" } + +// getMetadataFromObject extracts metadata from an object. +func getMetadataFromObject(obj metav1.Object, metadata AggregatePodMetadata) { + annotations := obj.GetAnnotations() + if risks, exists := annotations[RuntimeRiskAnnotationKey]; exists { + for _, risk := range strings.Split(risks, ",") { + r := deploymentrecord.ValidateRuntimeRisk(strings.TrimSpace(risk)) + if r != "" { + metadata.RuntimeRisks[r] = true + } + } + } +} diff --git a/pkg/deploymentrecord/record.go b/pkg/deploymentrecord/record.go index 842242e..511c3cf 100644 --- a/pkg/deploymentrecord/record.go +++ b/pkg/deploymentrecord/record.go @@ -6,16 +6,27 @@ const ( StatusDecommissioned = "decommissioned" ) +// Runtime risks for deployment records. +type RuntimeRisk string + +const ( + CriticalResource RuntimeRisk = "critical-resource" + InternetExposed RuntimeRisk = "internet-exposed" + LateralMovement RuntimeRisk = "lateral-movement" + SensitiveData RuntimeRisk = "sensitive-data" +) + // DeploymentRecord represents a deployment event record. type DeploymentRecord struct { - Name string `json:"name"` - Digest string `json:"digest"` - Version string `json:"version,omitempty"` - LogicalEnvironment string `json:"logical_environment"` - PhysicalEnvironment string `json:"physical_environment"` - Cluster string `json:"cluster"` - Status string `json:"status"` - DeploymentName string `json:"deployment_name"` + Name string `json:"name"` + Digest string `json:"digest"` + Version string `json:"version,omitempty"` + LogicalEnvironment string `json:"logical_environment"` + PhysicalEnvironment string `json:"physical_environment"` + Cluster string `json:"cluster"` + Status string `json:"status"` + DeploymentName string `json:"deployment_name"` + RuntimeRisks []RuntimeRisk `json:"runtime_risks,omitempty"` } // NewDeploymentRecord creates a new DeploymentRecord with the given status. @@ -23,7 +34,7 @@ type DeploymentRecord struct { // //nolint:revive func NewDeploymentRecord(name, digest, version, logicalEnv, physicalEnv, - cluster, status, deploymentName string) *DeploymentRecord { + cluster, status, deploymentName string, runtimeRisks []RuntimeRisk) *DeploymentRecord { // Validate status if status != StatusDeployed && status != StatusDecommissioned { status = StatusDeployed // default to deployed if invalid @@ -38,5 +49,18 @@ func NewDeploymentRecord(name, digest, version, logicalEnv, physicalEnv, Cluster: cluster, Status: status, DeploymentName: deploymentName, + RuntimeRisks: runtimeRisks, + } +} + +// ValidateRuntimeRisk confirms is string is a valid runtime risk, +// then returns the canonical runtime risk constant if valid, empty string otherwise. +func ValidateRuntimeRisk(risk string) RuntimeRisk { + r := RuntimeRisk(risk) + switch r { + case CriticalResource, InternetExposed, LateralMovement, SensitiveData: + return r + default: + return "" } } From e1e14b2ef5e9f1ce899040f924acbe609c00ab49 Mon Sep 17 00:00:00 2001 From: Eric Pickard Date: Mon, 9 Feb 2026 14:47:21 -0500 Subject: [PATCH 2/4] simplify recursion to iteration, don't get metadata for delete events --- internal/controller/controller.go | 53 ++++++++++++++++--------------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/internal/controller/controller.go b/internal/controller/controller.go index eba7d9c..f0071e0 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -12,6 +12,7 @@ import ( "github.com/github/deployment-tracker/pkg/deploymentrecord" "github.com/github/deployment-tracker/pkg/image" "github.com/github/deployment-tracker/pkg/metrics" + "k8s.io/apimachinery/pkg/types" corev1 "k8s.io/api/core/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" @@ -421,10 +422,10 @@ func (c *Controller) recordContainer(ctx context.Context, pod *corev1.Pod, conta // Extract image name and tag imageName, version := image.ExtractName(container.Image) - // Gather aggregate metadata - metadata := c.aggregateMetadata(ctx, pod) + // Gather aggregate metadata for adds/updates var runtimeRisks []deploymentrecord.RuntimeRisk if status != deploymentrecord.StatusDecommissioned { + metadata := c.aggregateMetadata(ctx, pod) for risk := range metadata.RuntimeRisks { runtimeRisks = append(runtimeRisks, risk) } @@ -496,36 +497,39 @@ func (c *Controller) aggregateMetadata(ctx context.Context, obj metav1.Object) A metadata := AggregatePodMetadata{ RuntimeRisks: make(map[deploymentrecord.RuntimeRisk]bool), } - visited := make(map[string]bool) + queue := []metav1.Object{obj} + visited := make(map[types.UID]bool) - getMetadataFromObject(obj, metadata) - c.getMetadataFromOwners(ctx, obj, metadata, visited) + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + + if visited[current.GetUID()] { + slog.Warn("Already visited object, skipping to avoid cycles", + "UID", current.GetUID(), + "name", current.GetName(), + ) + continue + } + visited[current.GetUID()] = true + + getMetadataFromObject(current, metadata) + c.addOwnersToQueue(ctx, current, &queue) + } return metadata } -// collectRuntimeRisksFromOwners recursively collects metadata from owner references -// in the ownership chain -// Visited map prevents infinite recursion on circular ownership references. -func (c *Controller) getMetadataFromOwners(ctx context.Context, obj metav1.Object, metadata AggregatePodMetadata, visited map[string]bool) { - ownerRefs := obj.GetOwnerReferences() +// collectRuntimeRisksFromOwners takes a current object and looks up its owners, adding them to the queue for processing +// to collect their metadata. +func (c *Controller) addOwnersToQueue(ctx context.Context, current metav1.Object, queue *[]metav1.Object) { + ownerRefs := current.GetOwnerReferences() for _, owner := range ownerRefs { - ownerKey := fmt.Sprintf("%s/%s/%s", obj.GetNamespace(), owner.Kind, owner.Name) - if visited[ownerKey] { - slog.Debug("Cycle detected in ownership chain, skipping", - "namespace", obj.GetNamespace(), - "owner_kind", owner.Kind, - "owner_name", owner.Name, - ) - continue - } - visited[ownerKey] = true - - ownerObj, err := c.getOwnerObject(ctx, obj.GetNamespace(), owner) + ownerObj, err := c.getOwnerObject(ctx, current.GetNamespace(), owner) if err != nil { slog.Warn("Failed to get owner object for metadata collection", - "namespace", obj.GetNamespace(), + "namespace", current.GetNamespace(), "owner_kind", owner.Kind, "owner_name", owner.Name, "error", err, @@ -537,8 +541,7 @@ func (c *Controller) getMetadataFromOwners(ctx context.Context, obj metav1.Objec continue } - getMetadataFromObject(ownerObj, metadata) - c.getMetadataFromOwners(ctx, ownerObj, metadata, visited) + *queue = append(*queue, ownerObj) } } From f83161c264e5b8e6600ed15dbde12d1eb2fc92a3 Mon Sep 17 00:00:00 2001 From: Eric Pickard Date: Mon, 9 Feb 2026 15:31:34 -0500 Subject: [PATCH 3/4] add metadata client, switch to using PartialObjectMetadata --- README.md | 8 +- cmd/deployment-tracker/main.go | 11 ++- deploy/manifest.yaml | 12 +-- internal/controller/controller.go | 122 +++++++++++++++++----------- pkg/deploymentrecord/record.go | 27 ++++-- pkg/deploymentrecord/record_test.go | 41 ++++++++++ 6 files changed, 152 insertions(+), 69 deletions(-) create mode 100644 pkg/deploymentrecord/record_test.go diff --git a/README.md b/README.md index d7f2ee6..81c6e28 100644 --- a/README.md +++ b/README.md @@ -87,11 +87,7 @@ The `DN_TEMPLATE` supports the following placeholders: You can track runtime risks through annotations. Add the annotation `github.com/runtime-risks`, with a comma-separated list of supported runtime risk values. Annotations are aggregated from the pod and its owner reference objects. -Currently supported runtime risks: -- `critical-resource` -- `lateral-movement` -- `internet-exposed` -- `sensitive-data` +Currently supported runtime risks can be found in the [Create Deployment Record API docs](https://docs.github.com/en/rest/orgs/artifact-metadata?apiVersion=2022-11-28#create-an-artifact-deployment-record). Invalid runtime risk values will be ignored. ## Kubernetes Deployment @@ -101,7 +97,7 @@ which includes: - **Namespace**: `deployment-tracker` - **ServiceAccount**: Identity for the controller pod -- **ClusterRole**: Minimal permissions (`get`, `list`, `watch` on pods) +- **ClusterRole**: Minimal permissions (`get`, `list`, `watch` on pods; `get` on other supported objects) - **ClusterRoleBinding**: Binds the ServiceAccount to the ClusterRole - **Deployment**: Runs the controller with security hardening diff --git a/cmd/deployment-tracker/main.go b/cmd/deployment-tracker/main.go index d4fc349..b447e5c 100644 --- a/cmd/deployment-tracker/main.go +++ b/cmd/deployment-tracker/main.go @@ -13,6 +13,7 @@ import ( "time" "github.com/github/deployment-tracker/internal/controller" + "k8s.io/client-go/metadata" "github.com/prometheus/client_golang/prometheus/promhttp" "k8s.io/client-go/kubernetes" @@ -112,6 +113,14 @@ func main() { os.Exit(1) } + // Create metadata client + metadataClient, err := metadata.NewForConfig(k8sCfg) + if err != nil { + slog.Error("Error creating Kubernetes metadata client", + "error", err) + os.Exit(1) + } + // Start the metrics server var promSrv = &http.Server{ Addr: ":" + metricsPort, @@ -151,7 +160,7 @@ func main() { cancel() }() - cntrl, err := controller.New(clientset, namespace, excludeNamespaces, &cntrlCfg) + cntrl, err := controller.New(clientset, metadataClient, namespace, excludeNamespaces, &cntrlCfg) if err != nil { slog.Error("Failed to create controller", "error", err) diff --git a/deploy/manifest.yaml b/deploy/manifest.yaml index a8f2c95..f5b8e72 100644 --- a/deploy/manifest.yaml +++ b/deploy/manifest.yaml @@ -17,12 +17,12 @@ rules: - apiGroups: [""] resources: ["pods"] verbs: ["get", "list", "watch"] - - apiGroups: [ "apps" ] - resources: [ "deployments" ] - verbs: [ "get" ] - - apiGroups: [ "apps" ] - resources: [ "replicasets" ] - verbs: [ "get" ] + - apiGroups: ["apps"] + resources: ["deployments"] + verbs: ["get"] + - apiGroups: ["apps"] + resources: ["replicasets"] + verbs: ["get"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding diff --git a/internal/controller/controller.go b/internal/controller/controller.go index f0071e0..5c9a24d 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -12,7 +12,9 @@ import ( "github.com/github/deployment-tracker/pkg/deploymentrecord" "github.com/github/deployment-tracker/pkg/image" "github.com/github/deployment-tracker/pkg/metrics" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/metadata" corev1 "k8s.io/api/core/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" @@ -48,11 +50,12 @@ type AggregatePodMetadata struct { // Controller is the Kubernetes controller for tracking deployments. type Controller struct { - clientset kubernetes.Interface - podInformer cache.SharedIndexInformer - workqueue workqueue.TypedRateLimitingInterface[PodEvent] - apiClient *deploymentrecord.Client - cfg *Config + clientset kubernetes.Interface + metadataClient metadata.Interface + podInformer cache.SharedIndexInformer + workqueue workqueue.TypedRateLimitingInterface[PodEvent] + apiClient *deploymentrecord.Client + cfg *Config // best effort cache to avoid redundant posts // post requests are idempotent, so if this cache fails due to // restarts or other events, nothing will break. @@ -60,7 +63,7 @@ type Controller struct { } // New creates a new deployment tracker controller. -func New(clientset kubernetes.Interface, namespace string, excludeNamespaces string, cfg *Config) (*Controller, error) { +func New(clientset kubernetes.Interface, metadataClient metadata.Interface, namespace string, excludeNamespaces string, cfg *Config) (*Controller, error) { // Create informer factory factory := createInformerFactory(clientset, namespace, excludeNamespaces) @@ -92,11 +95,12 @@ func New(clientset kubernetes.Interface, namespace string, excludeNamespaces str } cntrl := &Controller{ - clientset: clientset, - podInformer: podInformer, - workqueue: queue, - apiClient: apiClient, - cfg: cfg, + clientset: clientset, + metadataClient: metadataClient, + podInformer: podInformer, + workqueue: queue, + apiClient: apiClient, + cfg: cfg, } // Add event handlers to the informer @@ -342,16 +346,25 @@ func (c *Controller) processEvent(ctx context.Context, event PodEvent) error { var lastErr error + // Gather aggregate metadata for adds/updates + var runtimeRisks []deploymentrecord.RuntimeRisk + if status != deploymentrecord.StatusDecommissioned { + aggMetadata := c.aggregateMetadata(ctx, podToPartialMetadata(pod)) + for risk := range aggMetadata.RuntimeRisks { + runtimeRisks = append(runtimeRisks, risk) + } + } + // Record info for each container in the pod for _, container := range pod.Spec.Containers { - if err := c.recordContainer(ctx, pod, container, status, event.EventType); err != nil { + if err := c.recordContainer(ctx, pod, container, status, event.EventType, runtimeRisks); err != nil { lastErr = err } } // Also record init containers for _, container := range pod.Spec.InitContainers { - if err := c.recordContainer(ctx, pod, container, status, event.EventType); err != nil { + if err := c.recordContainer(ctx, pod, container, status, event.EventType, runtimeRisks); err != nil { lastErr = err } } @@ -379,7 +392,7 @@ func (c *Controller) deploymentExists(ctx context.Context, namespace, name strin } // recordContainer records a single container's deployment info. -func (c *Controller) recordContainer(ctx context.Context, pod *corev1.Pod, container corev1.Container, status, eventType string) error { +func (c *Controller) recordContainer(ctx context.Context, pod *corev1.Pod, container corev1.Container, status, eventType string, runtimeRisks []deploymentrecord.RuntimeRisk) error { dn := getARDeploymentName(pod, container, c.cfg.Template) digest := getContainerDigest(pod, container.Name) @@ -422,15 +435,6 @@ func (c *Controller) recordContainer(ctx context.Context, pod *corev1.Pod, conta // Extract image name and tag imageName, version := image.ExtractName(container.Image) - // Gather aggregate metadata for adds/updates - var runtimeRisks []deploymentrecord.RuntimeRisk - if status != deploymentrecord.StatusDecommissioned { - metadata := c.aggregateMetadata(ctx, pod) - for risk := range metadata.RuntimeRisks { - runtimeRisks = append(runtimeRisks, risk) - } - } - // Create deployment record record := deploymentrecord.NewDeploymentRecord( imageName, @@ -492,12 +496,12 @@ func (c *Controller) recordContainer(ctx context.Context, pod *corev1.Pod, conta return nil } -// aggregateRuntimeRisks aggregates metadata for a pod and its owners. -func (c *Controller) aggregateMetadata(ctx context.Context, obj metav1.Object) AggregatePodMetadata { - metadata := AggregatePodMetadata{ +// aggregateMetadata returns aggregated metadata for a pod and its owners. +func (c *Controller) aggregateMetadata(ctx context.Context, obj *metav1.PartialObjectMetadata) AggregatePodMetadata { + aggMetadata := AggregatePodMetadata{ RuntimeRisks: make(map[deploymentrecord.RuntimeRisk]bool), } - queue := []metav1.Object{obj} + queue := []*metav1.PartialObjectMetadata{obj} visited := make(map[types.UID]bool) for len(queue) > 0 { @@ -513,20 +517,20 @@ func (c *Controller) aggregateMetadata(ctx context.Context, obj metav1.Object) A } visited[current.GetUID()] = true - getMetadataFromObject(current, metadata) + extractMetadataFromObject(current, &aggMetadata) c.addOwnersToQueue(ctx, current, &queue) } - return metadata + return aggMetadata } -// collectRuntimeRisksFromOwners takes a current object and looks up its owners, adding them to the queue for processing +// addOwnersToQueue takes a current object and looks up its owners, adding them to the queue for processing // to collect their metadata. -func (c *Controller) addOwnersToQueue(ctx context.Context, current metav1.Object, queue *[]metav1.Object) { +func (c *Controller) addOwnersToQueue(ctx context.Context, current *metav1.PartialObjectMetadata, queue *[]*metav1.PartialObjectMetadata) { ownerRefs := current.GetOwnerReferences() for _, owner := range ownerRefs { - ownerObj, err := c.getOwnerObject(ctx, current.GetNamespace(), owner) + ownerObj, err := c.getOwnerMetadata(ctx, current.GetNamespace(), owner) if err != nil { slog.Warn("Failed to get owner object for metadata collection", "namespace", current.GetNamespace(), @@ -545,29 +549,39 @@ func (c *Controller) addOwnersToQueue(ctx context.Context, current metav1.Object } } -// getOwnerObject retrieves the owner object based on its kind, namespace, and name. -func (c *Controller) getOwnerObject(ctx context.Context, namespace string, owner metav1.OwnerReference) (metav1.Object, error) { +// getOwnerMetadata retrieves partial object metadata for an owner ref. +func (c *Controller) getOwnerMetadata(ctx context.Context, namespace string, owner metav1.OwnerReference) (*metav1.PartialObjectMetadata, error) { + gvr := schema.GroupVersionResource{ + Group: "apps", + Version: "v1", + } + switch owner.Kind { case "ReplicaSet": - rs, err := c.clientset.AppsV1().ReplicaSets(namespace).Get(ctx, owner.Name, metav1.GetOptions{}) - if err != nil { - return nil, err - } - return rs, nil + gvr.Resource = "replicasets" case "Deployment": - deployment, err := c.clientset.AppsV1().Deployments(namespace).Get(ctx, owner.Name, metav1.GetOptions{}) - if err != nil { - return nil, err - } - return deployment, nil + gvr.Resource = "deployments" default: - // Unsupported kinds slog.Debug("Unsupported owner kind for runtime risk collection", "kind", owner.Kind, "name", owner.Name, ) return nil, nil } + + obj, err := c.metadataClient.Resource(gvr).Namespace(namespace).Get(ctx, owner.Name, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + slog.Debug("Owner object not found for metadata collection", + "namespace", namespace, + "owner_kind", owner.Kind, + "owner_name", owner.Name, + ) + return nil, nil + } + return nil, err + } + return obj, nil } func getCacheKey(dn, digest string) string { @@ -678,15 +692,25 @@ func getDeploymentName(pod *corev1.Pod) string { return "" } -// getMetadataFromObject extracts metadata from an object. -func getMetadataFromObject(obj metav1.Object, metadata AggregatePodMetadata) { +// extractMetadataFromObject extracts metadata from an object. +func extractMetadataFromObject(obj *metav1.PartialObjectMetadata, aggMetadata *AggregatePodMetadata) { annotations := obj.GetAnnotations() if risks, exists := annotations[RuntimeRiskAnnotationKey]; exists { for _, risk := range strings.Split(risks, ",") { - r := deploymentrecord.ValidateRuntimeRisk(strings.TrimSpace(risk)) + r := deploymentrecord.ValidateRuntimeRisk(risk) if r != "" { - metadata.RuntimeRisks[r] = true + aggMetadata.RuntimeRisks[r] = true } } } } + +func podToPartialMetadata(pod *corev1.Pod) *metav1.PartialObjectMetadata { + return &metav1.PartialObjectMetadata{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Pod", + }, + ObjectMeta: pod.ObjectMeta, + } +} diff --git a/pkg/deploymentrecord/record.go b/pkg/deploymentrecord/record.go index 511c3cf..2876936 100644 --- a/pkg/deploymentrecord/record.go +++ b/pkg/deploymentrecord/record.go @@ -1,14 +1,20 @@ package deploymentrecord +import ( + "log/slog" + "strings" +) + // Status constants for deployment records. const ( StatusDeployed = "deployed" StatusDecommissioned = "decommissioned" ) -// Runtime risks for deployment records. +// RuntimeRisk for deployment records. type RuntimeRisk string +// Valid runtime risks. const ( CriticalResource RuntimeRisk = "critical-resource" InternetExposed RuntimeRisk = "internet-exposed" @@ -16,6 +22,14 @@ const ( SensitiveData RuntimeRisk = "sensitive-data" ) +// Map of valid runtime risks. +var validRuntimeRisks = map[RuntimeRisk]bool{ + CriticalResource: true, + InternetExposed: true, + LateralMovement: true, + SensitiveData: true, +} + // DeploymentRecord represents a deployment event record. type DeploymentRecord struct { Name string `json:"name"` @@ -53,14 +67,13 @@ func NewDeploymentRecord(name, digest, version, logicalEnv, physicalEnv, } } -// ValidateRuntimeRisk confirms is string is a valid runtime risk, +// ValidateRuntimeRisk confirms if string is a valid runtime risk, // then returns the canonical runtime risk constant if valid, empty string otherwise. func ValidateRuntimeRisk(risk string) RuntimeRisk { - r := RuntimeRisk(risk) - switch r { - case CriticalResource, InternetExposed, LateralMovement, SensitiveData: - return r - default: + r := RuntimeRisk(strings.ToLower(strings.TrimSpace(risk))) + if !validRuntimeRisks[r] { + slog.Debug("Invalid runtime risk", "risk", risk) return "" } + return r } diff --git a/pkg/deploymentrecord/record_test.go b/pkg/deploymentrecord/record_test.go new file mode 100644 index 0000000..f888e2d --- /dev/null +++ b/pkg/deploymentrecord/record_test.go @@ -0,0 +1,41 @@ +package deploymentrecord + +import "testing" + +func TestValidateRuntimeRisk(t *testing.T) { + tests := []struct { + name string + input string + expected RuntimeRisk + }{ + { + name: "valid runtime risk", + input: "critical-resource", + expected: CriticalResource, + }, + { + name: "valid runtime risk with space", + input: "critical-resource ", + expected: CriticalResource, + }, + { + name: "invalid empty string", + input: "", + expected: "", + }, + { + name: "invalid unknown risk", + input: "unknown-risk", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ValidateRuntimeRisk(tt.input) + if result != tt.expected { + t.Errorf("input: %s, actual: %q, wanted: %q", tt.input, result, tt.expected) + } + }) + } +} From 65a5729d62d615a7a9d342c23ae67c0b3b01155a Mon Sep 17 00:00:00 2001 From: Eric Pickard Date: Thu, 12 Feb 2026 12:37:57 -0500 Subject: [PATCH 4/4] focus linter only on new changes when run from PRs --- .github/workflows/lint.yml | 2 ++ internal/controller/controller.go | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 47b99ed..3a71ff9 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -24,3 +24,5 @@ jobs: go-version-file: go.mod - name: golangci-lint uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 + with: + only-new-issues: ${{ github.event_name == 'pull_request' }} diff --git a/internal/controller/controller.go b/internal/controller/controller.go index 5c9a24d..9ac951b 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log/slog" + "slices" "strings" "sync" "time" @@ -353,6 +354,7 @@ func (c *Controller) processEvent(ctx context.Context, event PodEvent) error { for risk := range aggMetadata.RuntimeRisks { runtimeRisks = append(runtimeRisks, risk) } + slices.Sort(runtimeRisks) } // Record info for each container in the pod