5b1b191ffc
gates / gates (push) Successful in 26s
Found live on 9202 (night 2026-09-24 Part B): the sync rendered the ladder's newest tested digest into a RUNNING app's compose, so the next restart would pull a new image with no backup and no undo. stacks.CarryDigests keeps the running digest for an installed app; a fresh install still takes the tested digest. Red-proofed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
167 lines
5.8 KiB
Go
167 lines
5.8 KiB
Go
package stacks
|
|
|
|
import (
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ── Exact image fingerprints on the box (`09` §6.4 part 6, box half; §3 decision 17; v0.269.0) ────
|
|
//
|
|
// The catalog records, per ladder entry, the registry digest of every `to` ref at the moment the step was
|
|
// tested (`scripts/image_digest.py`). The box:
|
|
//
|
|
// 1. RENDERS `name:tag@sha256:…` into the compose file it runs whenever the entry for exactly those refs
|
|
// carries a digest — so a pull fetches the TESTED image, not whatever the tag points at today.
|
|
// Docker and Compose accept the form and refuse a digest that does not exist (measured on 9202,
|
|
// 2026-09-23, `audits/update-rulings-2026-09-23/70-…`).
|
|
// 2. Keeps every PIN and every RECORD digest-free: ParseComposeImages strips `@…`, and the installed
|
|
// record's Ref is stripped too (its Digest is a field of its own). One strip at each door, so the
|
|
// pin, the ladder, the badge and the syncer all compare plain `name:tag`.
|
|
// 3. Reads the badge from the TESTED digest only — never a registry query (`09` §8.1): same tag, a
|
|
// different installed digest, and a catalog test NEWER than the install → „Frissítés elérhető".
|
|
|
|
var digestRe = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
|
|
|
|
// StripDigest removes a `@sha256:…` suffix from an image reference.
|
|
func StripDigest(ref string) string {
|
|
if at := strings.LastIndex(ref, "@"); at >= 0 {
|
|
return ref[:at]
|
|
}
|
|
return ref
|
|
}
|
|
|
|
var serviceLineRe = regexp.MustCompile(`^ ([A-Za-z0-9_-]+):\s*$`)
|
|
var imageLineRe = regexp.MustCompile(`^(\s+image:\s*)["']?([^\s"'#]+)["']?(.*)$`)
|
|
|
|
// renderDigests rewrites each service's own `image:` line to `ref@digest` when digests carries a valid
|
|
// one for that service. Line-based (the catalog's own reading, `ladder.images_in`), so comments and
|
|
// every other byte are kept. A service with no valid digest keeps its line.
|
|
func renderDigests(compose []byte, digests map[string]string) []byte {
|
|
if len(digests) == 0 {
|
|
return compose
|
|
}
|
|
lines := strings.Split(string(compose), "\n")
|
|
svc := ""
|
|
done := map[string]bool{}
|
|
inServices := false
|
|
for i, l := range lines {
|
|
if strings.HasPrefix(l, "services:") {
|
|
inServices = true
|
|
continue
|
|
}
|
|
if l != "" && !strings.HasPrefix(l, " ") && !strings.HasPrefix(l, "#") {
|
|
inServices = false
|
|
}
|
|
if !inServices {
|
|
continue
|
|
}
|
|
if m := serviceLineRe.FindStringSubmatch(l); m != nil {
|
|
svc = m[1]
|
|
continue
|
|
}
|
|
m := imageLineRe.FindStringSubmatch(l)
|
|
if m == nil || svc == "" || done[svc] {
|
|
continue
|
|
}
|
|
d := digests[svc]
|
|
if !digestRe.MatchString(d) {
|
|
continue
|
|
}
|
|
lines[i] = m[1] + StripDigest(m[2]) + "@" + d + m[3]
|
|
done[svc] = true
|
|
}
|
|
return []byte(strings.Join(lines, "\n"))
|
|
}
|
|
|
|
// ladderEntryFor returns the NEWEST ladder entry whose `to` is exactly these (digest-free) refs.
|
|
func ladderEntryFor(templateDir string, refs map[string]string) (LadderEntry, bool) {
|
|
ladder, err := LoadLadder(filepath.Join(templateDir, ".felhom.yml"))
|
|
if err != nil {
|
|
return LadderEntry{}, false
|
|
}
|
|
for i := len(ladder) - 1; i >= 0; i-- {
|
|
if sameRefs(ladder[i].To, refs) {
|
|
return ladder[i], true
|
|
}
|
|
}
|
|
return LadderEntry{}, false
|
|
}
|
|
|
|
// RenderWithLadderDigests is what the syncer and the update write: the compose bytes with the tested
|
|
// digests of the ladder entry for exactly its refs. No entry, or no digest → the bytes unchanged.
|
|
func RenderWithLadderDigests(templateDir string, compose []byte) []byte {
|
|
refs, err := parseComposeImagesBytes(compose)
|
|
if err != nil || len(refs) == 0 {
|
|
return compose
|
|
}
|
|
e, ok := ladderEntryFor(templateDir, refs)
|
|
if !ok {
|
|
return compose
|
|
}
|
|
return renderDigests(compose, e.Digest)
|
|
}
|
|
|
|
// composeImageLines returns each service's OWN image reference as written, digest included — the same
|
|
// line walk as renderDigests.
|
|
func composeImageLines(compose []byte) map[string]string {
|
|
out := map[string]string{}
|
|
svc, inServices := "", false
|
|
for _, l := range strings.Split(string(compose), "\n") {
|
|
if strings.HasPrefix(l, "services:") {
|
|
inServices = true
|
|
continue
|
|
}
|
|
if l != "" && !strings.HasPrefix(l, " ") && !strings.HasPrefix(l, "#") {
|
|
inServices = false
|
|
}
|
|
if !inServices {
|
|
continue
|
|
}
|
|
if m := serviceLineRe.FindStringSubmatch(l); m != nil {
|
|
svc = m[1]
|
|
continue
|
|
}
|
|
if m := imageLineRe.FindStringSubmatch(l); m != nil && svc != "" {
|
|
if _, seen := out[svc]; !seen {
|
|
out[svc] = m[2]
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// CarryDigests is the syncer's rule for a DEPLOYED app: the catalog compose, with the digest the app's
|
|
// CURRENT file already names for each service whose reference did not change — and no other. The digest
|
|
// is part of what the app runs, so only a guarded update (advancePinTo) may move it. Rendering the
|
|
// ladder's newest digest here instead let a sync change the image under a running app: the next restart
|
|
// pulled it with no backup and no undo (MEASURED on 9202, night 2026-09-24 Part B,
|
|
// `audits/night-2026-09-24/B/10-floating-tag.*`). Pinned by TestDigest_SyncerKeepsTheRunningDigest.
|
|
func CarryDigests(compose, current []byte) []byte {
|
|
cur := composeImageLines(current)
|
|
next := composeImageLines(compose)
|
|
keep := map[string]string{}
|
|
for svc, ref := range cur {
|
|
at := strings.LastIndex(ref, "@")
|
|
if at < 0 || !digestRe.MatchString(ref[at+1:]) {
|
|
continue
|
|
}
|
|
if n, ok := next[svc]; ok && StripDigest(n) == ref[:at] {
|
|
keep[svc] = ref[at+1:]
|
|
}
|
|
}
|
|
return renderDigests(compose, keep)
|
|
}
|
|
|
|
// catalogTestedDigests is the badge's input: the tested digest per service of the catalog's current
|
|
// refs, and when that test ran. Empty when the ladder has no entry for them.
|
|
func catalogTestedDigests(templateDir string, catalogRefs map[string]string) (map[string]string, time.Time) {
|
|
e, ok := ladderEntryFor(templateDir, catalogRefs)
|
|
if !ok {
|
|
return nil, time.Time{}
|
|
}
|
|
t, _ := time.Parse(time.RFC3339, e.TestedAt)
|
|
return e.Digest, t
|
|
}
|