Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 142 additions & 13 deletions cmd/fjordd/appjail_tag.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package main

import (
"cmp"
"fmt"
"os"
"path/filepath"
"slices"
"sort"
"strings"

Expand Down Expand Up @@ -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=<repo>:${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=<default>`, 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"
}
128 changes: 118 additions & 10 deletions cmd/fjordd/appjail_tag_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ package main

import (
"context"
"os"
"path/filepath"
"strings"
"testing"

Expand Down Expand Up @@ -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)
}
}
4 changes: 2 additions & 2 deletions cmd/fjordd/keep_address.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading