-
Notifications
You must be signed in to change notification settings - Fork 2
Aggregate metadata for ownership chain and use annotations for runtime risks #33
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
3ae0779
7fb224e
a0fb341
166c112
b0938e6
4e98645
72688cb
b24a8a4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,3 @@ | ||
| *~ | ||
| /deployment-tracker | ||
| .idea/ |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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` | ||||||||||
|
Comment on lines
+92
to
+93
|
||||||||||
| - `lateral-movement` | |
| - `internet-exposed` | |
| - `internet-exposed` | |
| - `lateral-movement` |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -12,6 +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" | ||||||||||||||||||||||
|
|
@@ -29,6 +32,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,21 +43,27 @@ 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 | ||||||||||||||||||||||
| 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. | ||||||||||||||||||||||
| observedDeployments sync.Map | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // 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) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
@@ -84,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 | ||||||||||||||||||||||
|
|
@@ -334,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 | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
@@ -371,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) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
@@ -424,6 +445,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 +479,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 +496,94 @@ func (c *Controller) recordContainer(ctx context.Context, pod *corev1.Pod, conta | |||||||||||||||||||||
| return nil | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // 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.PartialObjectMetadata{obj} | ||||||||||||||||||||||
| visited := make(map[types.UID]bool) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| 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 | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| extractMetadataFromObject(current, &aggMetadata) | ||||||||||||||||||||||
| c.addOwnersToQueue(ctx, current, &queue) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return aggMetadata | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // 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.PartialObjectMetadata, queue *[]*metav1.PartialObjectMetadata) { | ||||||||||||||||||||||
| ownerRefs := current.GetOwnerReferences() | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| for _, owner := range ownerRefs { | ||||||||||||||||||||||
| ownerObj, err := c.getOwnerMetadata(ctx, current.GetNamespace(), owner) | ||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||
| slog.Warn("Failed to get owner object for metadata collection", | ||||||||||||||||||||||
| "namespace", current.GetNamespace(), | ||||||||||||||||||||||
| "owner_kind", owner.Kind, | ||||||||||||||||||||||
| "owner_name", owner.Name, | ||||||||||||||||||||||
| "error", err, | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| continue | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if ownerObj == nil { | ||||||||||||||||||||||
| continue | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| *queue = append(*queue, ownerObj) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // 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": | ||||||||||||||||||||||
| gvr.Resource = "replicasets" | ||||||||||||||||||||||
| case "Deployment": | ||||||||||||||||||||||
| gvr.Resource = "deployments" | ||||||||||||||||||||||
| default: | ||||||||||||||||||||||
| 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 err != nil { | |
| 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 | |
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it worth adding a log indicating that invalid runtime risks were filtered out?
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,29 +1,43 @@ | ||
| package deploymentrecord | ||
|
|
||
| import "strings" | ||
|
|
||
| // Status constants for deployment records. | ||
| const ( | ||
| StatusDeployed = "deployed" | ||
| StatusDecommissioned = "decommissioned" | ||
| ) | ||
|
|
||
| // RuntimeRisk for deployment records. | ||
| type RuntimeRisk string | ||
|
|
||
| // Valid runtime risks. | ||
| 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"` | ||
| 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"` | ||
| 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. | ||
| // Status must be either StatusDeployed or StatusDecommissioned. | ||
| // | ||
| //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 +52,18 @@ func NewDeploymentRecord(name, digest, version, logicalEnv, physicalEnv, | |
| Cluster: cluster, | ||
| Status: status, | ||
| DeploymentName: deploymentName, | ||
| RuntimeRisks: runtimeRisks, | ||
| } | ||
| } | ||
|
|
||
| // 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 { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need to handle duplicate runtime risks here too? |
||
| r := RuntimeRisk(strings.TrimSpace(risk)) | ||
| switch r { | ||
| case CriticalResource, InternetExposed, LateralMovement, SensitiveData: | ||
| return r | ||
| default: | ||
| return "" | ||
| } | ||
| } | ||
|
Comment on lines
61
to
69
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we don't already, it may be worth linking out to the "create deployment record" API documentation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's probably also worth documenting that invalid runtime risks will be filtered out and the controller will proceed with creating a record with the remaining valid runtime risks.