diff --git a/cmd/fjordd/appjail_tag.go b/cmd/fjordd/appjail_tag.go index 2ea019e..946665c 100644 --- a/cmd/fjordd/appjail_tag.go +++ b/cmd/fjordd/appjail_tag.go @@ -1,9 +1,11 @@ package main import ( + "cmp" "fmt" "os" "path/filepath" + "slices" "sort" "strings" @@ -323,22 +325,149 @@ func cloneSeq(seq *yaml.Node) *yaml.Node { return out } -// isMakejailService says whether a director stack's service runs the image -// its Makejail builds from, the one the director's tag argument sets. -func isMakejailService(st *stack.Stack, service string) bool { - mj, err := os.ReadFile(filepath.Join(st.Dir, "Makejail")) +// setDirectorImage makes one director service run image. A service naming its +// own `from:` gets that; one built from the Makejail gets the tag argument its +// `OPTION from=:${tag}` reads, digest and all: a tag of +// 'latest@sha256:...' builds that digest, and director rebuilds only that +// service (netlab, 2026-10-15). An image of another repository than the +// Makejail's cannot be said there, so it is refused rather than half-written. +func setDirectorImage(directorYML, service, image, makejailRepo string) (string, error) { + var doc yaml.Node + if err := yaml.Unmarshal([]byte(directorYML), &doc); err != nil { + return "", fmt.Errorf("parse director: %w", err) + } + if len(doc.Content) == 0 || doc.Content[0].Kind != yaml.MappingNode { + return "", fmt.Errorf("director is not a YAML mapping") + } + svc := directorService(doc.Content[0], service) + if svc == nil { + return "", fmt.Errorf("the director has no service %q", service) + } + if serviceHasOwnImage(svc) { + forEachOption(svc, func(key string, val *yaml.Node) { + if key == "from" { + val.Value, val.Style, val.Tag = image, 0, "!!str" + } + }) + } else { + if makejailRepo == "" || imageRepo(image) != makejailRepo { + return "", fmt.Errorf("%s is built from %s; it cannot run %s", service, cmp.Or(makejailRepo, "its Makejail"), image) + } + setServiceArgument(svc, "tag", tagRef(image)) + } + var sb strings.Builder + enc := yaml.NewEncoder(&sb) + enc.SetIndent(2) + if err := enc.Encode(doc.Content[0]); err != nil { + return "", err + } + enc.Close() + return sb.String(), nil +} + +// tagRef is what follows an image's repository, as the Makejail's ${tag} +// takes it: "latest", "pkg@sha256:...". A digest with no tag gets latest in +// front, since the Makejail writes the colon. +func tagRef(image string) string { + ref := strings.TrimPrefix(strings.TrimPrefix(image, imageRepo(image)), ":") + if ref == "" || strings.HasPrefix(ref, "@") { + ref = "latest" + ref + } + return ref +} + +// directorFollowsCompose writes the compose's image for each named service +// (every one when none) into the director, so the version fjord shows and +// the one the jail runs are the same. A service the director does not have +// is left alone. +func directorFollowsCompose(st *stack.Stack, services []string) error { + if strings.TrimSpace(st.Director) == "" { + return nil + } + repo := makejailRepoOf(st) + var doc yaml.Node + if err := yaml.Unmarshal([]byte(st.Director), &doc); err != nil || len(doc.Content) == 0 { + return fmt.Errorf("parse director: %v", err) + } + have := map[string]bool{} + for _, n := range directorServiceNames(doc.Content[0]) { + have[n] = true + } + list, err := composepkg.ServiceImageList(st.Compose) if err != nil { - return false + return err + } + env := st.EnvMap() + sort.Slice(list, func(i, j int) bool { return list[i].Service < list[j].Service }) + for _, im := range list { + if !have[im.Service] || (len(services) > 0 && !slices.Contains(services, im.Service)) { + continue + } + if st.Director, err = setDirectorImage(st.Director, im.Service, composepkg.ExpandEnv(im.Image, env), repo); err != nil { + return err + } + } + return nil +} + +// makejailRepoOf is the repository a director stack's Makejail builds from. +func makejailRepoOf(st *stack.Stack) string { + mj := st.Makejail + if mj == "" { + b, _ := os.ReadFile(filepath.Join(st.Dir, "Makejail")) + mj = string(b) + } + return makejailRepo(mj) +} + +// directorImage is the image a director service runs: its own from:, or the +// Makejail's repository at its tag argument (the Makejail's ARG default when +// it has none). "" when the director has no such service. +func directorImage(st *stack.Stack, service string) string { + var doc yaml.Node + if err := yaml.Unmarshal([]byte(st.Director), &doc); err != nil || len(doc.Content) == 0 { + return "" } - repo := makejailRepo(string(mj)) - images, err := composepkg.ServiceImageList(st.Compose) - if err != nil || repo == "" { - return false + svc := directorService(doc.Content[0], service) + if svc == nil { + return "" } - for _, im := range images { - if im.Service == service { - return imageRepo(im.Image) == repo + from := "" + forEachOption(svc, func(key string, val *yaml.Node) { + if key == "from" { + from = val.Value } + }) + if from != "" { + return from } - return false + repo := makejailRepoOf(st) + if repo == "" { + return "" + } + tag := makejailTagDefault(st) + if args := mapKey(svc, "arguments"); args != nil && args.Kind == yaml.SequenceNode { + for _, item := range args.Content { + if item.Kind == yaml.MappingNode && len(item.Content) >= 2 && item.Content[0].Value == "tag" { + tag = item.Content[1].Value + } + } + } + return repo + ":" + tag +} + +// makejailTagDefault is the Makejail's `ARG tag=`, latest when it +// gives none. +func makejailTagDefault(st *stack.Stack) string { + mj := st.Makejail + if mj == "" { + b, _ := os.ReadFile(filepath.Join(st.Dir, "Makejail")) + mj = string(b) + } + for _, line := range strings.Split(mj, "\n") { + if def, ok := strings.CutPrefix(strings.TrimSpace(line), "ARG tag="); ok && strings.TrimSpace(def) != "" { + return strings.TrimSpace(def) + } + } + return "latest" } diff --git a/cmd/fjordd/appjail_tag_test.go b/cmd/fjordd/appjail_tag_test.go index 423d37d..9e94b2a 100644 --- a/cmd/fjordd/appjail_tag_test.go +++ b/cmd/fjordd/appjail_tag_test.go @@ -2,8 +2,6 @@ package main import ( "context" - "os" - "path/filepath" "strings" "testing" @@ -234,15 +232,125 @@ services: } } -// On AppJail only the Makejail's image changes version per service; the -// database a choice added keeps its own. -func TestIsMakejailService(t *testing.T) { - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "Makejail"), []byte("OPTION from=ghcr.io/daemonless/vikunja:${tag}\n"), 0o644); err != nil { +const pinDirector = `services: + vikunja: + name: vikunja_vikunja + options: + - container: 'args:--pull' + vikunja-mariadb: + name: vikunja_mariadb + options: + - from: ghcr.io/daemonless/mariadb:11.4 + - template: !ENV '${PWD}/template.conf' +` + +const pinDigest = "sha256:64a5270f0e2d8e0b1d3c4f8a7b6e5d4c3b2a1908f7e6d5c4b3a291807f6e5d4c" + +// A pin reaches the jail: the Makejail service through its tag argument, +// digest and all, a sidecar through its own from:. Nothing else moves. +func TestSetDirectorImage(t *testing.T) { + out, err := setDirectorImage(pinDirector, "vikunja", "ghcr.io/daemonless/vikunja:latest@"+pinDigest, "ghcr.io/daemonless/vikunja") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "- tag: 'latest@"+pinDigest+"'") { + t.Fatalf("tag argument not pinned:\n%s", out) + } + out, err = setDirectorImage(out, "vikunja-mariadb", "ghcr.io/daemonless/mariadb:11.4@"+pinDigest, "ghcr.io/daemonless/vikunja") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "from: ghcr.io/daemonless/mariadb:11.4@"+pinDigest) { + t.Fatalf("from: not pinned:\n%s", out) + } + for _, keep := range []string{"args:--pull", "!ENV '${PWD}/template.conf'", "vikunja_mariadb"} { + if !strings.Contains(out, keep) { + t.Errorf("lost %q:\n%s", keep, out) + } + } + // The Makejail builds one repository; another cannot be said there. + if _, err := setDirectorImage(pinDirector, "vikunja", "docker.io/other/thing:1", "ghcr.io/daemonless/vikunja"); err == nil { + t.Fatal("an image of another repository was written to the tag argument") + } + if _, err := setDirectorImage(pinDirector, "nope", "x:1", ""); err == nil { + t.Fatal("a service the director lacks was not refused") + } +} + +func TestTagRef(t *testing.T) { + for in, want := range map[string]string{ + "ghcr.io/daemonless/app:latest": "latest", + "ghcr.io/daemonless/app:pkg@" + pinDigest: "pkg@" + pinDigest, + "ghcr.io/daemonless/app@" + pinDigest: "latest@" + pinDigest, + "localhost:5000/app:2.1": "2.1", + } { + if got := tagRef(in); got != want { + t.Errorf("%s: got %q, want %q", in, got, want) + } + } +} + +// Rollback and Version... write the compose; the director follows for the +// services named, expanding the compose's variables, and leaves the rest. +func TestDirectorFollowsCompose(t *testing.T) { + st := &stack.Stack{ + Director: pinDirector, + Makejail: "OPTION from=ghcr.io/daemonless/vikunja:${tag}\n", + Compose: "services:\n vikunja:\n image: ghcr.io/daemonless/vikunja:${VIKUNJA_TAG:-latest}@" + pinDigest + + "\n vikunja-mariadb:\n image: ghcr.io/daemonless/mariadb:11.8\n", + Env: "VIKUNJA_TAG=pkg\n", + } + if err := directorFollowsCompose(st, []string{"vikunja"}); err != nil { + t.Fatal(err) + } + if !strings.Contains(st.Director, "- tag: 'pkg@"+pinDigest+"'") { + t.Fatalf("vikunja not pinned from the compose:\n%s", st.Director) + } + if !strings.Contains(st.Director, "from: ghcr.io/daemonless/mariadb:11.4") { + t.Fatalf("a service not named was changed:\n%s", st.Director) + } + if err := directorFollowsCompose(st, nil); err != nil { + t.Fatal(err) + } + if !strings.Contains(st.Director, "from: ghcr.io/daemonless/mariadb:11.8") { + t.Fatalf("every service follows when none is named:\n%s", st.Director) + } + // A podman stack has no director: nothing to do. + if err := directorFollowsCompose(&stack.Stack{Compose: st.Compose}, nil); err != nil { t.Fatal(err) } - st := &stack.Stack{Dir: dir, Compose: "services:\n vikunja:\n image: ghcr.io/daemonless/vikunja:latest\n mariadb:\n image: ghcr.io/daemonless/mariadb:11.4\n"} - if !isMakejailService(st, "vikunja") || isMakejailService(st, "mariadb") { - t.Fatal("vikunja is the Makejail's image, mariadb is not") +} + +// A director project with no compose (made from a director file): rollback +// and unpin write the director itself. +func TestSetServiceImageDirectorOnly(t *testing.T) { + st := &stack.Stack{ + Director: pinDirector, + Makejail: "ARG tag=2.0.5\nOPTION from=ghcr.io/daemonless/vikunja:${tag}\n", + } + if got := serviceImage(st, "vikunja"); got != "ghcr.io/daemonless/vikunja:2.0.5" { + t.Fatalf("vikunja runs %q, want the Makejail's default tag", got) + } + if err := setServiceImage(st, "vikunja", "ghcr.io/daemonless/vikunja:2.0.5@"+pinDigest); err != nil { + t.Fatal(err) + } + if err := setServiceImage(st, "vikunja-mariadb", "ghcr.io/daemonless/mariadb:11.4@"+pinDigest); err != nil { + t.Fatal(err) + } + if got := serviceImage(st, "vikunja"); got != "ghcr.io/daemonless/vikunja:2.0.5@"+pinDigest { + t.Fatalf("vikunja now %q", got) + } + if got := serviceImage(st, "vikunja-mariadb"); got != "ghcr.io/daemonless/mariadb:11.4@"+pinDigest { + t.Fatalf("mariadb now %q", got) + } + // Unpinning puts the tag back without the digest. + if err := setServiceImage(st, "vikunja", withoutDigest(serviceImage(st, "vikunja"))); err != nil { + t.Fatal(err) + } + if got := serviceImage(st, "vikunja"); got != "ghcr.io/daemonless/vikunja:2.0.5" { + t.Fatalf("after unpin vikunja is %q", got) + } + if st.Compose != "" { + t.Fatalf("a compose appeared: %q", st.Compose) } } diff --git a/cmd/fjordd/keep_address.go b/cmd/fjordd/keep_address.go index f7342fa..0e79a8d 100644 --- a/cmd/fjordd/keep_address.go +++ b/cmd/fjordd/keep_address.go @@ -35,8 +35,8 @@ import ( // by name). func (s *server) keepAddresses(ctx context.Context, st *stack.Stack) (kept, skipped []string) { be := s.backendFor(st) - if !be.Capabilities().PinServices || st.Compose == "" { - return nil, nil // appjail's director names no compose networks + if caps := be.Capabilities(); !caps.PinServices || caps.KeepsAddresses || st.Compose == "" { + return nil, nil } status, err := be.Status(ctx, st) if err != nil { diff --git a/cmd/fjordd/rollback.go b/cmd/fjordd/rollback.go index b2c0194..5212db6 100644 --- a/cmd/fjordd/rollback.go +++ b/cmd/fjordd/rollback.go @@ -24,10 +24,9 @@ type isRollbackKey struct{} // recordRollback saves, for each service about to be updated, the image its // container runs now. services empty = all of them. func (s *server) recordRollback(ctx context.Context, st *stack.Stack, services []string) { - // Rollback pins a service's image in the compose and updates just that - // service. An engine that does not run from the compose (appjail - // director, whose spec names makejails) cannot be pinned there, so - // nothing is recorded and no rollback is offered. + // Rollback pins a service's image in the compose (and, on AppJail, the + // director) and updates just that service. An engine that cannot take a + // pin records nothing and offers no rollback. if !s.backendFor(st).Capabilities().PinServices { return } @@ -92,19 +91,17 @@ func (s *server) stackRollback(w http.ResponseWriter, r *http.Request, name stri http.Error(w, "no state for "+name, 500) return } - compose := st.Compose for _, svc := range req.Services { rb, ok := state.Rollback[svc] if !ok { http.Error(w, fmt.Sprintf("%s has no earlier image recorded for %s", name, svc), 400) return } - if compose, err = composepkg.SetServiceImage(compose, svc, withoutDigest(rb.Ref)+"@"+rb.Digest); err != nil { + if err := setServiceImage(st, svc, withoutDigest(rb.Ref)+"@"+rb.Digest); err != nil { http.Error(w, err.Error(), 400) return } } - st.Compose = compose if err := s.manager.Save(st); err != nil { http.Error(w, err.Error(), 500) return @@ -135,13 +132,7 @@ func (s *server) stackUnpin(w http.ResponseWriter, r *http.Request, name string) http.Error(w, "Stack not found", 404) return } - list, _ := composepkg.ServiceImageList(st.Compose) - cur := "" - for _, si := range list { - if si.Service == req.Service { - cur = si.Image - } - } + cur := serviceImage(st, req.Service) if !strings.Contains(cur, "@") { http.Error(w, req.Service+" is not pinned", 400) return @@ -153,12 +144,10 @@ func (s *server) stackUnpin(w http.ResponseWriter, r *http.Request, name string) restore = rb.Compose } } - compose, err := composepkg.SetServiceImage(st.Compose, req.Service, restore) - if err != nil { + if err := setServiceImage(st, req.Service, restore); err != nil { http.Error(w, err.Error(), 400) return } - st.Compose = compose if err := s.manager.Save(st); err != nil { http.Error(w, err.Error(), 500) return @@ -174,3 +163,48 @@ func withoutDigest(ref string) string { } return ref } + +// setServiceImage makes a service run ref: written in the compose where the +// compose has the service, the director following on AppJail; in the +// director alone for a director project whose compose does not name it +// (an AppJail stack made from a director file, with no compose). +func setServiceImage(st *stack.Stack, service, ref string) error { + if composeImage(st.Compose, service) != "" { + compose, err := composepkg.SetServiceImage(st.Compose, service, ref) + if err != nil { + return err + } + st.Compose = compose + return directorFollowsCompose(st, []string{service}) + } + if strings.TrimSpace(st.Director) == "" { + return fmt.Errorf("no service %q", service) + } + director, err := setDirectorImage(st.Director, service, ref, makejailRepoOf(st)) + if err != nil { + return err + } + st.Director = director + return nil +} + +// serviceImage is the image a service is set to run: the compose's, or on a +// director project without one, the director's. +func serviceImage(st *stack.Stack, service string) string { + if img := composeImage(st.Compose, service); img != "" { + return img + } + return directorImage(st, service) +} + +// composeImage is a service's image as the compose writes it, "" when the +// compose has no such service. +func composeImage(compose, service string) string { + list, _ := composepkg.ServiceImageList(compose) + for _, si := range list { + if si.Service == service { + return si.Image + } + } + return "" +} diff --git a/cmd/fjordd/stack_handlers.go b/cmd/fjordd/stack_handlers.go index 50f2868..e630dda 100644 --- a/cmd/fjordd/stack_handlers.go +++ b/cmd/fjordd/stack_handlers.go @@ -863,11 +863,11 @@ func (s *server) stackSetTag(w http.ResponseWriter, r *http.Request, name string return } if body.Service != "" { - // A director stack's jails read the tag from the director, which has - // one: the Makejail image's. Other services (a database a choice - // added) name their own image there and keep it. - if st.Director != "" && !isMakejailService(st, body.Service) { - http.Error(w, body.Service+" runs its own image on AppJail; its version comes with the app", 400) + // A director service the compose does not name -- bundles call a + // sidecar authelia-redis where the compose says redis, and the page + // lists the director's names: its version goes to the director. + if st.Director != "" && composeImage(st.Compose, body.Service) == "" { + s.directorSetTag(r.Context(), w, st, body.Service, body.Tag, body.Pin) return } if why := s.noBuildForRetag(r.Context(), st.Compose, body.Service, body.Tag); why != "" { @@ -887,11 +887,11 @@ func (s *server) stackSetTag(w http.ResponseWriter, r *http.Request, name string } } st.Compose, st.Env = newCompose, newEnv - if st.Director != "" { - if st.Director, err = setDirectorTag(st.Director, body.Tag); err != nil { - http.Error(w, "director tag: "+err.Error(), 500) - return - } + // And where appjail reads it: the service's from:, or the Makejail's + // tag argument, pin included. + if err := directorFollowsCompose(st, []string{body.Service}); err != nil { + http.Error(w, "director: "+err.Error(), 400) + return } if err := s.manager.Save(st); err != nil { http.Error(w, err.Error(), 500) @@ -932,13 +932,11 @@ func (s *server) stackSetTag(w http.ResponseWriter, r *http.Request, name string st.Compose, st.Env = newCompose, newEnv // And where appjail will read it. Setting only the compose left the jail // on whatever ${tag} defaults to, so the version on screen and the version - // running were different numbers. - if st.Director != "" { - st.Director, err = setDirectorTag(st.Director, body.Tag) - if err != nil { - http.Error(w, "director tag: "+err.Error(), 500) - return - } + // running were different numbers. A pin goes along: the digest, not just + // the tag. + if err := directorFollowsCompose(st, nil); err != nil { + http.Error(w, "director: "+err.Error(), 400) + return } if err := s.manager.Save(st); err != nil { http.Error(w, err.Error(), 500) @@ -1417,6 +1415,41 @@ func (s *server) noBuildForRetag(ctx context.Context, compose, service, tag stri return "" } +// directorSetTag moves one director service to tag, pinned to the digest +// the tag resolves to now when asked, written into the director alone. +func (s *server) directorSetTag(ctx context.Context, w http.ResponseWriter, st *stack.Stack, service, tag string, pin bool) { + cur := directorImage(st, service) + if cur == "" { + http.Error(w, "no service "+service+" in the director", 400) + return + } + if why := s.noBuildHere(ctx, registry.Repo(cur), tag); why != "" { + http.Error(w, why, http.StatusConflict) + return + } + img := imageRepo(cur) + ":" + tag + if pin { + dctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + digest, err := registry.Digest(dctx, img) + if err != nil { + http.Error(w, err.Error(), 502) + return + } + img += "@" + digest + } + if err := setServiceImage(st, service, img); err != nil { + http.Error(w, err.Error(), 400) + return + } + if err := s.manager.Save(st); err != nil { + http.Error(w, err.Error(), 500) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"ok"}`)) +} + // stackDismissFailure forgets the stack's last failure. Without it Dismiss // lasted only as long as the page: a failure no later action clears (a slow // inspect read as "gone") came back on every reload. diff --git a/cmd/fjordd/updates_handler.go b/cmd/fjordd/updates_handler.go index ef0cfa7..26e79c4 100644 --- a/cmd/fjordd/updates_handler.go +++ b/cmd/fjordd/updates_handler.go @@ -44,9 +44,15 @@ func (s *server) updateServices(ctx context.Context, st *stack.Stack) []updates. // A service the compose names no image for, but whose engine knows what // it runs: an appjail director stack names makejails, not images, so the // ref the jail was built from is the only image there is to check. + // What the director says it runs wins over what the jail was built from: + // a rollback pins the director to a digest, and buildah reports the tag. for _, ri := range running { if !seen[ri.Service] && ri.Ref != "" { - out = append(out, updates.Service{Name: ri.Service, Image: ri.Ref, Running: ri.Digest, Known: ri.Digests}) + img := ri.Ref + if d := directorImage(st, ri.Service); d != "" { + img = composepkg.ExpandEnv(d, env) + } + out = append(out, updates.Service{Name: ri.Service, Image: img, Running: ri.Digest, Known: ri.Digests}) } } extra := out[len(list):] // map order is random; keep the panel's rows stable diff --git a/pkg/engine/appjail/prune.go b/pkg/engine/appjail/prune.go index e8f8f5b..97a4fc5 100644 --- a/pkg/engine/appjail/prune.go +++ b/pkg/engine/appjail/prune.go @@ -29,6 +29,11 @@ func (b *Backend) Capabilities() engine.Capabilities { // A subset, yes; pins and rollback, not yet: director never reads // the compose they are written in. UpdateServices: true, + // Pins reach the jails through the director (fjord writes the image + // into from: or the tag argument), and a rebuilt jail keeps its + // address on its own (netlab 2026-10-17: DHCP and virtualnet). + PinServices: true, + KeepsAddresses: true, // Boot only: a jail stays up after its app exits (persist, which its // start needs), so nothing can tell it to come back. fjord's start on // boot follows these. diff --git a/pkg/engine/engine.go b/pkg/engine/engine.go index 881d1f8..a792db5 100644 --- a/pkg/engine/engine.go +++ b/pkg/engine/engine.go @@ -474,6 +474,11 @@ type Capabilities struct { // pin written there takes effect -- an image digest (rollback, a // service's own version) or an address. PinServices bool `json:"pinServices"` + // KeepsAddresses: a rebuilt service gets the address it had without fjord + // pinning it. AppJail's epairs keep their names, so their generated MACs + // and DHCP leases, and its virtualnet keeps a jail's reservation; podman's + // host-local hands out the next address and its epairs are numbered. + KeepsAddresses bool `json:"keepsAddresses"` // RestartPolicies are the restart policies a stack on this engine can // take, as compose spells them (unless-stopped, always, on-failure, no); // none means fjord sets none. RestartOnExit: the engine itself restarts a diff --git a/pkg/stack/stack.go b/pkg/stack/stack.go index 5d1a1a1..c52bc45 100644 --- a/pkg/stack/stack.go +++ b/pkg/stack/stack.go @@ -310,8 +310,14 @@ func (m *Manager) Save(stack *Stack) error { // unterminated final line (an `OPTION from=` lost that way builds an // empty jail instead of the OCI container). body := strings.TrimRight(stack.Makejail, "\n") + "\n" - if err := writeFileAtomic(filepath.Join(dir, "Makejail"), []byte(body), 0644); err != nil { - return fmt.Errorf("failed to write Makejail: %w", err) + // Only when it changed: director rebuilds every service built from + // the Makejail when the file is newer than its last run, so a save of + // anything else (a rollback pinning one sidecar) rebuilt them all. + path := filepath.Join(dir, "Makejail") + if old, err := os.ReadFile(path); err != nil || string(old) != body { + if err := writeFileAtomic(path, []byte(body), 0644); err != nil { + return fmt.Errorf("failed to write Makejail: %w", err) + } } } diff --git a/pkg/stack/stack_test.go b/pkg/stack/stack_test.go index a8834e2..65e2df7 100644 --- a/pkg/stack/stack_test.go +++ b/pkg/stack/stack_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "testing" + "time" ) func TestMigrateEngineRecordsOnlyMissing(t *testing.T) { @@ -47,3 +48,33 @@ func TestAllocateNameSlugsAndSuffixes(t *testing.T) { t.Fatalf("after delete = %q, want radarr (name is free again)", got) } } + +// Saving a director stack again leaves an unchanged Makejail alone: director +// rebuilds every service built from it when the file is newer than its last +// run (a rollback of one sidecar restarted the app, netlab 2026-10-18). +func TestSaveKeepsUnchangedMakejail(t *testing.T) { + m := NewManager(t.TempDir()) + st := &Stack{Name: "aj", Director: "services:\n web:\n name: aj_web\n", Makejail: "OPTION from=ghcr.io/x/web:${tag}\n"} + if err := m.Save(st); err != nil { + t.Fatal(err) + } + path := filepath.Join(st.Dir, "Makejail") + old := time.Now().Add(-time.Hour) + if err := os.Chtimes(path, old, old); err != nil { + t.Fatal(err) + } + st.Director = "services:\n web:\n name: aj_web\n arguments:\n - tag: 'pkg'\n" + if err := m.Save(st); err != nil { + t.Fatal(err) + } + if fi, err := os.Stat(path); err != nil || !fi.ModTime().Equal(old) { + t.Fatalf("the Makejail was rewritten: %v %v", fi.ModTime(), err) + } + st.Makejail = "OPTION from=ghcr.io/x/web2:${tag}\n" + if err := m.Save(st); err != nil { + t.Fatal(err) + } + if b, _ := os.ReadFile(path); string(b) != st.Makejail { + t.Fatalf("a changed Makejail was not written: %q", b) + } +} diff --git a/test/ui/README.md b/test/ui/README.md index 9eb9fa3..4407da4 100644 --- a/test/ui/README.md +++ b/test/ui/README.md @@ -60,6 +60,7 @@ wire. | retag-var | Change Version on an image whose tag is a variable sets .env and leaves the compose as written | t-var | | leftover | a private network whose stack is gone reads as left over and can be deleted | | | appjail-dns | the Setup check for jail name resolution tells the truth: with dnsmasq stopped it is a terminal job with the commands on screen, and gone once they are run (leaves dnsmasq configured for appjail) | | +| appjail-update | one service of an AppJail stack updated from the panel, only its jail rebuilt; then rolled back from its row: the jail runs the image it had, pinned, and the other jail never restarts | t-aju | | privdel | deleting a stack removes its private network | t-pd | | apply | Apply recreates what a Save changed, even with an exec session holding the old container | t-apply | | wire | New Network asks the wire; networks that do not fit it are flagged | | diff --git a/test/ui/appjail-update.js b/test/ui/appjail-update.js index 7913778..5ca53ff 100644 --- a/test/ui/appjail-update.js +++ b/test/ui/appjail-update.js @@ -1,6 +1,7 @@ -// One service of an AppJail stack, updated from the panel. The stack (t-aju: -// web and sync, sync built from a stale image) is made by appjail-update.sh, -// which also checks the jails afterwards; this is the page's half. +// One service of an AppJail stack, updated from the panel, then rolled back. +// The stack (t-aju: web and sync, sync built from a stale image) is made by +// appjail-update.sh, which also checks the jails after each step; this is +// the page's half. STEP=rollback: Roll back sync from its row. const { chromium } = require('playwright-core'); const BASE = 'http://127.0.0.1:3567'; const NAME = 't-aju'; @@ -12,6 +13,29 @@ const detail = async (page) => (await page.request.get(BASE + '/api/stacks/' + N const page = await (await b.newContext({ viewport: { width: 1400, height: 1100 } })).newPage(); page.setDefaultTimeout(30000); try { + if (process.env.STEP === 'rollback') { + await page.goto(BASE + '/?t=' + Date.now() + '#/stacks/' + NAME, { waitUntil: 'networkidle' }); + await page.getByRole('button', { name: '↶ roll back' }).first().waitFor({ timeout: 90000 }); + await page.getByRole('button', { name: '↶ roll back' }).first().click(); + const sure = page.getByRole('button', { name: 'Roll back', exact: true }); + const asked = (await page.locator('body').innerText()).match(/Roll sync back to [^\n?]+\?/); + note('Roll back says where to, and asks first', !!asked, asked ? asked[0] : 'no confirmation shown'); + await sure.click(); + let d, seen = false; + for (let i = 0; i < 100 && !seen; i++) { d = await detail(page); seen = !!d.busy; if (!seen) await sleep(300); } + for (let i = 0; i < 120; i++) { d = await detail(page); if (!d.busy) break; await sleep(3000); } + await sleep(3000); + d = await detail(page); + note('rolled back, running, no failure recorded', d.status?.state === 'running' && !d.state?.last_failure, `${d.status?.state}, ${JSON.stringify(d.state?.last_failure || null)}`); + note('the director pins sync to a digest', /from: ghcr\.io\/daemonless\/syncthing:latest@sha256:[0-9a-f]{64}/.test(d.director || ''), (d.director || '').split('\n').find((l) => /syncthing/.test(l))?.trim() || 'no syncthing line'); + await page.goto(BASE + '/?t=' + Date.now() + '#/stacks/' + NAME, { waitUntil: 'networkidle' }); + await sleep(3000); + await page.screenshot({ path: '/out/appjail-rollback-done.png', fullPage: true }); + const body = await page.locator('body').innerText(); + note('the row says pinned, with unpin', /pinned · unpin/.test(body), /pinned/.test(body) ? 'pinned shown' : 'no pinned badge'); + await b.close(); + return; + } await page.goto(BASE + '/?t=' + Date.now() + '#/stacks/' + NAME, { waitUntil: 'networkidle' }); await page.getByText(/available/).first().waitFor({ timeout: 90000 }).catch(() => {}); await sleep(500); @@ -40,8 +64,9 @@ const detail = async (page) => (await page.request.get(BASE + '/api/stacks/' + N await page.getByText(/Up to date/).first().waitFor({ timeout: 90000 }).catch(() => {}); const body = await page.locator('body').innerText(); await page.screenshot({ path: '/out/appjail-update-done.png', fullPage: true }); - // No Roll back: nothing can pin an AppJail service to an image yet. - note('the page says Up to date, and offers no Roll back it cannot do', /Up to date/.test(body) && !/roll back/i.test(body), `${/Up to date/.test(body) ? 'Up to date' : 'no Up to date'}, ${/roll back/i.test(body) ? 'roll back offered' : 'no roll back'}`); + // The image it ran before is recorded, and the director can be pinned + // to it: Roll back is offered. + note('the page says Up to date and offers Roll back', /Up to date/.test(body) && /roll back/i.test(body), `${/Up to date/.test(body) ? 'Up to date' : 'no Up to date'}, ${/roll back/i.test(body) ? 'roll back offered' : 'no roll back'}`); } catch (e) { note('run', false, e.message.split('\n')[0]); await page.screenshot({ path: '/out/appjail-update-error.png', fullPage: true }).catch(() => {}); diff --git a/test/ui/appjail-update.sh b/test/ui/appjail-update.sh index 9777e38..64af28e 100644 --- a/test/ui/appjail-update.sh +++ b/test/ui/appjail-update.sh @@ -1,6 +1,7 @@ -# One service of an AppJail stack updated from the panel: the jail of the -# service that was behind is replaced and runs the new image, the other -# jail is never stopped (same jid). +# One service of an AppJail stack updated from the panel, then rolled back: +# the jail of the service that was behind is replaced and runs the new image, +# then is rebuilt from the image it had; the other jail is never stopped +# (same jid throughout). # # The stack is a director project written here, not a catalog app: `sync` # names its image (`from:`), as every sidecar of a catalog bundle does, and @@ -43,16 +44,20 @@ print(json.dumps({"engine": "appjail", "director": director, "makejail": makejai if [ -n "$AJU_WEB" ] && [ -n "$AJU_SYNC" ]; then say "PASS t-aju: both jails up (web jid $AJU_WEB, sync jid $AJU_SYNC)" else say "FAIL t-aju: jails not up after Start (web '$AJU_WEB', sync '$AJU_SYNC')"; return; fi run_js appjail-update + sync=$(aju_jid t_aju_sync); AJU_NEW=$(aju_image t_aju_sync) + if [ -n "$sync" ] && [ "$sync" != "$AJU_SYNC" ]; then say "PASS t-aju: sync's jail was replaced (jid $AJU_SYNC -> $sync)" + else say "FAIL t-aju: sync jid $AJU_SYNC -> ${sync:-}, want a new jail"; fi + if [ -n "$AJU_NEW" ] && [ "$AJU_NEW" != "$AJU_OLD" ]; then say "PASS t-aju: sync runs a newer image than it was built from" + else say "FAIL t-aju: sync still runs image ${AJU_NEW:-}, the one it had"; fi + run_js appjail-update -e STEP=rollback } after() { for _ in $(seq 1 30); do curl -s --max-time 10 "$FJORD/api/stacks/t-aju" | grep -q '"busy"' || break; sleep 5; done - web=$(aju_jid t_aju_web); sync=$(aju_jid t_aju_sync); new=$(aju_image t_aju_sync) - if [ -n "$web" ] && [ "$web" = "${AJU_WEB:-}" ]; then echo "PASS t-aju: web was left running (jid $web before and after)" + web=$(aju_jid t_aju_web); now=$(aju_image t_aju_sync) + if [ -n "$web" ] && [ "$web" = "${AJU_WEB:-}" ]; then echo "PASS t-aju: web was left running through the update and the rollback (jid $web)" else echo "FAIL t-aju: web jid ${AJU_WEB:-?} -> ${web:-}, it was not updated and must not restart"; fi - if [ -n "$sync" ] && [ "$sync" != "${AJU_SYNC:-}" ]; then echo "PASS t-aju: sync's jail was replaced (jid ${AJU_SYNC:-?} -> $sync)" - else echo "FAIL t-aju: sync jid ${AJU_SYNC:-?} -> ${sync:-}, want a new jail"; fi - if [ -n "$new" ] && [ "$new" != "${AJU_OLD:-}" ]; then echo "PASS t-aju: sync runs a newer image than it was built from" - else echo "FAIL t-aju: sync still runs image ${new:-}, the one it had"; fi + if [ -n "$now" ] && [ "$now" = "${AJU_OLD:-}" ]; then echo "PASS t-aju: after Roll back, sync runs the image it had before the update" + else echo "FAIL t-aju: after Roll back sync runs ${now:-}, want ${AJU_OLD:-?} (before the update)"; fi del_stack t-aju left=$(on 'jls name | grep "^t_aju_"' | tr '\n' ' ') [ -z "$left" ] || echo "FAIL t-aju: jails left behind: $left" diff --git a/ui/src/App.svelte b/ui/src/App.svelte index ebade80..7d31b8e 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -2953,7 +2953,7 @@ on:openUpdate={() => openUpdatePanel(selectedStack!.name)} on:rollback={(e) => rollback(selectedStack!.name, e.detail)} on:unpin={(e) => unpin(selectedStack!.name, e.detail)} - versionable={!selectedStack.director} + versionable={!selectedStack.director || (!!updateInfo?.pinServices && !!selectedStack.compose?.trim())} on:version={(e) => (changeVersion = { name: selectedStack!.name,