Skip to content

Commit 86eb953

Browse files
authored
Add custom tags to deployment records through annotations (#41)
* add custom tags from annotations * change prefix to metadata.github.com * address comments * allow empty annotation tag value * adjust runtime risk and prefix constant
1 parent c33a488 commit 86eb953

File tree

4 files changed

+123
-31
lines changed

4 files changed

+123
-31
lines changed

README.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,25 @@ The `DN_TEMPLATE` supports the following placeholders:
8383
- `{{deploymentName}}` - Name of the owning Deployment
8484
- `{{containerName}}` - Container name
8585

86-
## Runtime Risks
86+
## Annotations
87+
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.
8788

88-
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.
89+
### Runtime Risks
90+
91+
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.
92+
93+
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.
8994

9095
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.
9196

97+
### Custom Tags
98+
You can add custom tags to your deployment records to help filter and organize them in GitHub.
99+
100+
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.
101+
102+
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.
103+
104+
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.
92105

93106
## Kubernetes Deployment
94107

internal/controller/controller.go

Lines changed: 95 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"slices"
99
"strings"
1010
"time"
11+
"unicode/utf8"
1112

1213
"github.com/github/deployment-tracker/pkg/deploymentrecord"
1314
"github.com/github/deployment-tracker/pkg/dtmetrics"
@@ -33,8 +34,14 @@ const (
3334
EventCreated = "CREATED"
3435
// EventDeleted indicates that a pod has been deleted.
3536
EventDeleted = "DELETED"
36-
// RuntimeRiskAnnotationKey represents the annotation key for runtime risks.
37-
RuntimeRiskAnnotationKey = "github.com/runtime-risks"
37+
// MetadataAnnotationPrefix is the annotation key prefix for deployment record metadata like runtime risk and tags.
38+
MetadataAnnotationPrefix = "metadata.github.com/"
39+
// RuntimeRisksAnnotationKey is the tag key for runtime risks. Comes after MetadataAnnotationPrefix.
40+
RuntimeRisksAnnotationKey = "runtime-risks"
41+
// MaxCustomTags is the maximum number of custom tags per deployment record.
42+
MaxCustomTags = 5
43+
// MaxCustomTagLength is the maximum length for a custom tag key or value.
44+
MaxCustomTagLength = 100
3845
)
3946

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

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

357365
// Gather aggregate metadata for adds/updates
358-
var runtimeRisks []deploymentrecord.RuntimeRisk
366+
var aggPodMetadata *AggregatePodMetadata
359367
if status != deploymentrecord.StatusDecommissioned {
360-
aggMetadata := c.aggregateMetadata(ctx, podToPartialMetadata(pod))
361-
for risk := range aggMetadata.RuntimeRisks {
362-
runtimeRisks = append(runtimeRisks, risk)
363-
}
364-
slices.Sort(runtimeRisks)
368+
aggPodMetadata = c.aggregateMetadata(ctx, podToPartialMetadata(pod))
365369
}
366370

367371
// Record info for each container in the pod
368372
for _, container := range pod.Spec.Containers {
369-
if err := c.recordContainer(ctx, pod, container, status, event.EventType, runtimeRisks); err != nil {
373+
if err := c.recordContainer(ctx, pod, container, status, event.EventType, aggPodMetadata); err != nil {
370374
lastErr = err
371375
}
372376
}
373377

374378
// Also record init containers
375379
for _, container := range pod.Spec.InitContainers {
376-
if err := c.recordContainer(ctx, pod, container, status, event.EventType, runtimeRisks); err != nil {
380+
if err := c.recordContainer(ctx, pod, container, status, event.EventType, aggPodMetadata); err != nil {
377381
lastErr = err
378382
}
379383
}
@@ -401,7 +405,7 @@ func (c *Controller) deploymentExists(ctx context.Context, namespace, name strin
401405
}
402406

403407
// recordContainer records a single container's deployment info.
404-
func (c *Controller) recordContainer(ctx context.Context, pod *corev1.Pod, container corev1.Container, status, eventType string, runtimeRisks []deploymentrecord.RuntimeRisk) error {
408+
func (c *Controller) recordContainer(ctx context.Context, pod *corev1.Pod, container corev1.Container, status, eventType string, aggPodMetadata *AggregatePodMetadata) error {
405409
var cacheKey string
406410

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

452+
// Format runtime risks and tags
453+
var runtimeRisks []deploymentrecord.RuntimeRisk
454+
var tags map[string]string
455+
if aggPodMetadata != nil {
456+
for risk := range aggPodMetadata.RuntimeRisks {
457+
runtimeRisks = append(runtimeRisks, risk)
458+
}
459+
slices.Sort(runtimeRisks)
460+
tags = aggPodMetadata.Tags
461+
}
462+
448463
// Create deployment record
449464
record := deploymentrecord.NewDeploymentRecord(
450465
imageName,
@@ -456,6 +471,7 @@ func (c *Controller) recordContainer(ctx context.Context, pod *corev1.Pod, conta
456471
status,
457472
dn,
458473
runtimeRisks,
474+
tags,
459475
)
460476

461477
if err := c.apiClient.PostOne(ctx, record); err != nil {
@@ -489,8 +505,9 @@ func (c *Controller) recordContainer(ctx context.Context, pod *corev1.Pod, conta
489505
"name", record.Name,
490506
"deployment_name", record.DeploymentName,
491507
"status", record.Status,
492-
"runtime_risks", record.RuntimeRisks,
493508
"digest", record.Digest,
509+
"runtime_risks", record.RuntimeRisks,
510+
"tags", record.Tags,
494511
)
495512

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

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

538-
extractMetadataFromObject(current, &aggMetadata)
556+
extractMetadataFromObject(current, aggMetadata)
539557
c.addOwnersToQueue(ctx, current, &queue)
540558
}
541559

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

713731
// extractMetadataFromObject extracts metadata from an object.
714-
func extractMetadataFromObject(obj *metav1.PartialObjectMetadata, aggMetadata *AggregatePodMetadata) {
732+
func extractMetadataFromObject(obj *metav1.PartialObjectMetadata, aggPodMetadata *AggregatePodMetadata) {
715733
annotations := obj.GetAnnotations()
716-
if risks, exists := annotations[RuntimeRiskAnnotationKey]; exists {
734+
735+
// Extract runtime risks
736+
if risks, exists := annotations[MetadataAnnotationPrefix+RuntimeRisksAnnotationKey]; exists {
717737
for _, risk := range strings.Split(risks, ",") {
718738
r := deploymentrecord.ValidateRuntimeRisk(risk)
719739
if r != "" {
720-
aggMetadata.RuntimeRisks[r] = true
740+
aggPodMetadata.RuntimeRisks[r] = true
741+
}
742+
}
743+
}
744+
745+
// Extract tags by sorted keys to ensure tags are deterministic
746+
// if over the limit and some are dropped, the same ones will be dropped each time.
747+
keys := make([]string, 0, len(annotations))
748+
for key := range annotations {
749+
keys = append(keys, key)
750+
}
751+
slices.Sort(keys)
752+
753+
for _, key := range keys {
754+
if len(aggPodMetadata.Tags) >= MaxCustomTags {
755+
break
756+
}
757+
758+
if strings.HasPrefix(key, MetadataAnnotationPrefix) {
759+
tagKey := strings.TrimPrefix(key, MetadataAnnotationPrefix)
760+
tagValue := annotations[key]
761+
762+
if RuntimeRisksAnnotationKey == tagKey {
763+
// ignore runtime risks for custom tags
764+
continue
765+
}
766+
if utf8.RuneCountInString(tagKey) > MaxCustomTagLength || utf8.RuneCountInString(tagValue) > MaxCustomTagLength {
767+
slog.Warn("Tag key or value exceeds max length, skipping",
768+
"object_name", obj.GetName(),
769+
"kind", obj.Kind,
770+
"tag_key", tagKey,
771+
"tag_value", tagValue,
772+
"key_length", utf8.RuneCountInString(tagKey),
773+
"value_length", utf8.RuneCountInString(tagValue),
774+
"max_length", MaxCustomTagLength,
775+
)
776+
continue
777+
}
778+
if tagKey == "" {
779+
slog.Warn("Tag key is empty, skipping",
780+
"object_name", obj.GetName(),
781+
"kind", obj.Kind,
782+
"annotation", key,
783+
"tag_key", tagKey,
784+
"tag_value", tagValue,
785+
)
786+
continue
787+
}
788+
if _, exists := aggPodMetadata.Tags[tagKey]; exists {
789+
slog.Debug("Duplicate tag key found, skipping",
790+
"object_name", obj.GetName(),
791+
"kind", obj.Kind,
792+
"tag_key", tagKey,
793+
"existing_value", aggPodMetadata.Tags[tagKey],
794+
"new_value", tagValue,
795+
)
796+
continue
721797
}
798+
aggPodMetadata.Tags[tagKey] = tagValue
722799
}
723800
}
724801
}

pkg/deploymentrecord/client.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ func (c *Client) PostOne(ctx context.Context, record *DeploymentRecord) error {
228228

229229
// Drain and close response body to enable connection reuse
230230
_, _ = io.Copy(io.Discard, resp.Body)
231-
resp.Body.Close()
231+
_ = resp.Body.Close()
232232

233233
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
234234
dtmetrics.PostDeploymentRecordOk.Inc()

pkg/deploymentrecord/record.go

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,23 +32,24 @@ var validRuntimeRisks = map[RuntimeRisk]bool{
3232

3333
// DeploymentRecord represents a deployment event record.
3434
type DeploymentRecord struct {
35-
Name string `json:"name"`
36-
Digest string `json:"digest"`
37-
Version string `json:"version,omitempty"`
38-
LogicalEnvironment string `json:"logical_environment"`
39-
PhysicalEnvironment string `json:"physical_environment"`
40-
Cluster string `json:"cluster"`
41-
Status string `json:"status"`
42-
DeploymentName string `json:"deployment_name"`
43-
RuntimeRisks []RuntimeRisk `json:"runtime_risks,omitempty"`
35+
Name string `json:"name"`
36+
Digest string `json:"digest"`
37+
Version string `json:"version,omitempty"`
38+
LogicalEnvironment string `json:"logical_environment"`
39+
PhysicalEnvironment string `json:"physical_environment"`
40+
Cluster string `json:"cluster"`
41+
Status string `json:"status"`
42+
DeploymentName string `json:"deployment_name"`
43+
RuntimeRisks []RuntimeRisk `json:"runtime_risks,omitempty"`
44+
Tags map[string]string `json:"tags,omitempty"`
4445
}
4546

4647
// NewDeploymentRecord creates a new DeploymentRecord with the given status.
4748
// Status must be either StatusDeployed or StatusDecommissioned.
4849
//
4950
//nolint:revive
5051
func NewDeploymentRecord(name, digest, version, logicalEnv, physicalEnv,
51-
cluster, status, deploymentName string, runtimeRisks []RuntimeRisk) *DeploymentRecord {
52+
cluster, status, deploymentName string, runtimeRisks []RuntimeRisk, tags map[string]string) *DeploymentRecord {
5253
// Validate status
5354
if status != StatusDeployed && status != StatusDecommissioned {
5455
status = StatusDeployed // default to deployed if invalid
@@ -64,6 +65,7 @@ func NewDeploymentRecord(name, digest, version, logicalEnv, physicalEnv,
6465
Status: status,
6566
DeploymentName: deploymentName,
6667
RuntimeRisks: runtimeRisks,
68+
Tags: tags,
6769
}
6870
}
6971

0 commit comments

Comments
 (0)