Skip to content

Commit 0310168

Browse files
authored
send image digests with reporting info (#306)
* create a listener that watches pods created by the helm chart and extracts their images * send all images from all tracked namespaces * move image sha storage/list/etc logic to store * ensure pod handler makes requests as expected * parse images list in replicated helm values * only persist/report image shas that match the image list * use releaseImages not images * various fixes * unit test fix * better naming, deployment image diversity * get list of images after CI run * api endpoint F * use replicated CLI instead * log URL * fix url * look for correct image names, expect non-app image alpine/curl to not be present * add app and config files to test chart * skip unit and pact tests * add replicated chart file * print expected images * fix path in images * normalize images before filtering them * reenable unit and pact tests * make mock
1 parent 0ef1e3f commit 0310168

File tree

25 files changed

+901
-21
lines changed

25 files changed

+901
-21
lines changed

chart/templates/_helpers.tpl

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,21 @@ License Fields
9494
{{- end -}}
9595
{{- end -}}
9696

97+
{{/*
98+
Release Images
99+
Looks up list of images from values provided by the parent app/release. Supports both
100+
global.replicated.releaseImages and replicated.releaseImages locations.
101+
*/}}
102+
{{- define "replicated.releaseImages" -}}
103+
{{- if and .Values.global .Values.global.replicated .Values.global.replicated.releaseImages -}}
104+
{{- .Values.global.replicated.releaseImages | toYaml -}}
105+
{{- else if and .Values.replicated .Values.replicated.releaseImages -}}
106+
{{- .Values.replicated.releaseImages | toYaml -}}
107+
{{- else if .Values.releaseImages -}}
108+
{{- .Values.releaseImages | toYaml -}}
109+
{{- end -}}
110+
{{- end -}}
111+
97112
{{/*
98113
Detect if we're running on OpenShift
99114
*/}}

chart/templates/replicated-role.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ rules:
7171
- "pods"
7272
verbs:
7373
- "get"
74+
- "list"
75+
- "watch"
7476
# the SDK secret is required by default (to determine if integration test mode is enabled)
7577
- apiGroups:
7678
- ""

chart/templates/replicated-secret.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ stringData:
3030
{{- .Values.releaseNotes | default "" | nindent 6 }}
3131
versionLabel: {{ .Values.versionLabel | default "" | quote }}
3232
replicatedAppEndpoint: {{ include "replicated.appEndpoint" . | quote }}
33+
{{- /* Optionally include release images if provided by parent values */}}
34+
{{- $releaseImages := include "replicated.releaseImages" . -}}
35+
{{- if $releaseImages }}
36+
releaseImages:
37+
{{- $releaseImages | nindent 6 }}
38+
{{- end }}
3339
{{- if .Values.statusInformers }}
3440
statusInformers:
3541
{{- .Values.statusInformers | toYaml | nindent 6 }}

chart/values.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@ versionLabel: ""
286286
parentChartURL: ""
287287
statusInformers: null
288288
replicatedAppEndpoint: ""
289+
releaseImages: []
289290

290291
# Domain for the Replicated App Service - takes precedence over replicatedAppEndpoint if set
291292
# If not specified, the default domain "replicated.app" will be used

cmd/replicated/api.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ func APICmd() *cobra.Command {
7070
ReleaseNotes: replicatedConfig.ReleaseNotes,
7171
VersionLabel: replicatedConfig.VersionLabel,
7272
ReplicatedAppEndpoint: replicatedConfig.ReplicatedAppEndpoint,
73+
ReleaseImages: replicatedConfig.ReleaseImages,
7374
StatusInformers: replicatedConfig.StatusInformers,
7475
ReplicatedID: replicatedConfig.ReplicatedID,
7576
AppID: replicatedConfig.AppID,

dagger/e2e.go

Lines changed: 100 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ func e2e(
2121
ctx context.Context,
2222
source *dagger.Directory,
2323
opServiceAccount *dagger.Secret,
24+
appID string,
25+
customerID string,
26+
sdkImage string,
2427
licenseID string,
2528
distribution string,
2629
version string,
@@ -416,22 +419,22 @@ spec:
416419
fmt.Println("SDK logs after minimal RBAC test:")
417420
fmt.Println(out)
418421

419-
// Extract appID from the SDK logs
420-
var appID string
422+
// Extract instanceAppID from the SDK logs
423+
var instanceAppID string
421424
lines := strings.Split(out, "\n")
422-
appIDRegex := regexp.MustCompile(`appID:\s+([a-f0-9-]+)`)
425+
instanceAppIDRegex := regexp.MustCompile(`appID:\s+([a-f0-9-]+)`)
423426
for _, line := range lines {
424-
if match := appIDRegex.FindStringSubmatch(line); match != nil {
425-
appID = match[1]
426-
fmt.Printf("Extracted appID: %s\n", appID)
427+
if match := instanceAppIDRegex.FindStringSubmatch(line); match != nil {
428+
instanceAppID = match[1]
429+
fmt.Printf("Extracted instanceAppID: %s\n", instanceAppID)
427430
break
428431
}
429432
}
430-
if appID == "" {
431-
return fmt.Errorf("appID not found in SDK logs")
433+
if instanceAppID == "" {
434+
return fmt.Errorf("instanceAppID not found in SDK logs")
432435
}
433436

434-
// make a request to https://api.replicated.com/v1/instance/{appid}/events?pageSize=500
437+
// make a request to https://api.replicated.com/v1/instance/{instanceID}/events?pageSize=500
435438
tokenPlaintext, err := replicatedServiceAccount.Plaintext(ctx)
436439
if err != nil {
437440
return fmt.Errorf("failed to get service account token: %w", err)
@@ -446,7 +449,7 @@ spec:
446449
}
447450

448451
// Retry up to 5 times with 30 seconds between attempts
449-
err = waitForResourcesReady(ctx, resourceNames, 30, 5*time.Second, tokenPlaintext, appID, distribution)
452+
err = waitForResourcesReady(ctx, resourceNames, 30, 5*time.Second, tokenPlaintext, instanceAppID, distribution)
450453
if err != nil {
451454
return fmt.Errorf("failed to wait for resources to be ready: %w", err)
452455
}
@@ -465,12 +468,46 @@ spec:
465468
{Kind: "deployment", Name: "second-test-chart"},
466469
{Kind: "service", Name: "replicated"},
467470
}
468-
err = waitForResourcesReady(ctx, newResourceNames, 30, 5*time.Second, tokenPlaintext, appID, distribution)
471+
err = waitForResourcesReady(ctx, newResourceNames, 30, 5*time.Second, tokenPlaintext, instanceAppID, distribution)
469472
if err != nil {
470473
return fmt.Errorf("failed to wait for resources to be ready: %w", err)
471474
}
472475

473476
fmt.Printf("E2E test for distribution %s and version %s passed\n", distribution, version)
477+
478+
// Validate running images via vendor API
479+
// 1. Call vendor API to get running images for this instance
480+
imagesSet, err := getRunningImages(ctx, appID, customerID, instanceAppID, tokenPlaintext)
481+
if err != nil {
482+
return fmt.Errorf("failed to get running images: %w", err)
483+
}
484+
485+
// 2. Validate expected images
486+
required := []string{"docker.io/library/nginx:latest", "docker.io/library/nginx:alpine", strings.TrimSpace(sdkImage)}
487+
forbidden := []string{"docker.io/alpine/curl:latest"}
488+
missing := []string{}
489+
for _, img := range required {
490+
if img == "" {
491+
continue
492+
}
493+
if _, ok := imagesSet[img]; !ok {
494+
missing = append(missing, img)
495+
}
496+
}
497+
if len(missing) > 0 {
498+
// Build a small preview of what we saw for debugging
499+
seen := make([]string, 0, len(imagesSet))
500+
for k := range imagesSet {
501+
seen = append(seen, k)
502+
}
503+
return fmt.Errorf("running images missing expected entries: %v. Seen: %v", missing, seen)
504+
}
505+
for _, img := range forbidden {
506+
if _, ok := imagesSet[img]; ok {
507+
return fmt.Errorf("running images contains forbidden entry: %s", img)
508+
}
509+
}
510+
474511
return nil
475512
}
476513

@@ -490,8 +527,8 @@ type Event struct {
490527
} `json:"meta"`
491528
}
492529

493-
func getEvents(ctx context.Context, authToken string, appID string) ([]Event, error) {
494-
url := fmt.Sprintf("https://api.replicated.com/v1/instance/%s/events?pageSize=500", appID)
530+
func getEvents(ctx context.Context, authToken string, instanceAppID string) ([]Event, error) {
531+
url := fmt.Sprintf("https://api.replicated.com/v1/instance/%s/events?pageSize=500", instanceAppID)
495532
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
496533
if err != nil {
497534
return nil, fmt.Errorf("failed to create request: %w", err)
@@ -561,12 +598,12 @@ type Resource struct {
561598
Name string
562599
}
563600

564-
func waitForResourcesReady(ctx context.Context, resources []Resource, maxRetries int, retryInterval time.Duration, authToken string, appID string, distribution string) error {
601+
func waitForResourcesReady(ctx context.Context, resources []Resource, maxRetries int, retryInterval time.Duration, authToken string, instanceAppID string, distribution string) error {
565602

566603
for attempt := 1; attempt <= maxRetries; attempt++ {
567604
fmt.Printf("Attempt %d/%d: Checking resource states...\n", attempt, maxRetries)
568605

569-
events, err := getEvents(ctx, authToken, appID)
606+
events, err := getEvents(ctx, authToken, instanceAppID)
570607
if err != nil {
571608
if attempt == maxRetries {
572609
return fmt.Errorf("failed to get events after %d attempts: %w", maxRetries, err)
@@ -606,6 +643,53 @@ func waitForResourcesReady(ctx context.Context, resources []Resource, maxRetries
606643
return fmt.Errorf("unreachable code")
607644
}
608645

646+
// getRunningImages calls the vendor API to retrieve running images for the given instance and returns a set of image names.
647+
func getRunningImages(ctx context.Context, appID string, customerID string, instanceAppID string, authToken string) (map[string]struct{}, error) {
648+
url := fmt.Sprintf("https://api.replicated.com/vendor/v3/app/%s/customer/%s/instance/%s/running-images", appID, customerID, instanceAppID)
649+
650+
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
651+
if err != nil {
652+
return nil, fmt.Errorf("failed to create request: %w", err)
653+
}
654+
req.Header.Set("Accept", "application/json")
655+
req.Header.Set("Authorization", authToken)
656+
657+
client := &http.Client{}
658+
resp, err := client.Do(req)
659+
if err != nil {
660+
return nil, fmt.Errorf("failed to make request: %w", err)
661+
}
662+
defer resp.Body.Close()
663+
664+
if resp.StatusCode != http.StatusOK {
665+
body, _ := io.ReadAll(resp.Body)
666+
return nil, fmt.Errorf("vendor API request failed with status %d: %s", resp.StatusCode, string(body))
667+
}
668+
669+
body, err := io.ReadAll(resp.Body)
670+
if err != nil {
671+
return nil, fmt.Errorf("failed to read response: %w", err)
672+
}
673+
674+
var response struct {
675+
RunningImages map[string][]string `json:"running_images"`
676+
}
677+
if err := json.Unmarshal(body, &response); err != nil {
678+
return nil, fmt.Errorf("failed to unmarshal vendor API response: %w", err)
679+
}
680+
if len(response.RunningImages) == 0 {
681+
return nil, fmt.Errorf("vendor API returned no running_images: %s", string(body))
682+
}
683+
684+
imagesSet := map[string]struct{}{}
685+
for name := range response.RunningImages {
686+
if name != "" {
687+
imagesSet[name] = struct{}{}
688+
}
689+
}
690+
return imagesSet, nil
691+
}
692+
609693
// upgradeChartAndRestart upgrades the helm chart with the provided arguments and restarts deployments
610694
func upgradeChartAndRestart(
611695
ctx context.Context,
@@ -685,7 +769,7 @@ func upgradeChartAndRestart(
685769
[]string{
686770
"kubectl", "rollout", "restart", "deploy/test-chart",
687771
}))
688-
out, err = ctr.Stdout(ctx)
772+
_, err = ctr.Stdout(ctx)
689773
if err != nil {
690774
return fmt.Errorf("failed to restart test-chart deployment: %w", err)
691775
}

dagger/replicated.go

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"dagger/replicated-sdk/internal/dagger"
66
"encoding/json"
77
"fmt"
8+
"regexp"
9+
"strings"
810
"time"
911
)
1012

@@ -58,11 +60,61 @@ func createAppTestRelease(
5860
now := time.Now().Format("20060102150405")
5961
channelName := fmt.Sprintf("automated-%s", now)
6062

63+
// create non-chart files for the app
64+
replicatedAppFile := dag.File("app.yaml", `
65+
apiVersion: kots.io/v1beta1
66+
kind: Application
67+
metadata:
68+
name: replicated-sdk-e2e
69+
spec:
70+
title: Replicated SDK E2E
71+
icon: https://raw.githubusercontent.com/cncf/artwork/master/projects/kubernetes/icon/color/kubernetes-icon-color.png
72+
`)
73+
74+
replicatedConfigFile := dag.File("config.yaml", `
75+
apiVersion: kots.io/v1beta1
76+
kind: Config
77+
metadata:
78+
name: my-application
79+
spec:
80+
groups:
81+
- name: replicated_sdk_e2e
82+
title: Replicated SDK E2E
83+
description: Configuration for the Replicated SDK E2E
84+
items:
85+
- name: test_item
86+
type: bool
87+
default: "0"
88+
`)
89+
90+
replicatedHelmFile := dag.File("helm.yaml", `
91+
apiVersion: kots.io/v1beta2
92+
kind: HelmChart
93+
metadata:
94+
name: replicated-sdk-e2e
95+
spec:
96+
# chart identifies a matching chart from a .tgz
97+
chart:
98+
name: test-chart
99+
chartVersion: 0.1.0
100+
101+
# values are used in the customer environment, as a pre-render step
102+
# these values will be supplied to helm template
103+
values: {}
104+
105+
# builder values provide a way to render the chart with all images
106+
# and manifests. this is used in replicated to create air gap packages
107+
builder: {}
108+
`)
109+
61110
ctr := dag.Container().From("replicated/vendor-cli:latest").
62111
WithSecretVariable("REPLICATED_API_TOKEN", replicatedServiceAccount).
63-
WithMountedFile("/test-chart-0.1.0.tgz", testChartFile).
112+
WithMountedFile("/chart/test-chart-0.1.0.tgz", testChartFile).
113+
WithMountedFile("/chart/app.yaml", replicatedAppFile).
114+
WithMountedFile("/chart/config.yaml", replicatedConfigFile).
115+
WithMountedFile("/chart/helm.yaml", replicatedHelmFile).
64116
WithExec([]string{"/replicated", "channel", "create", "--app", "replicated-sdk-e2e", "--name", channelName}).
65-
WithExec([]string{"/replicated", "release", "create", "--app", "replicated-sdk-e2e", "--version", "0.1.0", "--promote", channelName, "--chart", "/test-chart-0.1.0.tgz"})
117+
WithExec([]string{"/replicated", "release", "create", "--app", "replicated-sdk-e2e", "--version", "0.1.0", "--promote", channelName, "--yaml-dir", "/chart"})
66118

67119
out, err := ctr.Stdout(ctx)
68120
if err != nil {
@@ -101,3 +153,44 @@ func createCustomer(
101153

102154
return replicatedCustomer.ID, replicatedCustomer.InstallationID, nil
103155
}
156+
157+
// getAppID resolves the application ID for a given app slug via the vendor API
158+
func getAppID(
159+
ctx context.Context,
160+
opServiceAccount *dagger.Secret,
161+
appSlug string,
162+
) (string, error) {
163+
// Use vendor CLI output from `replicated app ls`
164+
replicatedServiceAccount := mustGetSecret(ctx, opServiceAccount, "Replicated", "service_account", VaultDeveloperAutomation)
165+
ctr := dag.Container().From("replicated/vendor-cli:latest").
166+
WithSecretVariable("REPLICATED_API_TOKEN", replicatedServiceAccount).
167+
WithExec([]string{"/replicated", "app", "ls"})
168+
169+
out, err := ctr.Stdout(ctx)
170+
if err != nil {
171+
return "", fmt.Errorf("failed to list apps: %w", err)
172+
}
173+
174+
// Parse table rows; columns: ID NAME SLUG SCHEDULER
175+
rowRE := regexp.MustCompile(`^\s*-?([A-Za-z0-9._\-]+)\s+.+?\s+([A-Za-z0-9._\-]+)\s+[A-Za-z0-9._\-]+\s*$`)
176+
for _, line := range strings.Split(out, "\n") {
177+
line = strings.TrimSpace(line)
178+
if line == "" {
179+
continue
180+
}
181+
upper := strings.ToUpper(line)
182+
if strings.HasPrefix(upper, "ID ") || strings.HasPrefix(upper, "ID\t") {
183+
continue
184+
}
185+
m := rowRE.FindStringSubmatch(line)
186+
if len(m) == 0 {
187+
continue
188+
}
189+
id := m[1]
190+
slug := m[2]
191+
if slug == appSlug {
192+
return id, nil
193+
}
194+
}
195+
return "", fmt.Errorf("app with slug %q not found in 'replicated app ls' output", appSlug)
196+
}

dagger/validate.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ func (m *ReplicatedSdk) Validate(
2929
if err != nil {
3030
return err
3131
}
32-
fmt.Printf("Image pushed to %s/%s:%s\n", imageRegistry, imageRepository, imageTag)
32+
sdkImage := fmt.Sprintf("%s/%s:%s", imageRegistry, imageRepository, imageTag)
33+
fmt.Printf("Image pushed to %s\n", sdkImage)
3334

3435
chart, err := buildAndPushChartToTTL(ctx, source, imageRegistry, imageRepository, imageTag)
3536
if err != nil {
@@ -48,6 +49,12 @@ func (m *ReplicatedSdk) Validate(
4849
}
4950
fmt.Println(customerID, licenseID)
5051

52+
// Resolve app ID for replicated-sdk-e2e (used by vendor API checks)
53+
appID, err := getAppID(ctx, opServiceAccount, "replicated-sdk-e2e")
54+
if err != nil {
55+
return err
56+
}
57+
5158
cmxDistributions, err := listCMXDistributionsAndVersions(ctx, opServiceAccount)
5259
if err != nil {
5360
return err
@@ -58,7 +65,7 @@ func (m *ReplicatedSdk) Validate(
5865
wg.Add(1)
5966
go func(distribution DistributionVersion) {
6067
defer wg.Done()
61-
if err := e2e(ctx, source, opServiceAccount, licenseID, distribution.Distribution, distribution.Version, channelSlug); err != nil {
68+
if err := e2e(ctx, source, opServiceAccount, appID, customerID, sdkImage, licenseID, distribution.Distribution, distribution.Version, channelSlug); err != nil {
6269
panic(fmt.Sprintf("E2E test failed for distribution %s %s: %v", distribution.Distribution, distribution.Version, err))
6370
}
6471
}(distribution)

0 commit comments

Comments
 (0)