Skip to content

Commit b7b5fef

Browse files
committed
add custom tags from annotations
1 parent c33a488 commit b7b5fef

4 files changed

Lines changed: 111 additions & 29 deletions

File tree

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 `artifact-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 `artifact-metadata.github.com/<key>` (e.g. `artifact-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 less than 100 characters in length. Invalid tags will be ignored.
92105

93106
## Kubernetes Deployment
94107

internal/controller/controller.go

Lines changed: 83 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import (
55
"errors"
66
"fmt"
77
"log/slog"
8+
"regexp"
89
"slices"
910
"strings"
1011
"time"
12+
"unicode/utf8"
1113

1214
"github.com/github/deployment-tracker/pkg/deploymentrecord"
1315
"github.com/github/deployment-tracker/pkg/dtmetrics"
@@ -34,9 +36,17 @@ const (
3436
// EventDeleted indicates that a pod has been deleted.
3537
EventDeleted = "DELETED"
3638
// RuntimeRiskAnnotationKey represents the annotation key for runtime risks.
37-
RuntimeRiskAnnotationKey = "github.com/runtime-risks"
39+
RuntimeRiskAnnotationKey = "artifact-metadata.github.com/runtime-risks"
40+
// CustomTagAnnotationKeyPattern is a regex to find custom tag annotations and extract the key.
41+
CustomTagAnnotationKeyPattern = `artifact-metadata\.github\.com/([^\s]+)`
42+
// MaxCustomTags is the maximum number of custom tags per deployment record.
43+
MaxCustomTags = 5
44+
// MaxCustomTagLength is the maximum length for a custom tag key or value.
45+
MaxCustomTagLength = 100
3846
)
3947

48+
var customTagAnnotationKeyRegexp = regexp.MustCompile(CustomTagAnnotationKeyPattern)
49+
4050
type ttlCache interface {
4151
Get(k any) (any, bool)
4252
Set(k any, v any, ttl time.Duration)
@@ -53,6 +63,7 @@ type PodEvent struct {
5363
// AggregatePodMetadata represents combined metadata for a pod and its ownership hierarchy.
5464
type AggregatePodMetadata struct {
5565
RuntimeRisks map[deploymentrecord.RuntimeRisk]bool
66+
Tags map[string]string
5667
}
5768

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

357368
// Gather aggregate metadata for adds/updates
358-
var runtimeRisks []deploymentrecord.RuntimeRisk
369+
var aggPodMetadata *AggregatePodMetadata
359370
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)
371+
aggPodMetadata = c.aggregateMetadata(ctx, podToPartialMetadata(pod))
365372
}
366373

367374
// Record info for each container in the pod
368375
for _, container := range pod.Spec.Containers {
369-
if err := c.recordContainer(ctx, pod, container, status, event.EventType, runtimeRisks); err != nil {
376+
if err := c.recordContainer(ctx, pod, container, status, event.EventType, aggPodMetadata); err != nil {
370377
lastErr = err
371378
}
372379
}
373380

374381
// Also record init containers
375382
for _, container := range pod.Spec.InitContainers {
376-
if err := c.recordContainer(ctx, pod, container, status, event.EventType, runtimeRisks); err != nil {
383+
if err := c.recordContainer(ctx, pod, container, status, event.EventType, aggPodMetadata); err != nil {
377384
lastErr = err
378385
}
379386
}
@@ -401,7 +408,7 @@ func (c *Controller) deploymentExists(ctx context.Context, namespace, name strin
401408
}
402409

403410
// 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 {
411+
func (c *Controller) recordContainer(ctx context.Context, pod *corev1.Pod, container corev1.Container, status, eventType string, aggPodMetadata *AggregatePodMetadata) error {
405412
var cacheKey string
406413

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

455+
// Format runtime risks and tags
456+
var runtimeRisks []deploymentrecord.RuntimeRisk
457+
var tags map[string]string
458+
if aggPodMetadata != nil {
459+
for risk := range aggPodMetadata.RuntimeRisks {
460+
runtimeRisks = append(runtimeRisks, risk)
461+
}
462+
slices.Sort(runtimeRisks)
463+
tags = aggPodMetadata.Tags
464+
}
465+
448466
// Create deployment record
449467
record := deploymentrecord.NewDeploymentRecord(
450468
imageName,
@@ -456,6 +474,7 @@ func (c *Controller) recordContainer(ctx context.Context, pod *corev1.Pod, conta
456474
status,
457475
dn,
458476
runtimeRisks,
477+
tags,
459478
)
460479

461480
if err := c.apiClient.PostOne(ctx, record); err != nil {
@@ -489,8 +508,9 @@ func (c *Controller) recordContainer(ctx context.Context, pod *corev1.Pod, conta
489508
"name", record.Name,
490509
"deployment_name", record.DeploymentName,
491510
"status", record.Status,
492-
"runtime_risks", record.RuntimeRisks,
493511
"digest", record.Digest,
512+
"runtime_risks", record.RuntimeRisks,
513+
"tags", record.Tags,
494514
)
495515

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

517537
// 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{
538+
func (c *Controller) aggregateMetadata(ctx context.Context, obj *metav1.PartialObjectMetadata) *AggregatePodMetadata {
539+
aggMetadata := &AggregatePodMetadata{
520540
RuntimeRisks: make(map[deploymentrecord.RuntimeRisk]bool),
541+
Tags: make(map[string]string),
521542
}
522543
queue := []*metav1.PartialObjectMetadata{obj}
523544
visited := make(map[types.UID]bool)
@@ -535,7 +556,7 @@ func (c *Controller) aggregateMetadata(ctx context.Context, obj *metav1.PartialO
535556
}
536557
visited[current.GetUID()] = true
537558

538-
extractMetadataFromObject(current, &aggMetadata)
559+
extractMetadataFromObject(current, aggMetadata)
539560
c.addOwnersToQueue(ctx, current, &queue)
540561
}
541562

@@ -711,14 +732,60 @@ func getDeploymentName(pod *corev1.Pod) string {
711732
}
712733

713734
// extractMetadataFromObject extracts metadata from an object.
714-
func extractMetadataFromObject(obj *metav1.PartialObjectMetadata, aggMetadata *AggregatePodMetadata) {
735+
func extractMetadataFromObject(obj *metav1.PartialObjectMetadata, aggPodMetadata *AggregatePodMetadata) {
715736
annotations := obj.GetAnnotations()
737+
738+
// Extract runtime risks
716739
if risks, exists := annotations[RuntimeRiskAnnotationKey]; exists {
717740
for _, risk := range strings.Split(risks, ",") {
718741
r := deploymentrecord.ValidateRuntimeRisk(risk)
719742
if r != "" {
720-
aggMetadata.RuntimeRisks[r] = true
743+
aggPodMetadata.RuntimeRisks[r] = true
744+
}
745+
}
746+
}
747+
748+
// Extract tags by sorted keys to ensure tags are deterministic
749+
// if over the limit and some are dropped, the same ones will be dropped each time.
750+
keys := make([]string, 0, len(annotations))
751+
for key := range annotations {
752+
keys = append(keys, key)
753+
}
754+
slices.Sort(keys)
755+
756+
for _, key := range keys {
757+
if len(aggPodMetadata.Tags) >= MaxCustomTags {
758+
break
759+
}
760+
if RuntimeRiskAnnotationKey == key {
761+
continue
762+
}
763+
if matches := customTagAnnotationKeyRegexp.FindStringSubmatch(key); matches != nil {
764+
tagKey := matches[1]
765+
tagValue := annotations[key]
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 _, exists := aggPodMetadata.Tags[tagKey]; exists {
779+
slog.Debug("Duplicate tag key found, skipping",
780+
"object_name", obj.GetName(),
781+
"kind", obj.Kind,
782+
"tag_key", tagKey,
783+
"existing_value", aggPodMetadata.Tags[tagKey],
784+
"new_value", tagValue,
785+
)
786+
continue
721787
}
788+
aggPodMetadata.Tags[tagKey] = tagValue
722789
}
723790
}
724791
}

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)