Skip to content

Commit 799fc55

Browse files
committed
chore(embed): lint :latest image tags with pin-by-digest policy
Addresses W4 from the PR #343 review. The /v1 back-and-forth on PR #343 (add → revert → re-add) was consistent with a deployed x402-buyer:latest image lagging behind main, and the fix hardcoded /v1 in the LiteLLM template instead of pinning the image. Same risk applies to x402-verifier and serviceoffer-controller which also ship as :latest. - New internal/embed/embed_image_pin_test.go scans every embedded template and fails when a new :latest appears without an allowlist entry. The allowlist currently covers the three obolnetwork images pending digest pinning; each entry carries a short reason. Removing an entry without replacing :latest in the YAML fails the test (stale-allowlist check). - Inline TODO(image-pin) comments in llm.yaml and x402.yaml explain the policy at the point of violation so contributors who touch the deployment spec see it. This does not pin the images (that requires GHCR access to produce the digest) — it establishes the contract and makes drift visible.
1 parent ca77590 commit 799fc55

File tree

3 files changed

+155
-0
lines changed

3 files changed

+155
-0
lines changed
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package embed
2+
3+
import (
4+
"bufio"
5+
"bytes"
6+
"fmt"
7+
"sort"
8+
"strings"
9+
"testing"
10+
)
11+
12+
// TestEmbeddedImages_NoNewLatestTags guards against `image: foo:latest` drift
13+
// in the embedded infrastructure templates. The `/v1` saga in PR #343 (add,
14+
// revert, re-add) was consistent with a deployed `x402-buyer:latest` that
15+
// lagged behind the source mux, and the workaround cemented `/v1` in the
16+
// template instead of pinning the image.
17+
//
18+
// This test enumerates every `image: …:latest` occurrence under
19+
// internal/embed/infrastructure/base/templates/ and fails when a new one
20+
// appears. The allowlist below names every currently-unpinned image with the
21+
// rationale; to add an entry, add the image and a reason. To remove an entry,
22+
// replace `:latest` with a digest or immutable tag in the template and shrink
23+
// the allowlist.
24+
//
25+
// Corresponds to W4 in the PR #343 review.
26+
func TestEmbeddedImages_NoNewLatestTags(t *testing.T) {
27+
type latestHit struct {
28+
file string
29+
line int
30+
img string
31+
}
32+
33+
// Known unpinned images as of PR #343 follow-up. Each entry MUST have a
34+
// TODO in the template body explaining the pin-by-digest policy.
35+
allowed := map[string]string{
36+
"base/templates/llm.yaml:ghcr.io/obolnetwork/x402-buyer:latest": "x402-buyer: pin by digest once CI publishes a stable tag",
37+
"base/templates/x402.yaml:ghcr.io/obolnetwork/x402-verifier:latest": "x402-verifier: pin by digest once CI publishes a stable tag",
38+
"base/templates/x402.yaml:ghcr.io/obolnetwork/serviceoffer-controller:latest": "serviceoffer-controller: pin by digest once CI publishes a stable tag",
39+
}
40+
41+
files := []string{
42+
"base/templates/llm.yaml",
43+
"base/templates/x402.yaml",
44+
"base/templates/local-path.yaml",
45+
"base/templates/obol-agent.yaml",
46+
"base/templates/obol-agent-monetize-rbac.yaml",
47+
"base/templates/obol-agent-admission-policy.yaml",
48+
"base/templates/serviceoffer-crd.yaml",
49+
"base/templates/registrationrequest-crd.yaml",
50+
"base/templates/purchaserequest-crd.yaml",
51+
}
52+
53+
var hits []latestHit
54+
55+
for _, f := range files {
56+
data, err := ReadInfrastructureFile(f)
57+
if err != nil {
58+
// Some files may not exist in every branch; skip gracefully.
59+
continue
60+
}
61+
62+
scanner := bufio.NewScanner(bytes.NewReader(data))
63+
scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024)
64+
65+
lineNum := 0
66+
for scanner.Scan() {
67+
lineNum++
68+
line := scanner.Text()
69+
70+
trimmed := strings.TrimSpace(line)
71+
if strings.HasPrefix(trimmed, "#") {
72+
continue
73+
}
74+
// Match "image:" key in Pod/Deployment container specs.
75+
_, after, found := strings.Cut(trimmed, "image:")
76+
if !found {
77+
continue
78+
}
79+
// Extract the image reference (strip surrounding quotes, comments).
80+
after = strings.TrimSpace(after)
81+
82+
after = strings.Trim(after, `"'`)
83+
if i := strings.IndexAny(after, " \t#"); i >= 0 {
84+
after = after[:i]
85+
}
86+
87+
if after == "" {
88+
continue
89+
}
90+
91+
if !strings.HasSuffix(after, ":latest") {
92+
continue
93+
}
94+
95+
hits = append(hits, latestHit{file: f, line: lineNum, img: after})
96+
}
97+
98+
if err := scanner.Err(); err != nil {
99+
t.Fatalf("scan %s: %v", f, err)
100+
}
101+
}
102+
103+
var offending []string
104+
105+
seen := map[string]bool{}
106+
107+
for _, h := range hits {
108+
key := fmt.Sprintf("%s:%s", h.file, h.img)
109+
110+
seen[key] = true
111+
if _, ok := allowed[key]; !ok {
112+
offending = append(offending, fmt.Sprintf("%s:%d uses %q but has no pin-exception entry", h.file, h.line, h.img))
113+
}
114+
}
115+
116+
// Also enforce the allowlist is not stale: if an allowed image has been
117+
// pinned and removed from the YAML, the entry should be dropped from the
118+
// allowlist so a future drift does not go unnoticed.
119+
var stale []string
120+
121+
for key := range allowed {
122+
if !seen[key] {
123+
stale = append(stale, key)
124+
}
125+
}
126+
127+
sort.Strings(stale)
128+
129+
if len(offending) > 0 {
130+
sort.Strings(offending)
131+
t.Fatalf("embedded templates use :latest without a pin exception:\n %s\n\n"+
132+
"Pin by digest (preferred) or add to the allowlist in %s with a reason.",
133+
strings.Join(offending, "\n "),
134+
"internal/embed/embed_image_pin_test.go")
135+
}
136+
137+
if len(stale) > 0 {
138+
t.Fatalf("allowlist entries no longer match any :latest in templates (tighten the test):\n %s",
139+
strings.Join(stale, "\n "))
140+
}
141+
}

