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) } // 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 }