From f5153a490c43d8f98287ec665010162ff7437498 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 22 Oct 2026 10:27:01 +0200 Subject: [PATCH] Check that every image builds from a Dockerfile in its own module's directory (issue 1707) --- checks/imageplace/go.mod | 3 ++ checks/imageplace/main.go | 91 ++++++++++++++++++++++++++++++++++ checks/imageplace/main_test.go | 84 +++++++++++++++++++++++++++++++ merge-check.sh | 6 +++ 4 files changed, 184 insertions(+) create mode 100644 checks/imageplace/go.mod create mode 100644 checks/imageplace/main.go create mode 100644 checks/imageplace/main_test.go diff --git a/checks/imageplace/go.mod b/checks/imageplace/go.mod new file mode 100644 index 00000000..d076a4eb --- /dev/null +++ b/checks/imageplace/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-catalog/checks/imageplace + +go 1.22 diff --git a/checks/imageplace/main.go b/checks/imageplace/main.go new file mode 100644 index 00000000..6dbc4f53 --- /dev/null +++ b/checks/imageplace/main.go @@ -0,0 +1,91 @@ +// Command imageplace refuses an image artifact whose Dockerfile is not a file inside its own module's directory +// (novox/hq ADR 0487 §1, issue 1707): the image builder builds a module from that module's directory alone, so +// a `from` that leaves it (../other/Dockerfile) or names a file that is not there could never build. +// +// go run . +// +// Exits 1 on a finding. +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// Finding is one image refused: Module's artifact Artifact names From, and Why says what is wrong with it. +type Finding struct { + Module, Artifact, From, Why string +} + +// Check reads every manifest under root/modules and answers which images it refuses. +func Check(root string) ([]Finding, error) { + manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) + if err != nil { + return nil, err + } + sort.Strings(manifests) + var out []Finding + for _, path := range manifests { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var m struct { + Module string `json:"module"` + Build struct { + Artifacts []struct { + Name string `json:"name"` + Kind string `json:"kind"` + From string `json:"from"` + } `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + dir := filepath.Dir(path) + for _, a := range m.Build.Artifacts { + if a.Kind != "image" { + continue + } + f := Finding{Module: m.Module, Artifact: a.Name, From: a.From} + clean := filepath.Clean(a.From) + switch { + case a.From == "" || filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)): + f.Why = "is not a path inside the module's own directory" + default: + info, err := os.Stat(filepath.Join(dir, clean)) + if err != nil || info.IsDir() { + f.Why = "is not a file in the module's directory" + } + } + if f.Why != "" { + out = append(out, f) + } + } + } + return out, nil +} + +func main() { + root := "." + if len(os.Args) > 1 { + root = os.Args[1] + } + found, err := Check(root) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + for _, f := range found { + fmt.Printf("%s: image %s builds from %q, which %s (novox/hq ADR 0487 §1)\n", f.Module, f.Artifact, f.From, f.Why) + } + if len(found) > 0 { + os.Exit(1) + } + fmt.Println("imageplace: every image builds from a Dockerfile in its own module's directory") +} diff --git a/checks/imageplace/main_test.go b/checks/imageplace/main_test.go new file mode 100644 index 00000000..f8282b12 --- /dev/null +++ b/checks/imageplace/main_test.go @@ -0,0 +1,84 @@ +package main + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +// module writes modules//module.json with one image artifact from `from`, and the files named. +func module(t *testing.T, root, name, from string, files ...string) { + t.Helper() + dir := filepath.Join(root, "modules", name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + body := `{"module":"` + name + `","build":{"artifacts":[{"name":"server","kind":"image","from":"` + from + `"},{"name":"files","kind":"archive","from":"../elsewhere"}]}}` + if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + for _, f := range files { + p := filepath.Join(dir, f) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte("FROM scratch\n"), 0o644); err != nil { + t.Fatal(err) + } + } +} + +func TestADockerfileInTheModulesOwnDirectoryPasses(t *testing.T) { + root := t.TempDir() + module(t, root, "own", "Dockerfile", "Dockerfile") + module(t, root, "nested", "image/Dockerfile", "image/Dockerfile") + got, err := Check(root) + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Errorf("found %v, want none", got) + } +} + +func TestADockerfileOutsideTheModulesDirectoryFails(t *testing.T) { + root := t.TempDir() + module(t, root, "other", "Dockerfile", "Dockerfile") + module(t, root, "leaves", "../other/Dockerfile") + module(t, root, "absolute", "/etc/Dockerfile") + got, err := Check(root) + if err != nil { + t.Fatal(err) + } + var mods []string + for _, f := range got { + mods = append(mods, f.Module) + } + if want := []string{"absolute", "leaves"}; !reflect.DeepEqual(mods, want) { + t.Errorf("refused %v (%v), want %v", mods, got, want) + } +} + +func TestAMissingDockerfileFails(t *testing.T) { + root := t.TempDir() + module(t, root, "gone", "Dockerfile") + got, err := Check(root) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Module != "gone" { + t.Errorf("found %v, want gone refused", got) + } +} + +// The catalogue as it stands: this is the check itself, run here too. +func TestTheCataloguesImagesBuildFromTheirOwnDirectories(t *testing.T) { + got, err := Check("../..") + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Errorf("an image builds from outside its module: %v", got) + } +} diff --git a/merge-check.sh b/merge-check.sh index d3aacbf6..62431859 100755 --- a/merge-check.sh +++ b/merge-check.sh @@ -50,11 +50,17 @@ # every directory and archive says `unknown` (move-out, module or program) and a valid `knows`; `move-out` # only under a home; and no module declares a path inside a directory of another module. # checks/sshdir (ADR 0415 §6) stays while main runs it; it goes under issue 1035 once main runs checks/owned. +# 12. the image builder (novox/hq ADR 0487 §3, issue 1704): only modules/buildah provides rootless-image-build. +# 13. the image's place (novox/hq ADR 0487 §1, issue 1707): every image builds from a Dockerfile in its own module's directory. set -eu (cd checks/words && go test -count=1 . && go run . ../..) # The toolchains (novox/hq ADR 0477 clause 4): the package go only in modules/go, nodejs and npm only in modules/nodejs. (cd checks/toolchains && go test -count=1 . && go run . ../..) +# The image builder (novox/hq ADR 0487 §3): only modules/buildah provides rootless-image-build, docker never. +(cd checks/imagebuild && go test -count=1 . && go run . ../..) +# The image's place (novox/hq ADR 0487 §1): every image builds from a Dockerfile in its own module's directory. +(cd checks/imageplace && go test -count=1 . && go run . ../..) # The agent's home (novox/hq ADR 0266): no archive, and no file written into, under it. (cd checks/agenthome && go test -count=1 . && go run . ../..) # The mail-store seat (novox/hq ADR 0378 §3): only mesh-mail may claim it.