3c6b49b31c
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
213 lines
9.3 KiB
Go
213 lines
9.3 KiB
Go
package stacks
|
|
|
|
import (
|
|
"strings"
|
|
"time"
|
|
|
|
"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 {
|
|
// v0.269.0 (`09` §6.4 part 6): the same TAG can be a different image. Behind only when the catalog
|
|
// holds a TESTED digest for the service, the installed digest is known and differs, and the test
|
|
// is NEWER than this install — never from a registry query, never on an unknown.
|
|
if digestBehind(s) {
|
|
return UpdateOrderBehind
|
|
}
|
|
return UpdateOrderCurrent
|
|
}
|
|
return UpdateOrderAhead
|
|
}
|
|
|
|
// digestBehind: see CatalogOrder. Pinned by TestDigest_FloatingTagBehindOnlyForANewerTestedDigest.
|
|
func digestBehind(s Stack) bool {
|
|
if len(s.CatalogDigests) == 0 || s.CatalogTestedAt.IsZero() {
|
|
return false
|
|
}
|
|
for svc, want := range s.CatalogDigests {
|
|
got, ok := s.AppConfig.InstalledImages[svc]
|
|
if !ok || got.Digest == "" || !digestRe.MatchString(want) || got.Digest == want {
|
|
continue
|
|
}
|
|
at, err := time.Parse(time.RFC3339, got.At)
|
|
if err != nil {
|
|
continue // when the install happened is unknown → never "behind" on it
|
|
}
|
|
if s.CatalogTestedAt.After(at) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// 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. **10 of the catalog's 66 pins float like this**, RECOUNTED
|
|
// 2026-09-21 — 6 two-part lines (mariadb:11.4/11.6/12.3, claper:2.5, opengist:1.13,
|
|
// wger/server:2.6) and 4 major lines (postgres:15/16-alpine, redis:7-alpine,
|
|
// postgis:16-3.5-alpine). The "23" this comment used to carry matched no definition the
|
|
// catalog supports today; see 09 §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
|
|
}
|