package stacks import ( "strings" "gitea.dooplex.hu/admin/felhom-controller/internal/util" ) // ── Is this app behind the catalog, level with it, or AHEAD of it? (R-524, v0.260.0) ───────────── // // Slice 2 (v0.233.0) asked only "does the installed reference DIFFER from the catalog's?" and called // every difference „Frissítés elérhető". MEASURED 2026-09-15 (BIGNIGHT Phase 6): privatebin was // updated 2.0.5 → 2.0.6 and the catalog was then reverted to 2.0.5. The box read // „Frissítés elérhető — ma", and the guarded Update behind that badge would have advanced the pin // 2.0.6 → 2.0.5 — a DOWNGRADE offered to a household as an update, with a migrated datadir behind it // and §4's ruling saying it cannot be undone. // // So the comparison gains a fourth answer. It lives HERE, in stacks, and not in web, because two // callers must reach the SAME verdict: the badge (web.compareInstalledToTemplate) and the guarded // update's refusal (Manager.UpdatePreflight). A comparison implemented twice is a comparison that // drifts — the same lesson localisation learned when the badge's two language paths were written // apart (R-589). // // IT QUERIES NO REGISTRY, exactly as before (09-update-architecture.md §8.1). Ordering is decided // from the TAG TEXT alone, and only when the tag text can carry an order at all. // UpdateOrder is the four-way answer to "how does what this app RUNS stand against what the catalog // OFFERS?". // // ABSENT STILL MEANS UNKNOWN, AND NEVER „NAPRAKÉSZ" — the R-166 property slice 2 was built around, // carried over verbatim. The new value is Ahead, and it is deliberately NOT folded into Current: // the badge shows the same word for both, but the UPDATE must refuse only one of them, and a caller // that cannot tell them apart cannot refuse correctly. type UpdateOrder int const ( UpdateOrderUnknown UpdateOrder = iota // nothing recorded, or nothing to compare against UpdateOrderCurrent // every service runs exactly what the catalog pins UpdateOrderBehind // at least one service differs and is not provably newer UpdateOrderAhead // every differing service is provably NEWER than the catalog ) // CatalogOrder compares an app's recorded installed images against what the catalog offers. // // The Ahead arm is deliberately the narrow one: EVERY differing service must be orderable and newer. // One service that is older, or one tag that cannot carry an order, and the answer falls back to // Behind — i.e. to exactly the behaviour of v0.233.0..v0.259.0. A half-ahead app is not a downgrade // the box may refuse on its own; it is a mixed state a human should look at, and „Frissítés elérhető" // is the honest label for it. func CatalogOrder(s Stack) UpdateOrder { if !s.Deployed || s.Protected || s.Orphaned { // Not deployed: nothing is running. Protected: infra is ours, not the customer's to update. // Orphaned: the template is gone from the catalog, so there is nothing to be current WITH. return UpdateOrderUnknown } if s.AppConfig == nil || len(s.AppConfig.InstalledImages) == 0 { return UpdateOrderUnknown // legacy app.yaml — no record was ever written } if len(s.CatalogImages) == 0 { return UpdateOrderUnknown // no readable catalog template — cannot tell, so say nothing } if len(s.AppConfig.InstalledImages) != len(s.CatalogImages) { // A service was added or removed by the template. That IS a change the customer's running // stack has not taken up, and it is not a version order. return UpdateOrderBehind } differing := 0 for svc, want := range s.CatalogImages { got, ok := s.AppConfig.InstalledImages[svc] if !ok { return UpdateOrderBehind // the catalog names a service the record does not cover } if got.Ref == want { continue } differing++ if cmp, ok := CompareImageRefs(got.Ref, want); !ok || cmp <= 0 { return UpdateOrderBehind } } if differing == 0 { return UpdateOrderCurrent } return UpdateOrderAhead } // CompareImageRefs orders two image references the way a human reads them: -1 when a is older than // b, 0 when they are the same version, 1 when a is newer. The second return is the whole point — // FALSE means "these two cannot be ordered", and every caller must treat that as "do not know" // rather than as "equal". // // It answers false, on purpose, for far more than it answers true: // - a digest-pinned reference (`…@sha256:…`) — the digest carries no order; // - a reference with no tag — the implicit `latest` is a moving target, not a position; // - two references to DIFFERENT images (`alpine:3.20` against `…/bookstack:26.05.2`) — the numbers // are comparable and the comparison is meaningless, which is the worst kind of false positive; // - any tag that is not plain digits and dots: `16-alpine`, `latest`, `26.05.2-ls310`, `stable`, // a date stamp, a git sha. 23 of the catalog's 66 pins float exactly like this (§8.1). // // THE ORDER ITSELF IS util.Version.Compare AND NOTHING ELSE. The house rule is one comparator in // this repo; this function is a tag NORMALISER in front of it, never a second implementation. func CompareImageRefs(a, b string) (int, bool) { repoA, tagA := splitImageRef(a) repoB, tagB := splitImageRef(b) if repoA == "" || repoA != repoB { return 0, false } va, sufA, okA := parseImageTag(tagA) vb, sufB, okB := parseImageTag(tagB) if !okA || !okB { return 0, false } // THE SUFFIXES MUST BE IDENTICAL, and this is not pedantry. `nextcloud:31.0.14-apache` and // `nextcloud:31.0.15-apache` are the same flavour of the same image and order cleanly; but // `26.05.2-ls310` against `26.05.2-ls311` differs only in a build number this function has no // rule for, and `…:2.4.0-alpine` against `…:2.4.0` is a different image content under one repo // name. Equal-or-nothing keeps every one of those out of the Ahead arm. if sufA != sufB { return 0, false } return va.Compare(vb), true } // splitImageRef separates `repo` from `tag`, and returns two empty strings whenever the reference // carries no plain tag to compare. // // The `/` test after the last colon is what keeps a registry PORT from being read as a tag: // `gitea.dooplex.hu:3000/admin/app` has a colon in it and no tag at all. func splitImageRef(ref string) (repo, tag string) { if strings.Contains(ref, "@") { return "", "" // digest-pinned } i := strings.LastIndex(ref, ":") if i < 0 { return "", "" // no tag — the implicit `latest` } if strings.Contains(ref[i+1:], "/") { return "", "" // that colon was a registry port } return ref[:i], ref[i+1:] } // parseImageTag splits a tag into the version at its FRONT and whatever follows, and refuses // anything whose front is not `X.Y` or `X.Y.Z` (an optional leading `v` is allowed). // // "2.0.6" → 2.0.6, "" // "31.0.14-apache" → 31.0.14, "-apache" ← real: the catalog's nextcloud pin // "11.6" → 11.6.0, "" ← real: the catalog's mariadb pins // "16-alpine" → refused: one component is not a version, it is a major line // "apache-2.57.0" → refused: the version is not at the front (real: the catalog's kimai pin) // "20260915" → refused: a date stamp is one component // // A two-part tag is padded with `.0` before it reaches the comparator. The padding is safe in the // one direction that matters: it only ever adds the smallest possible patch number, and it is // applied to whichever side is short, so it can never make an older tag look newer. func parseImageTag(tag string) (util.Version, string, bool) { t := strings.TrimPrefix(tag, "v") end := 0 for end < len(t) && ((t[end] >= '0' && t[end] <= '9') || t[end] == '.') { end++ } head, suffix := t[:end], t[end:] parts := strings.Split(head, ".") if len(parts) < 2 || len(parts) > 3 { return util.Version{}, "", false } for _, p := range parts { if p == "" { return util.Version{}, "", false } } for len(parts) < 3 { parts = append(parts, "0") } v, err := util.ParseVersion(strings.Join(parts, ".")) if err != nil { return util.Version{}, "", false } return v, suffix, true }