internal/embed/infrastructure/base/templates/llm.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,12 @@ spec:
199199
cpu: "1"
200200
memory: 1Gi
201201
- name: x402-buyer
202+
# TODO(image-pin): replace :latest with a digest (ghcr.io/obolnetwork/x402-buyer@sha256:…)
203+
# or the short-SHA tag pattern used for litellm above (sha-<short>).
204+
# :latest drift between the deployed sidecar and the main branch is what
205+
# produced the /v1 vs bare-path 404 debacle in PR #343: the mux was
206+
# updated to register both paths, but the deployed image still served
207+
# only /v1/*. See internal/embed/embed_image_pin_test.go.
202208
image: ghcr.io/obolnetwork/x402-buyer:latest
203209
imagePullPolicy: IfNotPresent
204210
args:

internal/embed/infrastructure/base/templates/x402.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,10 @@ spec:
179179
serviceAccountName: x402-verifier
180180
containers:
181181
- name: verifier
182+
# TODO(image-pin): replace :latest with a digest (ghcr.io/obolnetwork/x402-verifier@sha256:…)
183+
# or a short-SHA tag. :latest drift between deployed verifier and main
184+
# masks protocol regressions (e.g. facilitator payload format, verifyOnly
185+
# handling). See internal/embed/embed_image_pin_test.go.
182186
image: ghcr.io/obolnetwork/x402-verifier:latest
183187
imagePullPolicy: IfNotPresent
184188
ports:
@@ -250,6 +254,10 @@ spec:
250254
serviceAccountName: serviceoffer-controller
251255
containers:
252256
- name: controller
257+
# TODO(image-pin): replace :latest with a digest or short-SHA tag.
258+
# The controller writes LiteLLM config and buyer ConfigMaps — a stale
259+
# image here silently breaks the paid/* routing without a clear error.
260+
# See internal/embed/embed_image_pin_test.go.
253261
image: ghcr.io/obolnetwork/serviceoffer-controller:latest
254262
imagePullPolicy: IfNotPresent
255263
env:

0 commit comments

Comments
 (0)