Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,25 @@ The `DN_TEMPLATE` supports the following placeholders:
- `{{deploymentName}}` - Name of the owning Deployment
- `{{containerName}}` - Container name

## Runtime Risks
## Annotations
Runtime risks and custom tags can be added to deployment records using annotations. Annotations will be aggregated from the pod and its owner reference objects (e.g. Deployment, ReplicaSet) so they can be added at any level of the ownership hierarchy.

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.
### Runtime Risks

Runtime risks are risks associated with the deployment of an artifact. These risks can be used to filter GitHub Advanced Security (GHAS) alerts and add context to alert prioritization.

Add the annotation `metadata.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 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.

### Custom Tags
You can add custom tags to your deployment records to help filter and organize them in GitHub.

Add annotations with the prefix `metadata.github.com/<key>` (e.g. `metadata.github.com/team: payments`) to add a custom tag. Annotations are aggregated from the pod and its owner reference objects.

If a key is seen at multiple levels of the ownership hierarchy, the value from the lowest level (closest to the pod) will take precedence. For example, if a tag key is present on both the pod and its owning deployment, the value from the pod will be used.

Currently, a maximum of 5 custom tags are allowed per deployment record. Custom tags will be ignored after the limit is reached, meaning tags lower in the ownership hierarchy will be prioritized. Tag keys and values must be 100 characters or less in length. Invalid tags will be ignored.

## Kubernetes Deployment

Expand Down
113 changes: 95 additions & 18 deletions internal/controller/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"slices"
"strings"
"time"
"unicode/utf8"

"github.com/github/deployment-tracker/pkg/deploymentrecord"
"github.com/github/deployment-tracker/pkg/dtmetrics"
Expand All @@ -33,8 +34,14 @@ 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"
// MetadataAnnotationPrefix is the annotation key prefix for deployment record metadata like runtime risk and tags.
MetadataAnnotationPrefix = "metadata.github.com/"
// RuntimeRisksAnnotationKey is the tag key for runtime risks. Comes after MetadataAnnotationPrefix.
RuntimeRisksAnnotationKey = "runtime-risks"
// MaxCustomTags is the maximum number of custom tags per deployment record.
MaxCustomTags = 5
// MaxCustomTagLength is the maximum length for a custom tag key or value.
MaxCustomTagLength = 100
)

type ttlCache interface {
Expand All @@ -53,6 +60,7 @@ type PodEvent struct {
// AggregatePodMetadata represents combined metadata for a pod and its ownership hierarchy.
type AggregatePodMetadata struct {
RuntimeRisks map[deploymentrecord.RuntimeRisk]bool
Tags map[string]string
}

// Controller is the Kubernetes controller for tracking deployments.
Expand Down Expand Up @@ -355,25 +363,21 @@ func (c *Controller) processEvent(ctx context.Context, event PodEvent) error {
var lastErr error

// Gather aggregate metadata for adds/updates
var runtimeRisks []deploymentrecord.RuntimeRisk
var aggPodMetadata *AggregatePodMetadata
if status != deploymentrecord.StatusDecommissioned {
aggMetadata := c.aggregateMetadata(ctx, podToPartialMetadata(pod))
for risk := range aggMetadata.RuntimeRisks {
runtimeRisks = append(runtimeRisks, risk)
}
slices.Sort(runtimeRisks)
aggPodMetadata = c.aggregateMetadata(ctx, podToPartialMetadata(pod))
}

// Record info for each container in the pod
for _, container := range pod.Spec.Containers {
if err := c.recordContainer(ctx, pod, container, status, event.EventType, runtimeRisks); err != nil {
if err := c.recordContainer(ctx, pod, container, status, event.EventType, aggPodMetadata); err != nil {
lastErr = err
}
}

// Also record init containers
for _, container := range pod.Spec.InitContainers {
if err := c.recordContainer(ctx, pod, container, status, event.EventType, runtimeRisks); err != nil {
if err := c.recordContainer(ctx, pod, container, status, event.EventType, aggPodMetadata); err != nil {
lastErr = err
}
}
Expand Down Expand Up @@ -401,7 +405,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, runtimeRisks []deploymentrecord.RuntimeRisk) error {
func (c *Controller) recordContainer(ctx context.Context, pod *corev1.Pod, container corev1.Container, status, eventType string, aggPodMetadata *AggregatePodMetadata) error {
var cacheKey string

dn := getARDeploymentName(pod, container, c.cfg.Template)
Expand Down Expand Up @@ -445,6 +449,17 @@ func (c *Controller) recordContainer(ctx context.Context, pod *corev1.Pod, conta
// Extract image name and tag
imageName, version := ociutil.ExtractName(container.Image)

// Format runtime risks and tags
var runtimeRisks []deploymentrecord.RuntimeRisk
var tags map[string]string
if aggPodMetadata != nil {
for risk := range aggPodMetadata.RuntimeRisks {
runtimeRisks = append(runtimeRisks, risk)
}
slices.Sort(runtimeRisks)
tags = aggPodMetadata.Tags
}

// Create deployment record
record := deploymentrecord.NewDeploymentRecord(
imageName,
Expand All @@ -456,6 +471,7 @@ func (c *Controller) recordContainer(ctx context.Context, pod *corev1.Pod, conta
status,
dn,
runtimeRisks,
tags,
)

if err := c.apiClient.PostOne(ctx, record); err != nil {
Expand Down Expand Up @@ -489,8 +505,9 @@ 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,
"runtime_risks", record.RuntimeRisks,
"tags", record.Tags,
)

// Update cache after successful post
Expand All @@ -515,9 +532,10 @@ func (c *Controller) recordContainer(ctx context.Context, pod *corev1.Pod, conta
}

// aggregateMetadata returns aggregated metadata for a pod and its owners.
func (c *Controller) aggregateMetadata(ctx context.Context, obj *metav1.PartialObjectMetadata) AggregatePodMetadata {
aggMetadata := AggregatePodMetadata{
func (c *Controller) aggregateMetadata(ctx context.Context, obj *metav1.PartialObjectMetadata) *AggregatePodMetadata {
aggMetadata := &AggregatePodMetadata{
RuntimeRisks: make(map[deploymentrecord.RuntimeRisk]bool),
Tags: make(map[string]string),
}
queue := []*metav1.PartialObjectMetadata{obj}
visited := make(map[types.UID]bool)
Expand All @@ -535,7 +553,7 @@ func (c *Controller) aggregateMetadata(ctx context.Context, obj *metav1.PartialO
}
visited[current.GetUID()] = true

extractMetadataFromObject(current, &aggMetadata)
extractMetadataFromObject(current, aggMetadata)
c.addOwnersToQueue(ctx, current, &queue)
}

Expand Down Expand Up @@ -711,14 +729,73 @@ func getDeploymentName(pod *corev1.Pod) string {
}

// extractMetadataFromObject extracts metadata from an object.
func extractMetadataFromObject(obj *metav1.PartialObjectMetadata, aggMetadata *AggregatePodMetadata) {
func extractMetadataFromObject(obj *metav1.PartialObjectMetadata, aggPodMetadata *AggregatePodMetadata) {
annotations := obj.GetAnnotations()
if risks, exists := annotations[RuntimeRiskAnnotationKey]; exists {

// Extract runtime risks
if risks, exists := annotations[MetadataAnnotationPrefix+RuntimeRisksAnnotationKey]; exists {
for _, risk := range strings.Split(risks, ",") {
r := deploymentrecord.ValidateRuntimeRisk(risk)
if r != "" {
aggMetadata.RuntimeRisks[r] = true
aggPodMetadata.RuntimeRisks[r] = true
}
}
}

// Extract tags by sorted keys to ensure tags are deterministic
// if over the limit and some are dropped, the same ones will be dropped each time.
keys := make([]string, 0, len(annotations))
for key := range annotations {
keys = append(keys, key)
}
slices.Sort(keys)

for _, key := range keys {
if len(aggPodMetadata.Tags) >= MaxCustomTags {
break
}

if strings.HasPrefix(key, MetadataAnnotationPrefix) {
tagKey := strings.TrimPrefix(key, MetadataAnnotationPrefix)
tagValue := annotations[key]

if RuntimeRisksAnnotationKey == tagKey {
// ignore runtime risks for custom tags
continue
}
if utf8.RuneCountInString(tagKey) > MaxCustomTagLength || utf8.RuneCountInString(tagValue) > MaxCustomTagLength {
slog.Warn("Tag key or value exceeds max length, skipping",
"object_name", obj.GetName(),
"kind", obj.Kind,
"tag_key", tagKey,
"tag_value", tagValue,
"key_length", utf8.RuneCountInString(tagKey),
"value_length", utf8.RuneCountInString(tagValue),
"max_length", MaxCustomTagLength,
)
continue
}
if tagKey == "" {
slog.Warn("Tag key is empty, skipping",
"object_name", obj.GetName(),
"kind", obj.Kind,
"annotation", key,
"tag_key", tagKey,
"tag_value", tagValue,
)
continue
}
if _, exists := aggPodMetadata.Tags[tagKey]; exists {
slog.Debug("Duplicate tag key found, skipping",
"object_name", obj.GetName(),
"kind", obj.Kind,
"tag_key", tagKey,
"existing_value", aggPodMetadata.Tags[tagKey],
"new_value", tagValue,
)
continue
}
aggPodMetadata.Tags[tagKey] = tagValue
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/deploymentrecord/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ func (c *Client) PostOne(ctx context.Context, record *DeploymentRecord) error {

// Drain and close response body to enable connection reuse
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
_ = resp.Body.Close()

if resp.StatusCode >= 200 && resp.StatusCode < 300 {
dtmetrics.PostDeploymentRecordOk.Inc()
Expand Down
22 changes: 12 additions & 10 deletions pkg/deploymentrecord/record.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,23 +32,24 @@ var validRuntimeRisks = map[RuntimeRisk]bool{

// 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"`
RuntimeRisks []RuntimeRisk `json:"runtime_risks,omitempty"`
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"`
Tags map[string]string `json:"tags,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, runtimeRisks []RuntimeRisk) *DeploymentRecord {
cluster, status, deploymentName string, runtimeRisks []RuntimeRisk, tags map[string]string) *DeploymentRecord {
// Validate status
if status != StatusDeployed && status != StatusDecommissioned {
status = StatusDeployed // default to deployed if invalid
Expand All @@ -64,6 +65,7 @@ func NewDeploymentRecord(name, digest, version, logicalEnv, physicalEnv,
Status: status,
DeploymentName: deploymentName,
RuntimeRisks: runtimeRisks,
Tags: tags,
}
}

Expand Down