v0.233.0: record what each compose service actually installed, and badge whether it is current
gates / gates (push) Successful in 12s
gates / gates (push) Successful in 12s
Update arc slices 1 and 2. NEITHER CHANGES ANY BEHAVIOUR — no new endpoint, no auto-update, the three lifecycle buttons byte-identical. Slice 1 — app.yaml gains installed_images, keyed by compose SERVICE name, each entry carrying ref + repo digest + first-seen timestamp. Written by Manager.recordInstalledImages after a successful compose up from StartStack, RestartStack, UpdateStack and runComposeDeploy. Read from the CONTAINER, never from docker-compose.yml: the syncer overwrites a deployed app's compose on a 15-minute cycle and the two disagreed for 25 minutes in the spike's own measurement. A failed write NEVER refuses the action - the deliberate opposite of SetDesiredState, because this is an observation and that is an intent. Not called from StartStackServices (the R-47 DB-only window). Its own docker seam with a context and a 30s timeout, which neither existing exec helper has. Slice 2 — .felhom.yml gains optional catalog_since; web.updateBadge compares the recorded ref per service against what the current template pins and returns a *MetaBadge through the EXISTING meta_badge partial. No new markup, no new CSS. NO RECORD RENDERS NOTHING: absent means unknown and never means current. No version number reaches the customer and no registry is queried. Known limitation, filed not hidden: 23 catalog pins float, so those apps can read Naprakesz when the image behind the tag has moved. +17 tests (1707 -> 1724), 28 packages green. Wiring proven through a real RestartStack plus an AST walk of the four call sites. Three companion red-proofs run and reverted.
This commit is contained in:
@@ -121,6 +121,39 @@ type AppConfig struct {
|
||||
// the primitive would make a nightly backup indistinguishable from the customer pressing Stop,
|
||||
// which is the exact confusion this field exists to end. Writers: SetDesiredState's callers.
|
||||
DesiredState string `yaml:"desired_state,omitempty" json:"desired_state,omitempty"`
|
||||
// InstalledImages records what each compose service is ACTUALLY RUNNING, read from the
|
||||
// containers after a successful compose up — never from docker-compose.yml, which the catalog
|
||||
// syncer overwrites on a 15-minute cycle with no deployed check at all (measured live:
|
||||
// SPIKE-app-update-2026-09-01 §3, where the file said v2.8.5 while the container ran v2.8.6 for
|
||||
// 25 minutes). The file is the value that has already moved; the container is the fact.
|
||||
//
|
||||
// Keyed by COMPOSE SERVICE NAME, not container name: the service name is what the compose file
|
||||
// and the catalog template both key on, so it is the only key a comparison can be made against.
|
||||
//
|
||||
// ABSENT MEANS UNKNOWN AND NEVER MEANS CURRENT (the R-166 rule, applied to an observation
|
||||
// instead of an intent). Every app.yaml written before v0.233.0 has no entry here, so absent is
|
||||
// the common value on upgrade; a reader that treated it as "up to date" would tell every
|
||||
// customer on the fleet that their months-old app is current.
|
||||
//
|
||||
// WRITTEN BY: Manager.recordInstalledImages ONLY, from StartStack / RestartStack / UpdateStack
|
||||
// and the deploy path. READ BY: web.updateBadge (v0.233.0). Nothing takes a DECISION from it.
|
||||
InstalledImages map[string]InstalledImage `yaml:"installed_images,omitempty" json:"installed_images,omitempty"`
|
||||
}
|
||||
|
||||
// InstalledImage is one compose service's observed image. See AppConfig.InstalledImages.
|
||||
type InstalledImage struct {
|
||||
// Ref is the reference the container was created FROM, i.e. docker inspect .Config.Image —
|
||||
// e.g. "lscr.io/linuxserver/bookstack:26.05.2". This is what the template pins and what the
|
||||
// comparison uses.
|
||||
Ref string `yaml:"ref" json:"ref"`
|
||||
// Digest is the repo digest of the image behind that reference — the only identifier that
|
||||
// cannot move. Empty for an image that was never pulled from a registry (a locally built or
|
||||
// imported image has no RepoDigests); an empty digest is recorded, never a skipped entry.
|
||||
Digest string `yaml:"digest,omitempty" json:"digest,omitempty"`
|
||||
// At is RFC3339 UTC: when this exact Ref+Digest pair was FIRST observed for this service. It is
|
||||
// deliberately NOT re-stamped on every restart — an unchanged observation must not rewrite
|
||||
// app.yaml (the SetDesiredState rule), and "running since" is more useful than "last looked at".
|
||||
At string `yaml:"at" json:"at"`
|
||||
}
|
||||
|
||||
// DeployRequest contains the user-provided values from the deploy form.
|
||||
@@ -443,8 +476,13 @@ func (m *Manager) runComposeDeploy(name, stackDir string, env map[string]string,
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
// Post-deploy container state check (async, non-blocking)
|
||||
// Record what this deploy actually installed, per compose service (v0.233.0). Runs AFTER the
|
||||
// SaveAppConfig above so it loads an app.yaml that already reads deployed=true. A failure here
|
||||
// never fails the deploy — see recordInstalledImages.
|
||||
deployEnv := m.stackEnv(stackDir)
|
||||
m.recordInstalledImages(name, stackDir, deployEnv)
|
||||
|
||||
// Post-deploy container state check (async, non-blocking)
|
||||
m.logPostStartStatus(name, stackDir, deployEnv)
|
||||
|
||||
_ = m.RefreshStatus()
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// installedRecordTimeout bounds every docker call this file makes.
|
||||
//
|
||||
// REUSE.md's trap table says it in terms: composeExecCustomEnv and execCommand have NO context and
|
||||
// NO timeout, so a hung docker CLI blocks forever. That is tolerable for the compose `up` a customer
|
||||
// is waiting on; it is NOT tolerable here, because this runs AFTER every successful start, restart,
|
||||
// update and deploy purely to write a note down. A bookkeeping read must never be able to wedge a
|
||||
// lifecycle action. Three short reads share this budget generously.
|
||||
const installedRecordTimeout = 30 * time.Second
|
||||
|
||||
// execRunner is this file's process boundary — the one seam the installed-images recorder uses.
|
||||
//
|
||||
// It is deliberately its OWN seam rather than Manager.execFn / composeExecCustomEnv: those two are
|
||||
// already load-bearing for refreshStatusLocked and for the compose lifecycle, and both lack the
|
||||
// context this code needs. dir "" means "do not chdir"; env nil means inherit os.Environ().
|
||||
// nil in production (defaultExecRunner); tests script argv → output and never touch docker.
|
||||
type execRunner func(ctx context.Context, dir string, env []string, name string, args ...string) (string, error)
|
||||
|
||||
func defaultExecRunner(ctx context.Context, dir string, env []string, name string, args ...string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
if dir != "" {
|
||||
cmd.Dir = dir
|
||||
}
|
||||
if env != nil {
|
||||
cmd.Env = env
|
||||
}
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
stderr := ""
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
stderr = truncateStr(string(ee.Stderr), 500)
|
||||
}
|
||||
return string(out), fmt.Errorf("exec %s %s: %w\nstderr: %s", name, strings.Join(args, " "), err, stderr)
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
func (m *Manager) runInstalled(ctx context.Context, dir string, env []string, name string, args ...string) (string, error) {
|
||||
if m.installedExecFn != nil {
|
||||
return m.installedExecFn(ctx, dir, env, name, args...)
|
||||
}
|
||||
return defaultExecRunner(ctx, dir, env, name, args...)
|
||||
}
|
||||
|
||||
// composeArgv splits the configured compose command into an argv prefix, mirroring
|
||||
// composeExecCustomEnv's own docker-compose-v1-vs-v2 branch. One rule, two callers.
|
||||
func (m *Manager) composeArgv(args ...string) (string, []string) {
|
||||
if m.composeCmd == "docker compose" {
|
||||
return "docker", append([]string{"compose"}, args...)
|
||||
}
|
||||
return "docker-compose", args
|
||||
}
|
||||
|
||||
// --- The template side: what the compose FILE currently pins, per service ---
|
||||
|
||||
// composeImagesDoc is the minimal view of a compose file needed here.
|
||||
//
|
||||
// A REAL YAML parse and never a line scan, for the reason dbservices.go's composeServicesDoc already
|
||||
// records: immich's top-level `immich_ml_cache:` volume key has exactly the shape a naive scan
|
||||
// misreads as a service. Manager.checkLocalImages IS such a line scan and is deliberately not reused
|
||||
// — it also cannot say which service an image belongs to, which is the whole comparison.
|
||||
type composeImagesDoc struct {
|
||||
Services map[string]struct {
|
||||
Image string `yaml:"image"`
|
||||
} `yaml:"services"`
|
||||
}
|
||||
|
||||
// ParseComposeImages returns compose SERVICE name -> the image reference the file pins for it.
|
||||
//
|
||||
// Services with no `image:` (a `build:`-only service — none in the catalog today) are omitted rather
|
||||
// than recorded as an empty pin, because "" would compare equal to nothing useful. An unreadable or
|
||||
// unparseable file returns an error: CANNOT-TELL must never read as "no images", or an app whose
|
||||
// compose file is briefly mid-write would render as up to date.
|
||||
func ParseComposeImages(composePath string) (map[string]string, error) {
|
||||
data, err := os.ReadFile(composePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading compose file: %w", err)
|
||||
}
|
||||
var doc composeImagesDoc
|
||||
if err := yaml.Unmarshal(data, &doc); err != nil {
|
||||
return nil, fmt.Errorf("parsing compose file %s: %w", composePath, err)
|
||||
}
|
||||
out := make(map[string]string, len(doc.Services))
|
||||
for svc, def := range doc.Services {
|
||||
if strings.TrimSpace(def.Image) == "" {
|
||||
continue
|
||||
}
|
||||
out[svc] = strings.TrimSpace(def.Image)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// --- The container side: what is ACTUALLY running ---
|
||||
|
||||
// composePSEntry is the subset of `docker compose ps --format json` this needs.
|
||||
type composePSEntry struct {
|
||||
ID string `json:"ID"`
|
||||
Name string `json:"Name"`
|
||||
Service string `json:"Service"`
|
||||
}
|
||||
|
||||
// parseComposePS tolerates BOTH shapes compose v2 has emitted for `ps --format json`: a single JSON
|
||||
// array (compose < 2.21) and newline-delimited objects (2.21+). Neither shape is guessed at from a
|
||||
// version string — the output is tried as an array first and falls back to per-line objects, so an
|
||||
// upgrade of the docker CLI underneath a customer's box cannot silently stop the recording.
|
||||
func parseComposePS(out string) ([]composePSEntry, error) {
|
||||
trimmed := strings.TrimSpace(out)
|
||||
if trimmed == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "[") {
|
||||
var arr []composePSEntry
|
||||
if err := json.Unmarshal([]byte(trimmed), &arr); err != nil {
|
||||
return nil, fmt.Errorf("parsing compose ps JSON array: %w", err)
|
||||
}
|
||||
return arr, nil
|
||||
}
|
||||
var entries []composePSEntry
|
||||
for _, line := range strings.Split(trimmed, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var e composePSEntry
|
||||
if err := json.Unmarshal([]byte(line), &e); err != nil {
|
||||
return nil, fmt.Errorf("parsing compose ps JSON line: %w", err)
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// containerFacts is one container's two identifiers as docker reports them.
|
||||
type containerFacts struct {
|
||||
ref string // .Config.Image — the reference the container was CREATED FROM
|
||||
imageID string // .Image — the local image id it actually resolved to
|
||||
}
|
||||
|
||||
const inspectSep = "\x1f" // ASCII unit separator: cannot occur in an image ref or an id
|
||||
|
||||
// observeInstalledImages reads what every compose service of this stack is running.
|
||||
//
|
||||
// Three short docker reads, in order: which containers belong to which service; what reference and
|
||||
// image id each container carries; and what repo digest each of those images has. Nothing is read
|
||||
// from docker-compose.yml — that file is the value the syncer has already moved.
|
||||
func (m *Manager) observeInstalledImages(stackDir string, env []string) (map[string]InstalledImage, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), installedRecordTimeout)
|
||||
defer cancel()
|
||||
|
||||
bin, argv := m.composeArgv("ps", "-a", "--format", "json")
|
||||
psOut, err := m.runInstalled(ctx, stackDir, env, bin, argv...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing containers: %w", err)
|
||||
}
|
||||
entries, err := parseComposePS(psOut)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// service -> container id, deterministic when a service has replicas (none in the catalog, but
|
||||
// a compose `deploy.replicas` would produce several; take the first by name so two runs agree).
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
|
||||
svcContainer := make(map[string]string)
|
||||
var ids []string
|
||||
for _, e := range entries {
|
||||
if e.Service == "" || e.ID == "" {
|
||||
continue
|
||||
}
|
||||
if _, seen := svcContainer[e.Service]; seen {
|
||||
continue
|
||||
}
|
||||
svcContainer[e.Service] = e.ID
|
||||
ids = append(ids, e.ID)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return map[string]InstalledImage{}, nil
|
||||
}
|
||||
|
||||
facts, err := m.inspectContainers(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
digests, err := m.inspectImageDigests(ctx, facts)
|
||||
if err != nil {
|
||||
// A missing digest is a recorded empty string, never a failed recording — see the edge-case
|
||||
// table. Log and carry on with refs only.
|
||||
m.logger.Printf("[WARN] [stacks] installed-images: reading repo digests failed, recording refs only: %v", err)
|
||||
digests = map[string]string{}
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
out := make(map[string]InstalledImage, len(svcContainer))
|
||||
for svc, id := range svcContainer {
|
||||
f, ok := facts[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out[svc] = InstalledImage{
|
||||
Ref: f.ref,
|
||||
Digest: pickDigest(f.ref, digests[f.imageID]),
|
||||
At: now,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Manager) inspectContainers(ctx context.Context, ids []string) (map[string]containerFacts, error) {
|
||||
args := append([]string{"inspect", "--type", "container",
|
||||
"--format", "{{.Id}}" + inspectSep + "{{.Config.Image}}" + inspectSep + "{{.Image}}"}, ids...)
|
||||
out, err := m.runInstalled(ctx, "", nil, "docker", args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspecting containers: %w", err)
|
||||
}
|
||||
facts := make(map[string]containerFacts, len(ids))
|
||||
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||
parts := strings.Split(strings.TrimSpace(line), inspectSep)
|
||||
if len(parts) != 3 {
|
||||
continue
|
||||
}
|
||||
facts[parts[0]] = containerFacts{ref: parts[1], imageID: parts[2]}
|
||||
// docker accepts short ids on the way in and returns full ones on the way out; key both so
|
||||
// the caller's compose-ps id (12 hex) finds its row.
|
||||
if len(parts[0]) > 12 {
|
||||
facts[parts[0][:12]] = containerFacts{ref: parts[1], imageID: parts[2]}
|
||||
}
|
||||
}
|
||||
return facts, nil
|
||||
}
|
||||
|
||||
// inspectImageDigests maps image id -> its RepoDigests, joined by a space. Empty when an image has
|
||||
// none (built or imported locally, never pulled) — recorded as an empty digest, not as a failure.
|
||||
func (m *Manager) inspectImageDigests(ctx context.Context, facts map[string]containerFacts) (map[string]string, error) {
|
||||
seen := map[string]bool{}
|
||||
var imgs []string
|
||||
for _, f := range facts {
|
||||
if f.imageID != "" && !seen[f.imageID] {
|
||||
seen[f.imageID] = true
|
||||
imgs = append(imgs, f.imageID)
|
||||
}
|
||||
}
|
||||
if len(imgs) == 0 {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
sort.Strings(imgs)
|
||||
args := append([]string{"image", "inspect",
|
||||
"--format", "{{.Id}}" + inspectSep + "{{range $i, $d := .RepoDigests}}{{if $i}} {{end}}{{$d}}{{end}}"}, imgs...)
|
||||
out, err := m.runInstalled(ctx, "", nil, "docker", args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspecting images: %w", err)
|
||||
}
|
||||
digests := make(map[string]string, len(imgs))
|
||||
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||
parts := strings.SplitN(strings.TrimSpace(line), inspectSep, 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
digests[parts[0]] = strings.TrimSpace(parts[1])
|
||||
}
|
||||
return digests, nil
|
||||
}
|
||||
|
||||
// pickDigest turns a RepoDigests list into the ONE sha256 that belongs to the ref we asked for.
|
||||
//
|
||||
// A local image can carry several repo digests (the same bytes tagged from two registries), and
|
||||
// picking the wrong one would record a digest for a repository this app never used. Match on the
|
||||
// repository part of the ref first; fall back to the single entry when there is exactly one; give up
|
||||
// (empty) rather than guess between several unrelated ones.
|
||||
func pickDigest(ref, repoDigests string) string {
|
||||
fields := strings.Fields(repoDigests)
|
||||
if len(fields) == 0 {
|
||||
return ""
|
||||
}
|
||||
repo := refRepository(ref)
|
||||
for _, rd := range fields {
|
||||
at := strings.LastIndex(rd, "@")
|
||||
if at < 0 {
|
||||
continue
|
||||
}
|
||||
if repo != "" && rd[:at] == repo {
|
||||
return rd[at+1:]
|
||||
}
|
||||
}
|
||||
if len(fields) == 1 {
|
||||
if at := strings.LastIndex(fields[0], "@"); at >= 0 {
|
||||
return fields[0][at+1:]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// refRepository strips the tag and/or digest from an image reference, leaving the repository.
|
||||
// Careful with a registry port ("registry:5000/app:1.2"): only a colon AFTER the last slash is a tag.
|
||||
func refRepository(ref string) string {
|
||||
if at := strings.LastIndex(ref, "@"); at >= 0 {
|
||||
ref = ref[:at]
|
||||
}
|
||||
slash := strings.LastIndex(ref, "/")
|
||||
if colon := strings.LastIndex(ref, ":"); colon > slash {
|
||||
ref = ref[:colon]
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
// --- The write ---
|
||||
|
||||
// sameInstalled compares two records on Ref and Digest ONLY, deliberately ignoring At.
|
||||
// Including At would make every restart a change, and app.yaml would be rewritten — with its
|
||||
// encrypted secrets — on every lifecycle action for no new information.
|
||||
func sameInstalled(a, b map[string]InstalledImage) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for k, va := range a {
|
||||
vb, ok := b[k]
|
||||
if !ok || va.Ref != vb.Ref || va.Digest != vb.Digest {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// recordInstalledImages writes what this stack is ACTUALLY running into its app.yaml.
|
||||
//
|
||||
// ── WHY A FAILURE HERE NEVER REFUSES THE ACTION ──────────────────────────────────────────────
|
||||
//
|
||||
// This is deliberately the OPPOSITE of SetDesiredState, and the difference is what the field means.
|
||||
// `desired_state` is the customer's INTENT: performing an act whose intent could not be recorded
|
||||
// recreates exactly the ambiguity R-166 closed, so a failed write there correctly refuses the act.
|
||||
// `installed_images` is an OBSERVATION. Refusing to start a customer's app because we could not write
|
||||
// down which version it is would trade a real outage for a bookkeeping gap. So: log at ERROR, loudly,
|
||||
// naming the app — and return. The app stays up.
|
||||
//
|
||||
// Called after a SUCCESSFUL compose up from StartStack, RestartStack, UpdateStack and
|
||||
// runComposeDeploy. NOT from StartStackServices: that path starts only the database service for the
|
||||
// R-47 restore window, and recording a partial stack there would overwrite a complete record with an
|
||||
// incomplete one.
|
||||
func (m *Manager) recordInstalledImages(name, stackDir string, env []string) {
|
||||
cfg := LoadAppConfig(stackDir)
|
||||
if cfg == nil {
|
||||
// No app.yaml: an infra/protected stack, or nothing deployed here. Nothing to record on.
|
||||
if m.isDebug() {
|
||||
m.logger.Printf("[DEBUG] [stacks] installed-images %s: no app.yaml — nothing to record", name)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
observed, err := m.observeInstalledImages(stackDir, env)
|
||||
if err != nil {
|
||||
m.logger.Printf("[ERROR] [stacks] installed-images %s: could not observe running images (the app is unaffected): %v", name, err)
|
||||
return
|
||||
}
|
||||
if len(observed) == 0 {
|
||||
m.logger.Printf("[WARN] [stacks] installed-images %s: no containers observed — nothing recorded, previous record left intact", name)
|
||||
return
|
||||
}
|
||||
|
||||
// A partial read is recorded AND said out loud, never written silently: a record that quietly
|
||||
// lost a service would read as a complete answer to "what is this app running".
|
||||
if tpl, terr := ParseComposeImages(filepath.Join(stackDir, "docker-compose.yml")); terr == nil && len(tpl) > len(observed) {
|
||||
var missing []string
|
||||
for svc := range tpl {
|
||||
if _, ok := observed[svc]; !ok {
|
||||
missing = append(missing, svc)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
m.logger.Printf("[WARN] [stacks] installed-images %s: recorded %d of %d compose service(s) — not observed: %s",
|
||||
name, len(observed), len(tpl), strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
// Carry forward the first-seen timestamp of every entry whose ref+digest is unchanged, so `at`
|
||||
// answers "running since" rather than "last looked at".
|
||||
for svc, prev := range cfg.InstalledImages {
|
||||
if cur, ok := observed[svc]; ok && cur.Ref == prev.Ref && cur.Digest == prev.Digest && prev.At != "" {
|
||||
cur.At = prev.At
|
||||
observed[svc] = cur
|
||||
}
|
||||
}
|
||||
|
||||
if sameInstalled(cfg.InstalledImages, observed) {
|
||||
if m.isDebug() {
|
||||
m.logger.Printf("[DEBUG] [stacks] installed-images %s: unchanged (%d service(s)) — app.yaml not rewritten", name, len(observed))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
cfg.InstalledImages = observed
|
||||
meta := LoadMetadata(stackDir)
|
||||
if err := SaveAppConfig(stackDir, cfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
|
||||
m.logger.Printf("[ERROR] [stacks] installed-images %s: recording failed, the app is running and unaffected: %v", name, err)
|
||||
return
|
||||
}
|
||||
m.logger.Printf("[INFO] [stacks] installed-images %s: recorded %d service(s) (%s)", name, len(observed), summariseInstalled(observed))
|
||||
|
||||
// Keep the in-memory view in step so the badge does not lag a full ScanStacks behind the file.
|
||||
m.mu.Lock()
|
||||
if s, ok := m.stacks[name]; ok && s.AppConfig != nil {
|
||||
s.AppConfig.InstalledImages = observed
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// summariseInstalled renders the record for ONE log line. Image refs and digests only — this file
|
||||
// never logs anything out of app.yaml's env map, which holds encrypted secrets.
|
||||
func summariseInstalled(m map[string]InstalledImage) string {
|
||||
svcs := make([]string, 0, len(m))
|
||||
for svc := range m {
|
||||
svcs = append(svcs, svc)
|
||||
}
|
||||
sort.Strings(svcs)
|
||||
parts := make([]string, 0, len(svcs))
|
||||
for _, svc := range svcs {
|
||||
d := m[svc].Digest
|
||||
if len(d) > 19 {
|
||||
d = d[:19] + "…"
|
||||
}
|
||||
if d == "" {
|
||||
d = "no digest"
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s=%s (%s)", svc, m[svc].Ref, d))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Slice 1 (v0.233.0) — the box writes down what it ACTUALLY installed.
|
||||
//
|
||||
// Every assertion here reads app.yaml BACK OFF DISK and checks the entries, their count and their
|
||||
// digests. "recordInstalledImages returned" proves nothing: the whole feature is a durable record.
|
||||
|
||||
// --- the docker seam ---
|
||||
|
||||
// fakeContainer is one scripted container: what `docker inspect` will say about it.
|
||||
type fakeContainer struct {
|
||||
id string
|
||||
ref string
|
||||
imageID string
|
||||
}
|
||||
|
||||
// scriptedInstalledDocker returns an execRunner that answers the recorder's three reads from canned data and
|
||||
// never touches a daemon. It fails the test on an argv it does not recognise, so a change to the
|
||||
// commands the recorder issues cannot pass silently.
|
||||
func scriptedInstalledDocker(t *testing.T, psOut string, containers []fakeContainer, digests map[string]string) execRunner {
|
||||
t.Helper()
|
||||
return func(_ context.Context, _ string, _ []string, name string, args ...string) (string, error) {
|
||||
// Accept BOTH compose spellings — composeArgv emits `docker compose ps` or `docker-compose
|
||||
// ps` depending on the configured command, and the wiring tests use the latter.
|
||||
if name == "docker" && len(args) >= 1 && args[0] == "compose" {
|
||||
args = args[1:]
|
||||
name = "docker-compose"
|
||||
}
|
||||
switch {
|
||||
case name == "docker-compose" && len(args) >= 1 && args[0] == "ps":
|
||||
return psOut, nil
|
||||
case name == "docker" && len(args) >= 1 && args[0] == "inspect":
|
||||
var b strings.Builder
|
||||
for _, want := range args {
|
||||
for _, c := range containers {
|
||||
if c.id == want {
|
||||
fmt.Fprintf(&b, "%s%s%s%s%s\n", c.id, inspectSep, c.ref, inspectSep, c.imageID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String(), nil
|
||||
case name == "docker" && len(args) >= 2 && args[0] == "image" && args[1] == "inspect":
|
||||
var b strings.Builder
|
||||
for _, want := range args {
|
||||
if d, ok := digests[want]; ok {
|
||||
fmt.Fprintf(&b, "%s%s%s\n", want, inspectSep, d)
|
||||
}
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
t.Fatalf("unexpected command in test: %s %v", name, args)
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
const threeServiceCompose = `services:
|
||||
web:
|
||||
image: lscr.io/linuxserver/bookstack:26.05.2
|
||||
db:
|
||||
image: mariadb:12.3
|
||||
cache:
|
||||
image: redis:7-alpine
|
||||
volumes:
|
||||
bookstack_config:
|
||||
`
|
||||
|
||||
// newInstalledManager builds a Manager over one real stack directory. Real FS, because the thing
|
||||
// under test is a file write.
|
||||
func newInstalledManager(t *testing.T, compose, appYAML string) (*Manager, string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
dir := filepath.Join(root, "bookstack")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte(compose), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if appYAML != "" {
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte(appYAML), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Paths.StacksDir = root
|
||||
m := &Manager{
|
||||
cfg: cfg,
|
||||
logger: log.New(io.Discard, "", 0),
|
||||
composeCmd: "docker compose",
|
||||
encKey: []byte("0123456789abcdef0123456789abcdef"),
|
||||
stacks: map[string]*Stack{
|
||||
"bookstack": {Name: "bookstack", ComposePath: filepath.Join(dir, "docker-compose.yml"), Deployed: true},
|
||||
},
|
||||
}
|
||||
// Mirror ScanStacks: the in-memory stack carries the loaded app.yaml and the template's pins.
|
||||
m.stacks["bookstack"].AppConfig = LoadAppConfig(dir)
|
||||
if imgs, err := ParseComposeImages(filepath.Join(dir, "docker-compose.yml")); err == nil {
|
||||
m.stacks["bookstack"].TemplateImages = imgs
|
||||
}
|
||||
return m, dir
|
||||
}
|
||||
|
||||
func readInstalled(t *testing.T, dir string) *AppConfig {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(filepath.Join(dir, "app.yaml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &AppConfig{}
|
||||
if err := yaml.Unmarshal(b, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
const ndjsonPS = `{"ID":"aaa111","Name":"bookstack","Service":"web"}
|
||||
{"ID":"bbb222","Name":"bookstack-db","Service":"db"}
|
||||
{"ID":"ccc333","Name":"bookstack-cache","Service":"cache"}`
|
||||
|
||||
func threeContainers() ([]fakeContainer, map[string]string) {
|
||||
return []fakeContainer{
|
||||
{id: "aaa111", ref: "lscr.io/linuxserver/bookstack:26.05.2", imageID: "sha256:img-web"},
|
||||
{id: "bbb222", ref: "mariadb:12.3", imageID: "sha256:img-db"},
|
||||
{id: "ccc333", ref: "redis:7-alpine", imageID: "sha256:img-cache"},
|
||||
}, map[string]string{
|
||||
"sha256:img-web": "lscr.io/linuxserver/bookstack@sha256:aaaaaaaa",
|
||||
"sha256:img-db": "mariadb@sha256:bbbbbbbb",
|
||||
"sha256:img-cache": "redis@sha256:cccccccc",
|
||||
}
|
||||
}
|
||||
|
||||
// --- GROUP A: one entry PER COMPOSE SERVICE, with digests ---
|
||||
|
||||
// TestGroupA_RecordsOneEntryPerService is the case that matters: a MULTI-container app. The wrong
|
||||
// implementation records one entry for the whole stack, and it would pass any single-service test.
|
||||
func TestGroupA_RecordsOneEntryPerService(t *testing.T) {
|
||||
m, dir := newInstalledManager(t, threeServiceCompose, "deployed: true\nenv: {}\n")
|
||||
cs, digs := threeContainers()
|
||||
m.installedExecFn = scriptedInstalledDocker(t, ndjsonPS, cs, digs)
|
||||
|
||||
before := time.Now().UTC().Add(-time.Second)
|
||||
m.recordInstalledImages("bookstack", dir, nil)
|
||||
|
||||
got := readInstalled(t, dir).InstalledImages
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("recorded %d entries, want ONE PER COMPOSE SERVICE (3): %+v", len(got), got)
|
||||
}
|
||||
want := map[string][2]string{
|
||||
"web": {"lscr.io/linuxserver/bookstack:26.05.2", "sha256:aaaaaaaa"},
|
||||
"db": {"mariadb:12.3", "sha256:bbbbbbbb"},
|
||||
"cache": {"redis:7-alpine", "sha256:cccccccc"},
|
||||
}
|
||||
for svc, w := range want {
|
||||
e, ok := got[svc]
|
||||
if !ok {
|
||||
t.Fatalf("service %q missing — entries must be keyed by COMPOSE SERVICE NAME, got %+v", svc, got)
|
||||
}
|
||||
if e.Ref != w[0] {
|
||||
t.Errorf("%s ref = %q, want %q", svc, e.Ref, w[0])
|
||||
}
|
||||
if e.Digest != w[1] {
|
||||
t.Errorf("%s digest = %q, want %q — the digest is the only identifier that cannot lie", svc, e.Digest, w[1])
|
||||
}
|
||||
ts, err := time.Parse(time.RFC3339, e.At)
|
||||
if err != nil {
|
||||
t.Errorf("%s at = %q, not RFC3339: %v", svc, e.At, err)
|
||||
} else if ts.Before(before) {
|
||||
t.Errorf("%s at = %v, older than the run that produced it", svc, ts)
|
||||
}
|
||||
}
|
||||
// The record must NOT have been assembled from the compose file: prove it by checking the
|
||||
// deployed marker survived the copy-and-overlay save.
|
||||
if !readInstalled(t, dir).Deployed {
|
||||
t.Error("the save dropped deployed=true — SaveAppConfig must stay copy-and-overlay")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroupA_ImageWithNoRepoDigestRecordsAnEmptyDigest — a locally built or imported image has no
|
||||
// RepoDigests. The entry is still recorded, with an empty digest: skipping it would silently lose a
|
||||
// service from the record.
|
||||
func TestGroupA_ImageWithNoRepoDigestRecordsAnEmptyDigest(t *testing.T) {
|
||||
m, dir := newInstalledManager(t, "services:\n web:\n image: local/built:dev\n", "deployed: true\nenv: {}\n")
|
||||
m.installedExecFn = scriptedInstalledDocker(t,
|
||||
`{"ID":"aaa111","Name":"w","Service":"web"}`,
|
||||
[]fakeContainer{{id: "aaa111", ref: "local/built:dev", imageID: "sha256:local"}},
|
||||
map[string]string{"sha256:local": ""})
|
||||
|
||||
m.recordInstalledImages("app", dir, nil)
|
||||
got := readInstalled(t, dir).InstalledImages
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("an image with no repo digest must still be RECORDED, got %+v", got)
|
||||
}
|
||||
if got["web"].Ref != "local/built:dev" || got["web"].Digest != "" {
|
||||
t.Fatalf("want ref recorded and digest empty, got %+v", got["web"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroupA_MissingContainerRecordsWhatExists — the edge-case table: record what is there, and say
|
||||
// the count out loud. A partial record written silently would read as a complete answer.
|
||||
func TestGroupA_MissingContainerRecordsWhatExists(t *testing.T) {
|
||||
var logs strings.Builder
|
||||
m, dir := newInstalledManager(t, threeServiceCompose, "deployed: true\nenv: {}\n")
|
||||
m.logger = log.New(&logs, "", 0)
|
||||
cs, digs := threeContainers()
|
||||
m.installedExecFn = scriptedInstalledDocker(t,
|
||||
`{"ID":"aaa111","Name":"bookstack","Service":"web"}
|
||||
{"ID":"bbb222","Name":"bookstack-db","Service":"db"}`, cs, digs)
|
||||
|
||||
m.recordInstalledImages("bookstack", dir, nil)
|
||||
got := readInstalled(t, dir).InstalledImages
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("want the 2 observed services recorded, got %+v", got)
|
||||
}
|
||||
if !strings.Contains(logs.String(), "recorded 2 of 3") || !strings.Contains(logs.String(), "cache") {
|
||||
t.Fatalf("a partial read must be said out loud, naming what is missing. Log was:\n%s", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- GROUP B: the record follows the CONTAINER, not the file ---
|
||||
|
||||
// TestGroupB_RecordFollowsTheContainerNotTheFile is the reason this feature exists. The compose file
|
||||
// and the running container can disagree indefinitely (measured: SPIKE §3 — 25 minutes). Here the
|
||||
// FILE says one thing and the CONTAINER another; the record must carry the container's answer.
|
||||
//
|
||||
// It also pins the re-record half of Scenario B: an existing record for the OLD image is replaced,
|
||||
// not left standing. A record that goes stale is worse than none, because it will be trusted.
|
||||
func TestGroupB_RecordFollowsTheContainerNotTheFile(t *testing.T) {
|
||||
const old = `deployed: true
|
||||
env: {}
|
||||
installed_images:
|
||||
web:
|
||||
ref: ghcr.io/alam00000/bentopdf:v2.8.5
|
||||
digest: sha256:oldoldold
|
||||
at: "2026-09-01T17:36:35Z"
|
||||
`
|
||||
// The FILE pins v2.8.5 — exactly the post-sync state the spike measured.
|
||||
m, dir := newInstalledManager(t, "services:\n web:\n image: ghcr.io/alam00000/bentopdf:v2.8.5\n", old)
|
||||
// The CONTAINER runs v2.8.6.
|
||||
m.installedExecFn = scriptedInstalledDocker(t,
|
||||
`{"ID":"aaa111","Name":"bentopdf","Service":"web"}`,
|
||||
[]fakeContainer{{id: "aaa111", ref: "ghcr.io/alam00000/bentopdf:v2.8.6", imageID: "sha256:new"}},
|
||||
map[string]string{"sha256:new": "ghcr.io/alam00000/bentopdf@sha256:newnewnew"})
|
||||
|
||||
m.recordInstalledImages("bentopdf", dir, nil)
|
||||
|
||||
got := readInstalled(t, dir).InstalledImages["web"]
|
||||
if got.Ref != "ghcr.io/alam00000/bentopdf:v2.8.6" {
|
||||
t.Fatalf("ref = %q — the record must read the CONTAINER; the file is the value that has already moved", got.Ref)
|
||||
}
|
||||
if got.Digest != "sha256:newnewnew" {
|
||||
t.Fatalf("digest = %q, want the new one — a record that goes stale is worse than none", got.Digest)
|
||||
}
|
||||
if got.At == "2026-09-01T17:36:35Z" {
|
||||
t.Fatal("`at` must be re-stamped when the image CHANGES")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroupB_UnchangedObservationDoesNotRewriteAppYAML — the SetDesiredState rule. app.yaml holds
|
||||
// encrypted secrets; rewriting it on every restart for no new information is pure risk. `at` is
|
||||
// therefore also carried forward, so it answers "running since" and not "last looked at".
|
||||
func TestGroupB_UnchangedObservationDoesNotRewriteAppYAML(t *testing.T) {
|
||||
const same = `deployed: true
|
||||
env: {}
|
||||
installed_images:
|
||||
web:
|
||||
ref: nginx:1.27
|
||||
digest: sha256:keepme
|
||||
at: "2026-08-01T00:00:00Z"
|
||||
`
|
||||
m, dir := newInstalledManager(t, "services:\n web:\n image: nginx:1.27\n", same)
|
||||
m.installedExecFn = scriptedInstalledDocker(t,
|
||||
`{"ID":"aaa111","Name":"n","Service":"web"}`,
|
||||
[]fakeContainer{{id: "aaa111", ref: "nginx:1.27", imageID: "sha256:i"}},
|
||||
map[string]string{"sha256:i": "nginx@sha256:keepme"})
|
||||
|
||||
path := filepath.Join(dir, "app.yaml")
|
||||
st0, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.recordInstalledImages("app", dir, nil)
|
||||
st1, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !st0.ModTime().Equal(st1.ModTime()) || st0.Size() != st1.Size() {
|
||||
t.Error("an unchanged observation must not rewrite app.yaml")
|
||||
}
|
||||
if got := readInstalled(t, dir).InstalledImages["web"].At; got != "2026-08-01T00:00:00Z" {
|
||||
t.Errorf("at = %q — the first-seen timestamp must be carried forward, not re-stamped", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- GROUP C: recording fails, the ACTION still succeeds ---
|
||||
|
||||
// TestGroupC_UnwritableAppYAMLDoesNotFailTheAction is the deliberate opposite of SetDesiredState.
|
||||
// `desired_state` is INTENT and a failed write correctly refuses the act. `installed_images` is an
|
||||
// OBSERVATION: refusing to restart a customer's app because we could not write down which version it
|
||||
// is would trade a real outage for a bookkeeping gap.
|
||||
//
|
||||
// COMPANION RED-PROOF (run 2026-09-02): give recordInstalledImages an `error` return and make
|
||||
// RestartStack `return` it on failure. This test then fails with "restart must SUCCEED" — i.e. the
|
||||
// customer's app refuses to start because a note could not be written. Reverted.
|
||||
func TestGroupC_UnwritableAppYAMLDoesNotFailTheAction(t *testing.T) {
|
||||
if os.Getuid() == 0 {
|
||||
t.Skip("root ignores directory permissions — this test cannot make a write fail")
|
||||
}
|
||||
var logs strings.Builder
|
||||
m, dir := newInstalledManager(t, "services:\n web:\n image: nginx:1.27\n", "deployed: true\nenv: {}\n")
|
||||
m.logger = log.New(&logs, "", 0)
|
||||
m.installedExecFn = scriptedInstalledDocker(t,
|
||||
`{"ID":"aaa111","Name":"n","Service":"web"}`,
|
||||
[]fakeContainer{{id: "aaa111", ref: "nginx:1.27", imageID: "sha256:i"}},
|
||||
map[string]string{"sha256:i": "nginx@sha256:d"})
|
||||
withFakeCompose(t, m)
|
||||
|
||||
// Read-only stack dir: SaveAppConfig's tmp+rename cannot create its temp file.
|
||||
if err := os.Chmod(dir, 0o555); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(dir, 0o755) })
|
||||
|
||||
if err := m.RestartStack("bookstack"); err != nil {
|
||||
t.Fatalf("restart must SUCCEED even when the record cannot be written: %v", err)
|
||||
}
|
||||
out := logs.String()
|
||||
if !strings.Contains(out, "[ERROR]") || !strings.Contains(out, "installed-images bookstack") {
|
||||
t.Fatalf("the failure must be logged at ERROR, naming the app. Log was:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "unaffected") {
|
||||
t.Errorf("the ERROR line should say the app is unaffected, so it is not read as an outage. Log was:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// --- GROUP E: the WIRING — reached through the REAL caller ---
|
||||
|
||||
// withFakeCompose puts a stub `docker-compose` on PATH and points the manager at it, so a REAL
|
||||
// RestartStack can run to completion without a docker daemon. It is the compose process boundary
|
||||
// that is faked, not the recorder — the recorder is reached exactly as production reaches it.
|
||||
func withFakeCompose(t *testing.T, m *Manager) {
|
||||
t.Helper()
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("the stub compose binary is a shell script")
|
||||
}
|
||||
bin := t.TempDir()
|
||||
script := "#!/bin/sh\nexit 0\n"
|
||||
if err := os.WriteFile(filepath.Join(bin, "docker-compose"), []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
m.composeCmd = "docker-compose"
|
||||
// refreshStatusLocked's `docker ps` — the OTHER, pre-existing seam.
|
||||
m.execFn = func(string, ...string) (string, error) { return "", nil }
|
||||
}
|
||||
|
||||
// TestGroupE_RestartStackReachesTheRecorder is the seam-discipline test. Three shipped defects in
|
||||
// three days were injected-seam tests that proved a component whose caller never invoked it, so at
|
||||
// least one test must reach recordInstalledImages through a REAL production caller. RestartStack is
|
||||
// invoked here in full; only the compose and `docker ps` process boundaries are stubbed.
|
||||
func TestGroupE_RestartStackReachesTheRecorder(t *testing.T) {
|
||||
m, dir := newInstalledManager(t, "services:\n web:\n image: nginx:1.27\n", "deployed: true\nenv: {}\n")
|
||||
m.installedExecFn = scriptedInstalledDocker(t,
|
||||
`{"ID":"aaa111","Name":"n","Service":"web"}`,
|
||||
[]fakeContainer{{id: "aaa111", ref: "nginx:1.27", imageID: "sha256:i"}},
|
||||
map[string]string{"sha256:i": "nginx@sha256:wired"})
|
||||
withFakeCompose(t, m)
|
||||
|
||||
if err := m.RestartStack("bookstack"); err != nil {
|
||||
t.Fatalf("restart: %v", err)
|
||||
}
|
||||
got := readInstalled(t, dir).InstalledImages
|
||||
if len(got) != 1 || got["web"].Digest != "sha256:wired" {
|
||||
t.Fatalf("RestartStack did not reach the recorder — app.yaml holds %+v", got)
|
||||
}
|
||||
// And the in-memory view is in step, so the badge does not lag a ScanStacks behind the file.
|
||||
if s, ok := m.GetStack("bookstack"); !ok || s.AppConfig == nil || s.AppConfig.InstalledImages["web"].Digest != "sha256:wired" {
|
||||
t.Error("the in-memory AppConfig must be updated too")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroupE_EveryBringUpPathCallsTheRecorder walks the AST of the production sources for the four
|
||||
// paths that cannot each be driven to completion from a unit test.
|
||||
//
|
||||
// An AST walk, NOT a strings.Contains: a commented-out call still contains the string, and that is
|
||||
// exactly the shape a "seam built but never wired" defect takes. It also asserts the NEGATIVE —
|
||||
// StartStackServices must NOT call it, because that path starts only the database service for the
|
||||
// R-47 window and would overwrite a complete record with an incomplete one.
|
||||
func TestGroupE_EveryBringUpPathCallsTheRecorder(t *testing.T) {
|
||||
callers := map[string]bool{} // enclosing func name -> calls recordInstalledImages
|
||||
fset := token.NewFileSet()
|
||||
for _, src := range []string{"manager.go", "deploy.go"} {
|
||||
f, err := parser.ParseFile(fset, src, nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, d := range f.Decls {
|
||||
fn, ok := d.(*ast.FuncDecl)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if sel, ok := call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "recordInstalledImages" {
|
||||
found = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
if found {
|
||||
callers[fn.Name.Name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"StartStack", "RestartStack", "UpdateStack", "runComposeDeploy"} {
|
||||
if !callers[want] {
|
||||
t.Errorf("%s does not call recordInstalledImages — a bring-up path that records nothing leaves a stale record standing", want)
|
||||
}
|
||||
}
|
||||
if callers["StartStackServices"] {
|
||||
t.Error("StartStackServices must NOT record: it starts only the DB service for the R-47 window, and a partial record would overwrite a complete one")
|
||||
}
|
||||
}
|
||||
|
||||
// --- parsing units ---
|
||||
|
||||
func TestParseComposePS_BothShapes(t *testing.T) {
|
||||
arr := `[{"ID":"a","Name":"n1","Service":"web"},{"ID":"b","Name":"n2","Service":"db"}]`
|
||||
for name, in := range map[string]string{"ndjson": ndjsonPS, "array": arr} {
|
||||
got, err := parseComposePS(in)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
if len(got) < 2 || got[0].Service == "" {
|
||||
t.Fatalf("%s: parsed %+v", name, got)
|
||||
}
|
||||
}
|
||||
if got, err := parseComposePS(" "); err != nil || got != nil {
|
||||
t.Errorf("empty output must be an empty list, not an error: %v %v", got, err)
|
||||
}
|
||||
if _, err := parseComposePS("not json"); err == nil {
|
||||
t.Error("unparseable output must be an ERROR — cannot-tell must never read as no-containers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickDigestAndRefRepository(t *testing.T) {
|
||||
cases := []struct{ ref, digests, want string }{
|
||||
{"mariadb:12.3", "mariadb@sha256:aaa", "sha256:aaa"},
|
||||
{"mariadb:12.3", "", ""},
|
||||
// Two repos, same bytes: pick the one this app's ref names, never the other.
|
||||
{"mariadb:12.3", "mirror.example/mariadb@sha256:zzz mariadb@sha256:aaa", "sha256:aaa"},
|
||||
// A registry PORT is not a tag.
|
||||
{"registry:5000/app:1.2", "registry:5000/app@sha256:bbb", "sha256:bbb"},
|
||||
// Sole entry, repo does not match: fall back rather than lose the digest.
|
||||
{"weird:1", "other@sha256:ccc", "sha256:ccc"},
|
||||
// Several unrelated entries and none matches: give up rather than guess.
|
||||
{"weird:1", "a@sha256:1 b@sha256:2", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := pickDigest(c.ref, c.digests); got != c.want {
|
||||
t.Errorf("pickDigest(%q, %q) = %q, want %q", c.ref, c.digests, got, c.want)
|
||||
}
|
||||
}
|
||||
if got := refRepository("registry:5000/app:1.2"); got != "registry:5000/app" {
|
||||
t.Errorf("refRepository dropped a registry port: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseComposeImages_RealYAMLParse pins the reason this is not a line scan: immich's top-level
|
||||
// volume keys have exactly the shape a naive scan misreads as a service.
|
||||
func TestParseComposeImages_RealYAMLParse(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "docker-compose.yml")
|
||||
body := `services:
|
||||
immich-server:
|
||||
image: ghcr.io/immich-app/immich-server:v2.0.1
|
||||
immich-db:
|
||||
image: ghcr.io/immich-app/postgres:16
|
||||
volumes:
|
||||
immich_ml_cache:
|
||||
immich_postgres_data:
|
||||
`
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ParseComposeImages(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("parsed %d services, want 2 — a top-level volume key is NOT a service: %+v", len(got), got)
|
||||
}
|
||||
if _, err := ParseComposeImages(filepath.Join(dir, "nope.yml")); err == nil {
|
||||
t.Error("an unreadable file must be an ERROR — cannot-tell must never read as no-images")
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,13 @@ type Stack struct {
|
||||
// forgetting costs at most one threshold window, whereas persisting could carry a stale
|
||||
// "this app is crash-looping" verdict across the restart that fixed it.
|
||||
RestartingSince time.Time `json:"restarting_since,omitempty"`
|
||||
// TemplateImages is what the stack's CURRENT docker-compose.yml pins, per compose service —
|
||||
// i.e. what the catalog says this app should be running right now. Refreshed by ScanStacks for
|
||||
// deployed, non-protected apps only; nil for everything else and nil when the file cannot be
|
||||
// parsed. Nil means CANNOT-TELL and never means "matches": web.updateBadge renders nothing.
|
||||
// Not persisted — it is a read of a file the syncer owns, and re-reading is cheaper than a
|
||||
// second copy that can go stale.
|
||||
TemplateImages map[string]string `json:"template_images,omitempty"`
|
||||
}
|
||||
|
||||
// Manager handles all docker compose stack operations.
|
||||
@@ -200,6 +207,11 @@ type Manager struct {
|
||||
// Debug dump network section); nil in production. One seam for all guest-net reads — tests
|
||||
// script canned `ip`/resolv.conf outputs per argv and never touch docker.
|
||||
guestNetExecFn func(args ...string) (string, error)
|
||||
// installedExecFn is the installed-images recorder's OWN process boundary (installed.go); nil in
|
||||
// production (defaultExecRunner). Separate from execFn deliberately: this one carries a context
|
||||
// and a timeout, which execFn/composeExecCustomEnv do not, and a bookkeeping read must never be
|
||||
// able to wedge a lifecycle action. Tests script argv -> output and never touch docker.
|
||||
installedExecFn execRunner
|
||||
}
|
||||
|
||||
// SetSambaRunProbe injects the samba liveness probe. Exported for the same reason
|
||||
@@ -502,6 +514,19 @@ func (m *Manager) ScanStacks() error {
|
||||
m.logger.Printf("[DEBUG] [stacks] ScanStacks: found stack %q deployed=%v composePath=%s", name, deployed, composePath)
|
||||
}
|
||||
|
||||
// What the CURRENT template pins, for the update badge. Deployed non-protected apps only:
|
||||
// an undeployed template has nothing to compare against, and infra stacks are not the
|
||||
// customer's to update. A parse failure leaves this nil, which reads as CANNOT-TELL.
|
||||
var tplImages map[string]string
|
||||
if deployed && !m.cfg.IsProtectedStack(name) {
|
||||
imgs, ierr := ParseComposeImages(composePath)
|
||||
if ierr != nil {
|
||||
m.logger.Printf("[WARN] [stacks] ScanStacks: cannot read image pins from %s: %v", composePath, ierr)
|
||||
} else {
|
||||
tplImages = imgs
|
||||
}
|
||||
}
|
||||
|
||||
if existing, ok := m.stacks[name]; ok {
|
||||
existing.ComposePath = composePath
|
||||
existing.Meta = meta
|
||||
@@ -511,16 +536,18 @@ func (m *Manager) ScanStacks() error {
|
||||
if !existing.Deploying {
|
||||
existing.Deployed = deployed
|
||||
existing.AppConfig = appCfg
|
||||
existing.TemplateImages = tplImages
|
||||
}
|
||||
} else {
|
||||
m.stacks[name] = &Stack{
|
||||
Name: name,
|
||||
Meta: meta,
|
||||
ComposePath: composePath,
|
||||
State: StateNotDeployed,
|
||||
Deployed: deployed,
|
||||
Protected: m.cfg.IsProtectedStack(name),
|
||||
AppConfig: appCfg,
|
||||
Name: name,
|
||||
Meta: meta,
|
||||
ComposePath: composePath,
|
||||
State: StateNotDeployed,
|
||||
Deployed: deployed,
|
||||
Protected: m.cfg.IsProtectedStack(name),
|
||||
AppConfig: appCfg,
|
||||
TemplateImages: tplImages,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1052,6 +1079,7 @@ func (m *Manager) StartStack(name string) error {
|
||||
}
|
||||
|
||||
m.logger.Printf("[INFO] [stacks] Stack %s started successfully (took %.1fs)", name, time.Since(start).Seconds())
|
||||
m.recordInstalledImages(name, dir, env)
|
||||
m.logPostStartStatus(name, dir, env)
|
||||
|
||||
// Clear stale health probe so refreshStatus won't re-apply an old unhealthy override.
|
||||
@@ -1155,6 +1183,7 @@ func (m *Manager) RestartStack(name string) error {
|
||||
}
|
||||
|
||||
m.logger.Printf("[INFO] [stacks] Stack %s restarted successfully (took %.1fs)", name, time.Since(start).Seconds())
|
||||
m.recordInstalledImages(name, dir, env)
|
||||
m.logPostStartStatus(name, dir, env)
|
||||
|
||||
// Clear stale health probe so refreshStatus won't re-apply an old unhealthy override.
|
||||
@@ -1193,6 +1222,7 @@ func (m *Manager) UpdateStack(name string) error {
|
||||
}
|
||||
|
||||
m.logger.Printf("[INFO] [stacks] Stack %s updated successfully (took %.1fs)", name, time.Since(start).Seconds())
|
||||
m.recordInstalledImages(name, dir, env)
|
||||
m.logPostStartStatus(name, dir, env)
|
||||
return m.RefreshStatus()
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -30,6 +31,15 @@ type Metadata struct {
|
||||
// An UNKNOWN value degrades to available with one WARN (see LoadMetadata) — a typo in a catalog
|
||||
// push must never brick a template.
|
||||
Lifecycle string `yaml:"lifecycle,omitempty" json:"lifecycle,omitempty"`
|
||||
// CatalogSince is the date (YYYY-MM-DD) on which THIS CATALOG last changed the app's pinned
|
||||
// images. It is not a version and it is not an upstream release date — it answers only
|
||||
// "how long has a newer pin been sitting in the catalog", which is the one thing a household
|
||||
// can act on. The customer never sees a version string anywhere (operator ruling, 2026-09-02).
|
||||
//
|
||||
// OPTIONAL and TOLERANT in the Lifecycle style: absent, empty, malformed or dated in the FUTURE
|
||||
// all degrade to "no age known" with one WARN, and the badge simply renders without an age. A
|
||||
// catalog push must never be able to brick a template.
|
||||
CatalogSince string `yaml:"catalog_since,omitempty" json:"catalog_since,omitempty"`
|
||||
// OpenPath is appended to the app's public URL for the "Megnyitás" (open) link, for apps whose UI
|
||||
// isn't at "/" (e.g. Gokapi → "/admin"). Empty = bare root. Must start with "/".
|
||||
OpenPath string `yaml:"open_path,omitempty" json:"open_path,omitempty"`
|
||||
@@ -256,6 +266,37 @@ func (m Metadata) CanInstall() bool { return m.EffectiveLifecycle() == Lifecycle
|
||||
// IsAbandoned reports whether a DEPLOYED instance should carry the "no longer maintained" notice.
|
||||
func (m Metadata) IsAbandoned() bool { return m.EffectiveLifecycle() == LifecycleAbandoned }
|
||||
|
||||
// catalogSinceLayout is the ONE accepted form. Deliberately a single strict layout rather than a
|
||||
// list of tolerated ones: a date this code half-guesses at would print a confident "45 napja" from
|
||||
// a value nobody checked.
|
||||
const catalogSinceLayout = "2006-01-02"
|
||||
|
||||
// CatalogSinceAge returns how many WHOLE DAYS ago this app's pins last moved in the catalog, and
|
||||
// whether that age is knowable at all. VALUE receiver, for the reason stated above CanInstall.
|
||||
//
|
||||
// FALSE — the age is unknown — for every degraded case: absent, empty, unparseable, and a date in
|
||||
// the FUTURE. The future case is not pedantry: a box whose clock is behind the catalog's would
|
||||
// otherwise render "-3 napja", which is worse than saying nothing. `now` is injected so the rule is
|
||||
// a testable contract and not a property of the clock.
|
||||
func (m Metadata) CatalogSinceAge(now time.Time) (int, bool) {
|
||||
if strings.TrimSpace(m.CatalogSince) == "" {
|
||||
return 0, false
|
||||
}
|
||||
since, err := time.Parse(catalogSinceLayout, strings.TrimSpace(m.CatalogSince))
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
// Compare CALENDAR DAYS, not elapsed hours: "yesterday" must read as 1 napja whether it is now
|
||||
// 00:30 or 23:30, and a duration division answers 0 for one of those.
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
sinceDay := time.Date(since.Year(), since.Month(), since.Day(), 0, 0, 0, 0, time.UTC)
|
||||
days := int(today.Sub(sinceDay).Hours() / 24)
|
||||
if days < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return days, true
|
||||
}
|
||||
|
||||
// LoadMetadata reads .felhom.yml from a stack directory.
|
||||
// Returns default metadata if the file doesn't exist.
|
||||
func LoadMetadata(stackDir string) Metadata {
|
||||
@@ -300,6 +341,16 @@ func LoadMetadata(stackDir string) Metadata {
|
||||
dirName, meta.Lifecycle, LifecycleAvailable, LifecycleAvailable, LifecycleHidden, LifecycleAbandoned)
|
||||
}
|
||||
|
||||
// catalog_since: warn ONCE per load on a value that is present but unusable, then let
|
||||
// CatalogSinceAge degrade it to "no age known". Same placement and same reason as Lifecycle
|
||||
// above — one line per load, not one per render.
|
||||
if raw := strings.TrimSpace(meta.CatalogSince); raw != "" {
|
||||
if _, ok := meta.CatalogSinceAge(time.Now().UTC()); !ok {
|
||||
log.Printf("[WARN] [stacks] %s: unusable catalog_since %q in .felhom.yml (want YYYY-MM-DD, not in the future) — the update badge will show no age",
|
||||
dirName, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// Default healthcheck fields
|
||||
if meta.HealthCheck != nil {
|
||||
if meta.HealthCheck.Interval == "" {
|
||||
|
||||
Reference in New Issue
Block a user