Skip to content

Commit 7d72d3e

Browse files
committed
fix(deploy): recognize immutable local image IDs
1 parent 09bb457 commit 7d72d3e

5 files changed

Lines changed: 58 additions & 3 deletions

File tree

‎AUDIT_OPEN.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Open audit items
22

3+
## 2026-09-25 — local image identity
4+
5+
Full local `sha256:<64hex>` image IDs now count as immutable in deployment
6+
plans and receipts. An existing ID is used without a registry request; an absent
7+
ID refuses with build/load guidance. Named mutable tags still pull on every
8+
deploy. Regression tests cover both cache states and the provenance identity,
9+
including malformed digests. Full Go vet and tests passed.
10+
311
Unresolved findings for this repository from the ChatGPT-led audit series.
412
Pass 1-5 (2026-09-09 through 2026-09-11, register: teploy-neutron-lullmail
513
expanded audit) closed fully below. Pass 6 (2026-09-17, 78 findings F01-F78,

‎internal/cli/deploy.go‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1320,12 +1320,15 @@ func healthConfigFrom(h config.AppHealthConfig) deploy.HealthConfig {
13201320
}
13211321

13221322
// isDigestPinned reports whether an image reference is content-addressed
1323-
// (`repo@sha256:...`). Such a reference names exactly one set of bytes forever,
1323+
// (`repo@sha256:...` or a full local `sha256:...` ID). Such a reference names exactly one set of bytes forever,
13241324
// so a local copy of it can never be out of date. Everything else — every tag,
13251325
// and a bare repo (which Docker resolves to `:latest`) — is mutable: the
13261326
// registry can move it under us at any time, and "looks like a git sha" is a
13271327
// convention nothing enforces, so tags are not special-cased here.
13281328
func isDigestPinned(image string) bool {
1329+
if deploy.ImageDigestFromRef(image) != "" {
1330+
return true
1331+
}
13291332
i := strings.LastIndex(image, "@")
13301333
if i < 0 {
13311334
return false
@@ -1366,6 +1369,9 @@ func ensureImage(ctx context.Context, dk *docker.Client, image string, out io.Wr
13661369
fmt.Fprintf(out, " Using local image %s (digest-pinned, cannot be stale)\n", image)
13671370
return nil
13681371
}
1372+
if strings.HasPrefix(image, "sha256:") && deploy.ImageDigestFromRef(image) != "" {
1373+
return fmt.Errorf("local image ID %s is absent from the server; build or load it there before deploying", image)
1374+
}
13691375
fmt.Fprintf(out, "Pulling image %s...\n", image)
13701376
if err := dk.Pull(ctx, image); err != nil {
13711377
if exists {

‎internal/cli/deploy_test.go‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,3 +208,28 @@ func TestIsDigestPinned(t *testing.T) {
208208
}
209209
}
210210
}
211+
212+
func TestEnsureImageLocalIDNeverPulls(t *testing.T) {
213+
image := "sha256:" + strings.Repeat("b", 64)
214+
for _, exists := range []bool{true, false} {
215+
response := ""
216+
if exists {
217+
response = "exists\n"
218+
}
219+
mock := ssh.NewMockExecutor("host", ssh.MockCommand{Match: "err=$(mktemp); if docker image inspect", Output: response})
220+
var out bytes.Buffer
221+
err := ensureImage(context.Background(), docker.NewClient(mock), image, &out)
222+
if exists && err != nil {
223+
t.Fatal(err)
224+
}
225+
if !exists && (err == nil || !strings.Contains(err.Error(), "build or load")) {
226+
t.Fatalf("missing image: %v", err)
227+
}
228+
if pullAttempted(mock) {
229+
t.Fatal("local image ID must never cause a registry request")
230+
}
231+
if exists && !strings.Contains(out.String(), "digest-pinned") {
232+
t.Fatalf("identity misclassified: %s", out.String())
233+
}
234+
}
235+
}

‎internal/deploy/deploy.go‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1762,14 +1762,18 @@ func containerPort(c Config) int {
17621762
}
17631763

17641764
// ImageDigestFromRef extracts the digest of a digest-pinned image
1765-
// reference ("repo@sha256:<64hex>"), or "" for every other reference
1765+
// reference ("repo@sha256:<64hex>") or a full local "sha256:<64hex>" ID,
1766+
// or "" for every other reference
17661767
// shape. Exported for the CLI's plan-time provenance, which must apply
17671768
// the SAME like-for-like rule the deployed record applies (a pinned ref
17681769
// is identified by its manifest digest, a mutable ref by docker's
17691770
// resolved content ID) or plan/receipt equality compares apples to
17701771
// oranges.
17711772
func ImageDigestFromRef(image string) string {
1772-
if _, digest, ok := strings.Cut(image, "@"); ok && strings.HasPrefix(digest, "sha256:") && len(digest) == len("sha256:")+64 {
1773+
if strings.HasPrefix(image, "sha256:") && len(image) == 71 && docker.IsImageID(image) {
1774+
return image
1775+
}
1776+
if repo, digest, ok := strings.Cut(image, "@"); ok && repo != "" && strings.HasPrefix(digest, "sha256:") && len(digest) == 71 && docker.IsImageID(digest) {
17731777
return digest
17741778
}
17751779
return ""

‎internal/deploy/provenance_test.go‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,3 +200,15 @@ func TestDeploy_NoPlanDigestNoFalseAlarm(t *testing.T) {
200200
t.Errorf("nothing to compare must not warn:\n%s", buf.String())
201201
}
202202
}
203+
204+
func TestFullLocalImageIDIsItsOwnProvenance(t *testing.T) {
205+
id := "sha256:" + strings.Repeat("b", 64)
206+
if got := plannedImageDigest(id, id, nil); got != id {
207+
t.Fatalf("got %q, want %q", got, id)
208+
}
209+
for _, invalid := range []string{"sha256:" + strings.Repeat("z", 64), "sha256:abcdef123456", "@" + id, "repo@sha256:" + strings.Repeat("z", 64)} {
210+
if got := ImageDigestFromRef(invalid); got != "" {
211+
t.Fatalf("invalid identity %q returned %q", invalid, got)
212+
}
213+
}
214+
}

0 commit comments

Comments
 (0)