Add mesh-build-image: rootless image builds with buildah, pushed by tag from trunk only (issue 1703)

This commit is contained in:
2026-10-22 10:27:01 +02:00
parent ce8d52ed0b
commit 692fc3e1ab
31 changed files with 2800 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
# mesh-build-image — the image build module
Design 47, release R4c-9 (novox/hq ADR 0487, ADR 0466 §1, ADR 0469; issue 1703). The module that builds a module's
`image` artifact from the Dockerfile in the module's own directory, on a build machine, as an account of its own
(`runs-as`, no login shell, home at mode 0700). It serves every module's image, whatever the base, so it is not a
build module for one module. It claims no seat: it is assigned to the build machines and runs dark until the builder
uses it.
## Rootless
The tool is `buildah`, which the `buildah` module installs and provides as `rootless-image-build` with the machine's
reach: a machine without it refuses this module at assignment, naming `buildah`. The build runs as the module's own
account in a user namespace (`--isolation oci`), with buildah's image store and runtime state in
`/var/lib/mesh-build-image-store` (outside the home), no mount of the host, no container runtime socket, no registry
credential and an environment built from scratch.
The account's subordinate user and group ranges are not declared anywhere: the node-engine makes every `user` account
with `useradd`, which allocates them. The module's health reads `/etc/subuid` and `/etc/subgid` and is unhealthy
without a range for the account, without buildah, or when a user namespace cannot be made.
The Dockerfile's steps may use the network (ADR 0487 §6); nothing a step can read holds a key.
## Steps
- `build_image_fetch` fetches a commit over SSH into a workspace (a base commit makes the merge preview, as the other
build modules' fetch). Long: answers a step id and ends on its key in `steps`.
- `build_image_build` builds one image artifact: `buildah bud` with the Dockerfile of the module's directory as file
and context, `--target`, and the images `build.on` pins as `--build-arg`. Answers the image's digest. With
`publish: true` (a merged trunk commit only) the module's own process then pushes it to the registry under
`<module>/<artifact>:<tag of its own>` (the first 12 hex digits of the digest unless the call gives one), waiting out
a registry held still, and the key carries the digest the registry serves and the reference pinned by it. Without
`publish` (a pull request) nothing is pushed.
- It refuses, naming why, an image whose build context is a whole repository (`context`) or whose `build.on` names
another module's artifact (`unsupportedImage` in `build.go`): those forms belong to the images build-agent builds
until it is removed at R4g.
- `build_image_step`, `build_image_steps`, `build_image_status` and `build_image_health` read.
The registry's address is bound from the `artifact-store` provision into `<home>/registry` (`files/registry`).
@@ -0,0 +1,215 @@
package main
// The build step (novox/hq ADR 0487 §1 §2 §5 §6; issue 1703c): one `image` artifact of a module of a fetched workspace,
// built from the Dockerfile in the module's own directory with buildah, rootless: as the module's own account, in a
// user namespace, with its image store outside the home, no mount of the host, no registry credential and an
// environment built from scratch. The step runs the Dockerfile and nothing else; the push of a trunk build is made
// after the build has ended (push.go).
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// BuildResult is what a passed build step says: the image's manifest digest as buildah holds it, and, for a trunk
// build that published, the digest the registry serves and the reference pinned by it.
type BuildResult struct {
Artifact string `json:"artifact"`
Digest string `json:"digest"`
Reference string `json:"reference,omitempty"`
Pushed bool `json:"pushed"`
}
var tagOK = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$`)
func (m *Module) buildTool() StepTool {
return StepTool{
Name: BuildTool,
Description: "Build one image artifact of a module of a fetched workspace from the Dockerfile in the module's own directory, " +
"rootless with buildah. Answers a step id at once; the step ends on its key with the image's digest. With publish " +
"true (a merged trunk commit) the image is then pushed to the registry from this module's own process and the key " +
"carries the digest the registry serves and the reference pinned by it; without it (a pull request) nothing is pushed.",
Input: map[string]any{
"type": "object",
"properties": map[string]any{
"workspace": map[string]any{"type": "string", "description": "the workspace id build_image_fetch answered"},
"module": map[string]any{"type": "string", "description": "the module's directory in the workspace, such as modules/n8n"},
"artifact": map[string]any{"type": "string", "description": "the image artifact's name in the module's manifest"},
"publish": map[string]any{"type": "boolean", "description": "push the image to the registry after the build: only for a merged trunk commit"},
"tag": map[string]any{"type": "string", "description": "the registry tag of a published image; the first 12 hex digits of the digest when left out"},
},
"required": []string{"workspace", "module", "artifact"},
},
Long: true,
Run: m.build,
}
}
// unsupportedImage says why this module does not build the artifact, or "" when it does. It is the one place that
// knows which image forms are out: ADR 0487 §1 takes the Dockerfile's directory as the build context and the base
// from the Dockerfile or an image pinned in `build.on`, so a context that is another repository and a base that is
// another module's artifact are not built (the lead's decision for R4c-9, which retires them with build-agent at R4g).
func unsupportedImage(manifest catalogue.Manifest, a catalogue.Artifact) string {
if a.Context != nil {
return fmt.Sprintf("the image %s of %s builds with the repository %s as its context; mesh-build-image takes the Dockerfile's directory as the build context (ADR 0487 §1) and does not build a whole repository",
a.Name, manifest.Module, a.Context.Repository)
}
if manifest.Build != nil {
for _, on := range manifest.Build.On {
if on.Module != "" {
return fmt.Sprintf("the image %s of %s stands on the artifact %s of the module %s through the build argument %s; mesh-build-image builds only from the Dockerfile's directory and an image pinned in build.on (ADR 0487 §1)",
a.Name, manifest.Module, on.Artifact, on.Module, on.Arg)
}
}
}
return ""
}
// imageArgs are the --build-arg words of the images the manifest pins in build.on, in its order.
func imageArgs(manifest catalogue.Manifest) []string {
var out []string
if manifest.Build == nil {
return out
}
for _, on := range manifest.Build.On {
if on.Image != "" && on.Arg != "" {
out = append(out, "--build-arg", on.Arg+"="+on.Image)
}
}
return out
}
func (m *Module) build(ctx context.Context, args map[string]any) (any, error) {
workspace, _ := args["workspace"].(string)
module, _ := args["module"].(string)
name, _ := args["artifact"].(string)
publish, _ := args["publish"].(bool)
tag, _ := args["tag"].(string)
if !workspaceOK.MatchString(workspace) {
return nil, fmt.Errorf("%q is not a workspace id", workspace)
}
if tag != "" && !tagOK.MatchString(tag) {
return nil, fmt.Errorf("%q is not a registry tag", tag)
}
tree := filepath.Join(m.Work, workspace, "tree")
dir := filepath.Join(tree, filepath.FromSlash(module))
if rel, err := filepath.Rel(tree, dir); err != nil || strings.HasPrefix(rel, "..") {
return nil, fmt.Errorf("the module directory %q lies outside the workspace", module)
}
raw, err := os.ReadFile(filepath.Join(dir, "module.json"))
if err != nil {
return nil, fmt.Errorf("no module in %q of workspace %s: %w", module, workspace, err)
}
manifest, err := catalogue.ParseManifest(raw)
if err != nil {
return nil, fmt.Errorf("the manifest in %q: %w", module, err)
}
var art *catalogue.Artifact
names := []string{}
if manifest.Build != nil {
for i := range manifest.Build.Artifacts {
names = append(names, manifest.Build.Artifacts[i].Name)
if manifest.Build.Artifacts[i].Name == name {
art = &manifest.Build.Artifacts[i]
}
}
}
if art == nil {
return nil, fmt.Errorf("the module %s has no artifact %q (it has %s)", manifest.Module, name, strings.Join(names, ", "))
}
if art.Kind != "image" {
return nil, fmt.Errorf("the artifact %s of %s is a %s: this module builds images", name, manifest.Module, art.Kind)
}
if why := unsupportedImage(manifest, *art); why != "" {
return nil, &buildstep.Refusal{Text: why}
}
dockerfile, contextDir, err := dockerfileOf(dir, art.From)
if err != nil {
return nil, fmt.Errorf("the image %s of %s: %w", name, manifest.Module, err)
}
local := localName(workspace, manifest.Module, art.Name)
argv := append(m.storeFlags(), "bud", "--isolation", "oci", "--tag", local, "--file", dockerfile)
if art.Target != "" {
argv = append(argv, "--target", art.Target)
}
argv = append(argv, imageArgs(manifest)...)
argv = append(argv, contextDir)
if r := m.Run(ctx, m.env(), "buildah", argv...); r.Status != 0 || r.Err != "" {
return nil, fmt.Errorf("buildah bud of %s: %s", art.Name, lastLine(r.Err+"\n"+r.Stderr+"\n"+r.Stdout))
}
defer m.remove(local)
digest, err := m.localDigest(ctx, local)
if err != nil {
return nil, err
}
res := BuildResult{Artifact: art.Name, Digest: digest}
if !publish {
return res, nil
}
if tag == "" {
tag = strings.TrimPrefix(digest, "sha256:")[:12]
}
return m.push(ctx, workspace, manifest.Module, local, res, tag)
}
// dockerfileOf is the Dockerfile an artifact's `from` names and the build context: the directory it lies in. It must
// be a regular file inside the module's own directory (checks/imageplace holds the catalogue to the same).
func dockerfileOf(moduleDir, from string) (dockerfile, contextDir string, err error) {
clean := filepath.Clean(filepath.FromSlash(strings.TrimSpace(from)))
if from == "" || filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", "", fmt.Errorf("%q is not a Dockerfile in the module's own directory", from)
}
dockerfile = filepath.Join(moduleDir, clean)
info, err := os.Lstat(dockerfile)
if err != nil || !info.Mode().IsRegular() {
return "", "", fmt.Errorf("%q is not a file in the module's directory", from)
}
return dockerfile, filepath.Dir(dockerfile), nil
}
// localName names the image in buildah's store: one name for each artifact of a workspace.
func localName(workspace, module, artifact string) string {
sum := sha256.Sum256([]byte(module + "/" + artifact))
return "mesh-build:" + workspace + "-" + hex.EncodeToString(sum[:])[:8]
}
// localDigest is the manifest digest of the image buildah holds under name.
func (m *Module) localDigest(ctx context.Context, name string) (string, error) {
r := m.Run(ctx, m.env(), "buildah", append(m.storeFlags(), "images", "--no-trunc", "--format", "{{.Digest}}", name)...)
digest := strings.TrimSpace(r.Stdout)
if r.Status != 0 || r.Err != "" || !digestOK.MatchString(digest) {
return "", fmt.Errorf("the digest of the image %s could not be read: %s", name, lastLine(r.Err+"\n"+r.Stderr+"\n"+r.Stdout))
}
return digest, nil
}
var digestOK = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
// remove drops the image from buildah's store, so the store does not grow with every build; a failure is said.
func (m *Module) remove(name string) {
r := m.Run(m.Ctx, m.env(), "buildah", append(m.storeFlags(), "rmi", "--force", name)...)
if (r.Status != 0 || r.Err != "") && m.Say != nil {
m.Say("the image %s could not be removed from the store: %s", name, lastLine(r.Err+"\n"+r.Stderr))
}
}
// lastLine is the last line of a command's output that says something: buildah's error is its last line.
func lastLine(s string) string {
lines := strings.Split(s, "\n")
for i := len(lines) - 1; i >= 0; i-- {
if l := strings.TrimSpace(lines[i]); l != "" {
return l
}
}
return ""
}
@@ -0,0 +1,200 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// The fixture module (testdata/repos/image): a Dockerfile that adds a file to an upstream base the manifest pins in
// build.on, as a workspace's tree holds it.
func place(t *testing.T, m *Module, edit func(string) string) {
t.Helper()
src := filepath.Join("testdata", "repos", "image", "modules", "demo")
dst := filepath.Join(m.Work, "ws1", "tree", "modules", "demo")
if err := os.MkdirAll(dst, 0o755); err != nil {
t.Fatal(err)
}
for _, n := range []string{"Dockerfile", "hello.txt", "module.json.fixture"} {
raw, err := os.ReadFile(filepath.Join(src, n))
if err != nil {
t.Fatal(err)
}
if n == "module.json.fixture" {
n = "module.json"
raw = []byte(edit(string(raw)))
}
if err := os.WriteFile(filepath.Join(dst, n), raw, 0o644); err != nil {
t.Fatal(err)
}
}
}
func same(s string) string { return s }
func runBuild(t *testing.T, m *Module, states *keptStates, extra map[string]any) (BuildResult, delivery.Step) {
t.Helper()
args := map[string]any{"workspace": "ws1", "module": "modules/demo", "artifact": "server"}
for k, v := range extra {
args[k] = v
}
if _, err := tool(t, m, BuildTool)(args); err != nil {
t.Fatal(err)
}
st := states.waitEnd(t)
var res BuildResult
if st.State == delivery.StepPassed {
if err := json.Unmarshal(st.Result, &res); err != nil {
t.Fatal(err)
}
}
return res, st
}
func setup(t *testing.T, edit func(string) string) (*Module, *fake, *keptStates) {
t.Helper()
f := newFake(t)
states := newStates()
m := moduleWith(t, f, states)
place(t, m, edit)
return m, f, states
}
// 1703c/AC: the build runs buildah bud rootless: the store and the runtime state in the store directory, the
// Dockerfile of the module's own directory and that directory as the context, the target, the pinned base as a build
// argument; no mount, no credential file, a runtime directory of its own, and it answers the image's digest.
func TestBuildRunsBuildahRootlessAndAnswersTheDigest(t *testing.T) {
m, f, states := setup(t, same)
res, st := runBuild(t, m, states, nil)
if st.State != delivery.StepPassed {
t.Fatalf("step = %+v", st)
}
if res.Digest != imageDigest || res.Artifact != "server" || res.Pushed || res.Reference != "" {
t.Errorf("result = %+v, want the local digest and no push", res)
}
bud := f.called("bud")
if len(bud) != 1 {
t.Fatalf("buildah bud ran %d times", len(bud))
}
dir := filepath.Join(m.Work, "ws1", "tree", "modules", "demo")
for _, want := range []string{
"--root " + m.Store + "/root", "--runroot " + m.Store + "/run", "--isolation oci",
"--file " + dir + "/Dockerfile", "--target final",
"--build-arg DEMO_BASE=docker.io/library/busybox@sha256:0000000000000000000000000000000000000000000000000000000000000001",
} {
if !strings.Contains(bud[0].line, want) {
t.Errorf("bud line lacks %q:\n%s", want, bud[0].line)
}
}
if !strings.HasSuffix(bud[0].line, " "+dir) {
t.Errorf("the context is not the Dockerfile's directory:\n%s", bud[0].line)
}
for _, c := range f.calls {
if c.name != "buildah" {
continue
}
for _, bad := range []string{" -v ", "--volume", "--mount", "--authfile", "--creds", "--cap-add", "--privileged", "--device"} {
if strings.Contains(" "+c.line+" ", bad) {
t.Errorf("%q carries %q", c.line, bad)
}
}
env := strings.Join(c.env, " ")
if strings.Contains(env, "REGISTRY_AUTH") || strings.Contains(env, "DOCKER_CONFIG") || strings.Contains(env, "MESH_") {
t.Errorf("the environment of %q carries a credential or the module's own: %s", c.line, env)
}
if !strings.Contains(env, "XDG_RUNTIME_DIR="+m.Store+"/xdg") {
t.Errorf("the environment of %q lacks the module's own runtime directory: %s", c.line, env)
}
}
// The image is removed from the store once the digest is read, so the store does not grow with every build.
if len(f.called("rmi")) != 1 {
t.Errorf("rmi ran %d times, want the image removed once", len(f.called("rmi")))
}
}
// 1703c: a failing Dockerfile step fails the step, saying buildah's last line.
func TestBuildFailsWithBuildahsLastLine(t *testing.T) {
m, f, states := setup(t, same)
f.failBud = "Error: building at STEP \"RUN false\": exit status 1"
_, st := runBuild(t, m, states, nil)
if st.State != delivery.StepFailed || !strings.Contains(st.Reason, `STEP "RUN false"`) {
t.Errorf("step = %+v", st)
}
if len(f.called("push")) != 0 {
t.Error("a failed build pushed")
}
}
// 1703c: an image whose context is a repository, or whose build.on names another module's artifact, is refused
// (not failed), the reason saying why; nothing is built.
func TestBuildRefusesAnImageWithARepositoryContextOrAModuleBase(t *testing.T) {
cases := map[string]struct {
edit func(string) string
want []string
}{
"context": {func(s string) string {
return strings.Replace(s, `"target": "final"`, `"target": "final", "context": {"seat": "git", "repository": "novox/mesh-controller"}`, 1)
}, []string{"novox/mesh-controller", "Dockerfile's directory", "ADR 0487"}},
"module base": {func(s string) string {
return strings.Replace(s, `"image": "docker.io/library/busybox@sha256:0000000000000000000000000000000000000000000000000000000000000001"`, `"module": "mesh-sdk", "artifact": "lib"`, 1)
}, []string{"artifact lib of the module mesh-sdk", "DEMO_BASE"}},
}
for name, c := range cases {
m, f, states := setup(t, c.edit)
_, st := runBuild(t, m, states, nil)
if st.State != delivery.StepRefused {
t.Errorf("%s: step = %+v, want refused", name, st)
continue
}
for _, w := range c.want {
if !strings.Contains(st.Reason, w) {
t.Errorf("%s: the reason %q lacks %q", name, st.Reason, w)
}
}
if len(f.called("bud")) != 0 {
t.Errorf("%s: a refused image was built", name)
}
}
}
// The refusal is one small function: it knows the two forms and passes the rest.
func TestUnsupportedImageNamesTheTwoFormsAndPassesTheRest(t *testing.T) {
plain := catalogue.Manifest{Module: "x", Build: &catalogue.Build{On: []catalogue.BuildsOn{{Arg: "A", Image: "i@sha256:aa"}}}}
if why := unsupportedImage(plain, catalogue.Artifact{Name: "a", Kind: "image"}); why != "" {
t.Errorf("a plain image was refused: %s", why)
}
if why := unsupportedImage(plain, catalogue.Artifact{Name: "a", Kind: "image", Context: &catalogue.ArtifactContext{Repository: "o/r"}}); why == "" {
t.Error("a repository context passed")
}
}
func TestBuildFailsForWhatIsNotAnImageOrNotThere(t *testing.T) {
for name, tc := range map[string]struct {
edit func(string) string
args map[string]any
}{
"not an image": {func(s string) string { return strings.Replace(s, `"kind": "image"`, `"kind": "archive"`, 1) }, nil},
"no artifact": {same, map[string]any{"artifact": "other"}},
"no Dockerfile": {func(s string) string {
return strings.Replace(s, `"from": "Dockerfile"`, `"from": "Missing"`, 1)
}, nil},
"leaves the module": {func(s string) string {
return strings.Replace(s, `"from": "Dockerfile"`, `"from": "../x/Dockerfile"`, 1)
}, nil},
"outside the workspace": {same, map[string]any{"module": "../.."}},
} {
m, f, states := setup(t, tc.edit)
_, st := runBuild(t, m, states, tc.args)
if st.State != delivery.StepFailed {
t.Errorf("%s: step = %+v, want failed", name, st)
}
if len(f.called("bud")) != 0 {
t.Errorf("%s: buildah bud ran", name)
}
}
}
@@ -0,0 +1,159 @@
package main
// The fetch step (novox/hq ADR 0487 §1, ADR 0466 §4 §5, ADR 0469 §2; issue 1703b): a commit of a repository of the
// mesh's forge, fetched over SSH as the module's own account into a workspace of its own. Nothing is downloaded
// beyond it: an image's Dockerfile fetches what it needs itself, inside the build.
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/buildstep"
)
var (
commitOK = regexp.MustCompile(`^[0-9a-f]{40}$`)
repositoryOK = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$`)
workspaceOK = regexp.MustCompile(`^[a-z0-9]+$`)
)
// ForgeAlias is the ssh name the forge-account provision sets up in the account's ssh files (ADR 0409 §1); the
// remote of a fetch is ssh://<it>/<owner>/<repository>.git.
const ForgeAlias = "mesh-forge"
// WorkspaceAge is how long a workspace is kept: a module removes the workspaces older than a day, each time it
// fetches and every round it checks itself. A workspace lies in the module's work directory, never in its home
// (ADR 0466 §1).
const WorkspaceAge = 24 * time.Hour
// FetchResult is what a passed fetch step says: the workspace it made and the commit in it, and, only when the call
// gave a base, the merge preview of ADR 0484 clause 3 (the trunk commit, the merge commit of the head onto it, the
// files in conflict, the paths the merge changes and the tree of each module directory). A head that does not
// merge cleanly is an answer: Merge is empty, Conflicts names the files, and the workspace is left at the trunk.
type FetchResult struct {
Workspace string `json:"workspace"`
Commit string `json:"commit"`
Base string `json:"base,omitempty"`
Merge string `json:"merge,omitempty"`
Conflicts []string `json:"conflicts,omitempty"`
Changed []string `json:"changed,omitempty"`
Trees map[string]buildstep.ModuleTree `json:"trees,omitempty"`
}
func (m *Module) fetchTool() StepTool {
return StepTool{
Name: FetchTool,
Description: "Fetch a commit of a repository of the mesh's forge over SSH, as this module's own account, into a " +
"workspace of its own. Answers a step id at once; the step ends on its key in the steps state with the " +
"workspace's id.",
Input: map[string]any{
"type": "object",
"properties": map[string]any{
"repository": map[string]any{"type": "string", "description": "<owner>/<repository> on the forge"},
"commit": map[string]any{"type": "string", "description": "the full commit, 40 hex digits"},
"ref": map[string]any{"type": "string", "description": "refs/pull/<n>/head when only a pull request's head carries the commit"},
"base": map[string]any{"type": "string", "description": "optional trunk commit, 40 hex digits: the head is merged onto it as the forge's merge would, and the answer carries the merge commit, the files in conflict (an answer, the workspace is then left at the trunk commit), the changed paths and the tree of each module directory"},
},
"required": []string{"repository", "commit"},
},
Long: true,
Run: m.fetch,
}
}
func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) {
repository, _ := args["repository"].(string)
commit, _ := args["commit"].(string)
ref, _ := args["ref"].(string)
base, _ := args["base"].(string)
if !repositoryOK.MatchString(repository) || strings.Contains(repository, "..") {
return nil, fmt.Errorf("%q is not a repository, <owner>/<name>", repository)
}
if !commitOK.MatchString(commit) {
return nil, fmt.Errorf("%q is not a full commit (40 hex digits)", commit)
}
if base != "" && !commitOK.MatchString(base) {
return nil, fmt.Errorf("%q is not a trunk commit (40 hex digits)", base)
}
m.CleanWorkspaces(m.clock())
id := buildstep.NewID()
tree := filepath.Join(m.Work, id, "tree")
if err := os.MkdirAll(tree, 0o700); err != nil {
return nil, err
}
remote := m.remote(repository)
if err := buildstep.FetchCommit(ctx, m.Run, tree, remote, commit, ref, m.FetchOptions...); err != nil {
return nil, err
}
res := FetchResult{Workspace: id, Commit: commit}
if base != "" {
if err := m.merge(ctx, &res, tree, remote, base, commit); err != nil {
return nil, err
}
}
return res, nil
}
// remote is the address a repository is fetched from: the forge over ssh as the account, or RemoteBase when a test set it.
func (m *Module) remote(repository string) string {
base := m.RemoteBase
if base == "" {
base = "ssh://" + ForgeAlias + "/"
}
return base + repository + ".git"
}
// merge makes the merge preview of the head onto base (the SDK's buildstep.MergeInto, shared by every build module)
// and writes the answer into res.
func (m *Module) merge(ctx context.Context, res *FetchResult, tree, remote, base, commit string) error {
mr, err := buildstep.MergeInto(ctx, m.Run, tree, remote, base, commit, m.FetchOptions...)
if err != nil {
return err
}
res.Base = mr.Base
res.Conflicts, res.Merge, res.Changed = mr.Conflicts, mr.Merge, mr.Changed
if res.Merge == "" {
return nil
}
res.Trees, err = buildstep.ModuleTrees(ctx, m.Run, nil, tree, res.Merge)
return err
}
// CleanWorkspaces removes the workspaces older than WorkspaceAge from the work directory.
func (m *Module) CleanWorkspaces(now time.Time) {
entries, err := os.ReadDir(m.Work)
if err != nil {
return
}
for _, e := range entries {
info, err := e.Info()
if err != nil || !e.IsDir() || !workspaceOK.MatchString(e.Name()) || now.Sub(info.ModTime()) < WorkspaceAge {
continue
}
if err := os.RemoveAll(filepath.Join(m.Work, e.Name())); err != nil && m.Say != nil {
m.Say("the workspace %s could not be removed: %v", e.Name(), err)
}
}
}
// clock is the time now: Now when a test set it, else the wall clock.
func (m *Module) clock() time.Time {
if m.Now != nil {
return m.Now()
}
return time.Now()
}
func firstLine(s string) string {
for _, l := range strings.Split(s, "\n") {
if l = strings.TrimSpace(l); l != "" {
return l
}
}
return ""
}
@@ -0,0 +1,67 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// 1703b: the fetch step asks git, through the SDK's FetchCommit, for the ssh remote of the forge as the account, makes
// the workspace's tree in the work directory and answers its id and the commit; it downloads nothing else.
func TestFetchAsksGitForTheSshRemoteIntoAWorkspace(t *testing.T) {
f := newFake(t)
states := newStates()
m := moduleWith(t, f, states)
ans, err := tool(t, m, FetchTool)(map[string]any{"repository": "novox/mesh-catalog", "commit": commitSHA, "ref": "refs/pull/7/head"})
if err != nil {
t.Fatal(err)
}
if a := ans.(map[string]any); a["state"] != delivery.StepRunning || a["step"] == "" {
t.Errorf("the call answered %v, want a running step", a)
}
st := states.waitEnd(t)
if st.State != delivery.StepPassed {
t.Fatalf("step = %+v", st)
}
var res FetchResult
if err := json.Unmarshal(st.Result, &res); err != nil {
t.Fatal(err)
}
if res.Commit != commitSHA || res.Workspace == "" || res.Merge != "" {
t.Errorf("result = %+v", res)
}
if info, err := os.Stat(filepath.Join(m.Work, res.Workspace, "tree")); err != nil || !info.IsDir() {
t.Errorf("no tree in the workspace: %v", err)
}
joined := strings.Join(f.lines(), "\n")
if !strings.Contains(joined, "ssh://mesh-forge/novox/mesh-catalog.git") || !strings.Contains(joined, commitSHA) {
t.Errorf("git was not asked for the forge's ssh remote and the commit:\n%s", joined)
}
for _, l := range f.lines() {
if strings.Contains(l, "buildah bud") || strings.HasPrefix(l, "go ") {
t.Errorf("a fetch ran %q", l)
}
}
}
func TestFetchRefusesWhatIsNotARepositoryOrACommit(t *testing.T) {
for name, args := range map[string]map[string]any{
"repository": {"repository": "../etc", "commit": commitSHA},
"commit": {"repository": "novox/mesh-catalog", "commit": "main"},
"base": {"repository": "novox/mesh-catalog", "commit": commitSHA, "base": "trunk"},
} {
f := newFake(t)
states := newStates()
m := moduleWith(t, f, states)
if _, err := tool(t, m, FetchTool)(args); err != nil {
t.Fatal(err)
}
if st := states.waitEnd(t); st.State != delivery.StepFailed {
t.Errorf("%s: step = %+v, want failed", name, st)
}
}
}
@@ -0,0 +1,258 @@
package main
// The forge side of a build module (novox/hq ADR 0466 §2 and §4, issue 1484). This file is the same for every build
// module: mesh-build-node takes a copy and changes the three constants below.
import (
"context"
"encoding/base64"
"encoding/binary"
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
const (
forgeModule = "mesh-build-image"
forgeHome = "/var/lib/mesh-build-image"
forgeModuleD = "../.." // the module's directory, where module.json and files/ are
)
type forgeManifest struct {
Moved bool `json:"moved"`
Requires []any `json:"requires"`
State []any `json:"state"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
func readForgeManifest(t *testing.T) forgeManifest {
t.Helper()
raw, err := os.ReadFile(filepath.Join(forgeModuleD, "module.json"))
if err != nil {
t.Fatal(err)
}
var m forgeManifest
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
return m
}
// The forge files are filled on the node by the provisioner's client, never by the controller (ADR 0466 §4, ADR 0400
// §5, issue 1537): the forge serves a module's entry only after the module has run and reported its key (ADR 0400
// §2), so a controller filling them would refuse the machine until then, and the module could never run.
func TestTheForgeFilesAreFilledOnTheNodeSoTheModuleCanRunBeforeItsEntry(t *testing.T) {
if !readForgeManifest(t).Moved {
t.Errorf("%s's manifest does not say \"moved\": true, so the controller fills its forge files and refuses "+
"every machine it is assigned to until the forge serves an entry it makes only after the module ran", forgeModule)
}
}
func forgeTemplate(t *testing.T, r map[string]any) string {
t.Helper()
name, _ := r["template"].(string)
if name == "" {
t.Fatalf("resource %v names no template", r["id"])
}
raw, err := os.ReadFile(filepath.Join(forgeModuleD, filepath.FromSlash(name)))
if err != nil {
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
}
return string(raw)
}
func forgeResource(t *testing.T, m forgeManifest, id string) map[string]any {
t.Helper()
for _, r := range m.Resources {
if r["id"] == id {
if r["owner"] != forgeModule {
t.Errorf("%s is owned by %v, not the module's own account", id, r["owner"])
}
if p, _ := r["path"].(string); !strings.HasPrefix(p, "${dir:") || strings.Contains(p, "account-home") {
t.Errorf("%s is at %q: a path of the account's own home (${dir:...}), never the operator's", id, p)
}
return r
}
}
t.Fatalf("no resource %q", id)
return nil
}
var forgeSample = map[string]string{
"known_host": "[novox.internal]:222 ssh-ed25519 AAAAC3Nza",
"host_key": "ssh-ed25519 AAAAC3Nza",
"ssh_host": "novox.internal",
"ssh_alias": "mesh-forge",
"ssh_port": "222",
"ssh_base": "ssh://git@novox.internal:222/",
"https_base": "https://git.novox.be/",
}
// forgeRender fills ${bound:forge-account:<key>} with the sample values, as the provisioner client does.
func forgeRender(t *testing.T, s string) string {
t.Helper()
for k, v := range forgeSample {
s = strings.ReplaceAll(s, "${bound:forge-account:"+k+"}", v)
}
if strings.Contains(s, "${") {
t.Errorf("unfilled placeholder in %q", s)
}
return s
}
// 1484/AC1: given sample bound values, the ssh config has Host mesh-forge, IdentitiesOnly yes and the key under the
// module's own home; known_hosts carries the host key; git's config rewrites https_base to ssh://mesh-forge/; no
// ${ is left.
func TestForgeFilesRenderFromTheBoundValuesInTheModulesOwnHome(t *testing.T) {
m := readForgeManifest(t)
cfg := forgeResource(t, m, "ssh-config")
want := "Host mesh-forge\n HostName novox.internal\n Port 222\n HostKeyAlias mesh-forge\n User git\n IdentityFile ~/.ssh/mesh_ed25519\n IdentitiesOnly yes\n"
if got := forgeRender(t, forgeTemplate(t, cfg)); got != want {
t.Errorf("ssh config = %q, want %q", got, want)
}
if cfg["path"] != "${dir:ssh-dir}/config" || cfg["mode"] != "0600" || cfg["into"] != nil {
t.Errorf("ssh config resource = %v, want the whole file ${dir:ssh-dir}/config at mode 0600", cfg)
}
kh := forgeResource(t, m, "known-hosts")
if got := strings.TrimSpace(forgeRender(t, forgeTemplate(t, kh))); got != "mesh-forge ssh-ed25519 AAAAC3Nza" {
t.Errorf("known_hosts = %q", got)
}
g := forgeResource(t, m, "git-config")
wantGit := "[url \"ssh://mesh-forge/\"]\n\tinsteadOf = https://git.novox.be/\n"
if got := forgeRender(t, forgeTemplate(t, g)); got != wantGit {
t.Errorf("git config = %q, want %q", got, wantGit)
}
dir := forgeResource(t, m, "ssh-dir")
if dir["mode"] != "0700" || dir["path"] != "${dir:state}/.ssh" {
t.Errorf("ssh-dir = %v, want ${dir:state}/.ssh at mode 0700", dir)
}
}
// 1484/AC2: the module requires forge-account, keeps the state keys, and every file resource takes its text from a
// template in the files/ archive (novox/hq ADR 0349 §2).
func TestManifestRequiresForgeAccountKeepsKeysAndTakesFilesFromTemplates(t *testing.T) {
m := readForgeManifest(t)
if want := []any{"forge-account", "rootless-image-build", "artifact-store"}; !reflect.DeepEqual(m.Requires, want) {
t.Errorf("requires = %v, want %v", m.Requires, want)
}
keeps := false
for _, s := range m.State {
if s == "keys" {
keeps = true
}
}
if !keeps {
t.Errorf("state = %v lacks keys", m.State)
}
files := 0
for _, r := range m.Resources {
if _, inline := r["content"]; inline {
t.Errorf("resource %v keeps its text inline", r["id"])
}
if r["type"] == "file" {
files++
forgeTemplate(t, r)
}
}
if files == 0 {
t.Errorf("no file resource: the forge's ssh files are missing")
}
packs := 0
for _, a := range m.Build.Artifacts {
if a["kind"] == "archive" && a["from"] == "files" && a["name"] == "files" {
packs++
}
}
if packs != 1 {
t.Errorf("the build packs files/ into %d archives named files, not one", packs)
}
}
// forgeEd25519Line is a public key line with a well-formed ed25519 blob.
func forgeEd25519Line(comment string) string {
blob := []byte{}
for _, part := range [][]byte{[]byte("ssh-ed25519"), make([]byte, 32)} {
l := make([]byte, 4)
binary.BigEndian.PutUint32(l, uint32(len(part)))
blob = append(append(blob, l...), part...)
}
return "ssh-ed25519 " + base64.StdEncoding.EncodeToString(blob) + " " + comment
}
type forgeKeys struct{ puts map[string][]byte }
func (f *forgeKeys) Put(key string, value any) (uint64, error) {
raw, _ := json.Marshal(value)
if f.puts == nil {
f.puts = map[string][]byte{}
}
f.puts[key] = raw
return uint64(len(f.puts)), nil
}
// forgeKeygen stands in for ssh-keygen: it writes the pair the command names.
func forgeKeygen(_ context.Context, _ []string, name string, args ...string) forgekey.Ran {
var file, comment string
for i, a := range args {
switch a {
case "-f":
file = args[i+1]
case "-C":
comment = args[i+1]
}
}
if name != "ssh-keygen" || file == "" {
return forgekey.Ran{Status: 1, Stderr: "unexpected"}
}
os.WriteFile(file, []byte("PRIVATE\n"), 0o600)
os.WriteFile(file+".pub", []byte(forgeEd25519Line(comment)+"\n"), 0o644)
return forgekey.Ran{}
}
// 1484/AC3: given the bundle started with an empty home, the keeper writes <node>.<module> with kind builder, and
// the key is in the home's .ssh.
func TestTheKeeperWritesTheModulesEntryWithKindBuilder(t *testing.T) {
home, st := t.TempDir(), &forgeKeys{}
if err := keeperFor(home, "ace", forgeModule, forgeKeygen, st).Once(context.Background()); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(home, ".ssh", "mesh_ed25519")); err != nil {
t.Errorf("no key in an empty home: %v", err)
}
raw, ok := st.puts["ace."+forgeModule]
if !ok || len(st.puts) != 1 {
t.Fatalf("entries = %v, want one at ace.%s", st.puts, forgeModule)
}
var e forgekey.KeyEntry
if err := json.Unmarshal(raw, &e); err != nil {
t.Fatal(err)
}
if e.Kind != "builder" || e.Node != "ace" || e.Account != forgeModule || !strings.HasPrefix(e.Public, "ssh-ed25519 ") {
t.Errorf("entry = %s", raw)
}
if strings.Contains(strings.ToUpper(string(raw)), "PRIVATE") {
t.Errorf("the entry carries private material: %s", raw)
}
}
// The home the manifest places is the one the account is given, so the keeper's home and the templates' agree.
func TestTheAccountsHomeIsTheOneTheManifestNames(t *testing.T) {
m := readForgeManifest(t)
for _, r := range m.Resources {
if r["type"] == "user" && r["name"] == forgeModule && r["home"] != forgeHome {
t.Errorf("the account's home is %v, want %s", r["home"], forgeHome)
}
}
}
@@ -0,0 +1,201 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
const commitSHA = "0123456789abcdef0123456789abcdef01234567"
// keptStates is the steps state, kept in memory: every write of a key, and the keys that ended.
type keptStates struct {
mu sync.Mutex
steps map[string][]delivery.Step
ended chan string
}
func newStates() *keptStates {
return &keptStates{steps: map[string][]delivery.Step{}, ended: make(chan string, 16)}
}
func (s *keptStates) putStep(key string, value any) (uint64, error) {
st, err := delivery.DecodeStep(value.(json.RawMessage))
if err != nil {
return 0, err
}
s.mu.Lock()
s.steps[key] = append(s.steps[key], st)
s.mu.Unlock()
if st.State != delivery.StepRunning {
s.ended <- key
}
return 1, nil
}
func (s *keptStates) waitEnd(t *testing.T) delivery.Step {
t.Helper()
select {
case key := <-s.ended:
s.mu.Lock()
defer s.mu.Unlock()
v := s.steps[key]
return v[len(v)-1]
case <-time.After(5 * time.Second):
t.Fatal("the step never ended on its key")
return delivery.Step{}
}
}
// cmdRecord is one command a step ran.
type cmdRecord struct {
env []string
name string
line string
}
// fake stands in for git and buildah at the process boundary and for the registry over HTTP.
type fake struct {
mu sync.Mutex
calls []cmdRecord
digest string // what `buildah images` prints
pushes int
refuse int // the first n pushes answer "connection refused"
failBud string
// registry: repository:tag -> digest
registry map[string]string
server *httptest.Server
}
const imageDigest = "sha256:1111111111111111111111111111111111111111111111111111111111111111"
func newFake(t *testing.T) *fake {
f := &fake{digest: imageDigest, registry: map[string]string{}}
f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
key := strings.TrimPrefix(r.URL.Path, "/v2/")
i := strings.Index(key, "/manifests/")
if r.Method != http.MethodHead || i < 0 {
http.NotFound(w, r)
return
}
d, ok := f.registry[key[:i]+":"+key[i+len("/manifests/"):]]
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Docker-Content-Digest", d)
}))
t.Cleanup(f.server.Close)
return f
}
func (f *fake) address() string { return f.server.Listener.Addr().String() }
func (f *fake) run(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
line := name + " " + strings.Join(args, " ")
f.mu.Lock()
f.calls = append(f.calls, cmdRecord{env: env, name: name, line: line})
f.mu.Unlock()
switch {
case name == "git" && strings.Contains(line, " rev-parse HEAD"):
return forgekey.Ran{Stdout: commitSHA + "\n"}
case name == "buildah" && strings.Contains(line, "--version"):
return forgekey.Ran{Stdout: "buildah version 1.45.1 (image-spec 1.1.0, runtime-spec 1.1.0)\n"}
case name == "buildah" && strings.Contains(line, " bud "):
if f.failBud != "" {
return forgekey.Ran{Status: 1, Stderr: "STEP 1/2: FROM x\n" + f.failBud + "\n"}
}
case name == "buildah" && strings.Contains(line, " images "):
return forgekey.Ran{Stdout: f.digest + "\n"}
case name == "buildah" && strings.Contains(line, " push "):
f.mu.Lock()
defer f.mu.Unlock()
if f.refuse > 0 {
f.refuse--
return forgekey.Ran{Status: 125, Stderr: "Error: dial tcp 127.0.0.1:5000: connect: connection refused\n"}
}
f.pushes++
var digestFile, dest string
for i, a := range args {
if a == "--digestfile" {
digestFile = args[i+1]
}
if strings.HasPrefix(a, "docker://") {
dest = strings.TrimPrefix(a, "docker://")
}
}
dest = strings.TrimPrefix(dest, f.address()+"/")
f.registry[dest] = f.digest
os.WriteFile(digestFile, []byte(f.digest), 0o644)
}
return forgekey.Ran{}
}
func (f *fake) lines() []string {
f.mu.Lock()
defer f.mu.Unlock()
out := []string{}
for _, c := range f.calls {
out = append(out, c.line)
}
return out
}
func (f *fake) called(verb string) []cmdRecord {
f.mu.Lock()
defer f.mu.Unlock()
var out []cmdRecord
for _, c := range f.calls {
if c.name == "buildah" && strings.Contains(c.line, " "+verb+" ") {
out = append(out, c)
}
}
return out
}
// moduleWith is a module on node ace over the fake, with subordinate ranges for its account, and the registry's
// address written where the manifest would bind it.
func moduleWith(t *testing.T, f *fake, states *keptStates) *Module {
t.Helper()
dir := t.TempDir()
write := func(name, body string) string {
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
return p
}
m := &Module{
Node: "ace", Account: "mesh-build-image", UID: "1234", Home: t.TempDir(), Work: t.TempDir(), Store: t.TempDir(),
Steps: states.putStep, Ctx: context.Background(), Run: f.run, HTTP: f.server.Client(),
SubUIDFile: write("subuid", "someone:100000:65536\nmesh-build-image:624288:65536\n"),
SubGIDFile: write("subgid", "mesh-build-image:624288:65536\n"),
RegistryFile: write("registry", f.address()+"\n"),
RegistryWait: 2 * time.Second,
RegistryPause: 5 * time.Millisecond,
}
return m
}
func tool(t *testing.T, m *Module, name string) func(map[string]any) (any, error) {
t.Helper()
for _, tl := range m.Tools(m.StepTools()) {
if tl.Name == name {
return tl.Run
}
}
t.Fatalf("no tool %s", name)
return nil
}
@@ -0,0 +1,219 @@
//go:build integration
package main
// The image build module against real git and real buildah (novox/hq ADR 0487 §1, §2, §6; issue 1703e). They start
// processes, so they carry the tag; the unit tests prove the commands with a fake runner. A test whose tools are not
// installed where the check runs skips, loudly: the mesh's checker has git but not buildah until buildah is delivered
// to its node, so the rootless isolation of §2 and §6 is proven live after the delivery (the issue says so).
import (
"context"
"encoding/json"
"os"
"os/exec"
"os/user"
"path/filepath"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
func needTools(t *testing.T, what string, tools ...string) {
t.Helper()
for _, tool := range tools {
if _, err := exec.LookPath(tool); err != nil {
t.Skipf("NOT RUN: no %s installed where this check runs (%s); proven live on a build node", tool, what)
}
}
}
func git(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", dir, "-c", "user.name=t", "-c", "user.email=t@example.invalid", "-c", "commit.gpgsign=false"}, args...)...)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
return strings.TrimSpace(string(out))
}
// realModule is a module over the real runner, a local directory standing in for the forge and the account running the test.
func realModule(t *testing.T) (*Module, *keptStates) {
t.Helper()
states := newStates()
u, err := user.Current()
if err != nil {
t.Fatal(err)
}
home := t.TempDir()
m := &Module{Node: "ace", Account: u.Username, UID: u.Uid, Home: home, Work: t.TempDir(), Store: t.TempDir(),
Steps: states.putStep, Ctx: context.Background(), Say: t.Logf, Run: StepRunner(home),
FetchOptions: []buildstep.Option{buildstep.AllowFileRemote()}}
return m, states
}
// The fetch step on a real repository: the workspace holds the commit's tree, with the Dockerfile in it, and a base
// makes the merge preview.
func TestFetchOnARealRepository(t *testing.T) {
needTools(t, "the fetch step on a real repository", "git")
m, states := realModule(t)
root := t.TempDir()
bare, work := filepath.Join(root, "forge", "novox", "demo.git"), filepath.Join(root, "work")
for _, d := range []string{bare, work} {
os.MkdirAll(d, 0o755)
}
git(t, bare, "init", "-q", "--bare")
git(t, work, "init", "-q", "-b", "main")
os.MkdirAll(filepath.Join(work, "modules", "demo"), 0o755)
os.WriteFile(filepath.Join(work, "modules", "demo", "Dockerfile"), []byte("FROM scratch\n"), 0o644)
os.WriteFile(filepath.Join(work, "modules", "demo", "module.json"), []byte(`{"module":"demo","version":"1"}`), 0o644)
git(t, work, "add", ".")
git(t, work, "commit", "-q", "-m", "one")
git(t, work, "push", "-q", bare, "HEAD:refs/heads/main")
base := git(t, work, "rev-parse", "HEAD")
os.WriteFile(filepath.Join(work, "modules", "demo", "hello.txt"), []byte("hi\n"), 0o644)
git(t, work, "add", ".")
git(t, work, "commit", "-q", "-m", "two")
head := git(t, work, "rev-parse", "HEAD")
git(t, work, "push", "-q", bare, "HEAD:refs/pull/1/head")
m.RemoteBase = "file://" + filepath.Join(root, "forge") + "/"
if _, err := tool(t, m, FetchTool)(map[string]any{"repository": "novox/demo", "commit": head, "ref": "refs/pull/1/head", "base": base}); err != nil {
t.Fatal(err)
}
st := waitLong(t, states)
if st.State != delivery.StepPassed {
t.Fatalf("step = %+v", st)
}
var res FetchResult
if err := json.Unmarshal(st.Result, &res); err != nil {
t.Fatal(err)
}
if res.Commit != head || res.Base != base || res.Merge == "" || len(res.Conflicts) != 0 {
t.Errorf("result = %+v, want the head merged cleanly onto the base", res)
}
if _, err := os.Stat(filepath.Join(m.Work, res.Workspace, "tree", "modules", "demo", "Dockerfile")); err != nil {
t.Errorf("the Dockerfile is not in the workspace: %v", err)
}
}
func waitLong(t *testing.T, s *keptStates) delivery.Step {
t.Helper()
select {
case key := <-s.ended:
s.mu.Lock()
defer s.mu.Unlock()
v := s.steps[key]
return v[len(v)-1]
case <-time.After(5 * time.Minute):
t.Fatal("the step never ended on its key")
return delivery.Step{}
}
}
// imageModule writes a workspace "ws1" holding the module "modules/img" with one image artifact and this Dockerfile.
func imageModule(t *testing.T, m *Module, dockerfile string, files map[string]string) {
t.Helper()
dir := filepath.Join(m.Work, "ws1", "tree", "modules", "img")
os.MkdirAll(dir, 0o755)
os.WriteFile(filepath.Join(dir, "module.json"), []byte(`{"module":"img","version":"1","build":{"artifacts":[{"name":"server","kind":"image","from":"Dockerfile"}]}}`), 0o644)
os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte(dockerfile), 0o644)
for n, c := range files {
os.MkdirAll(filepath.Dir(filepath.Join(dir, n)), 0o755)
os.WriteFile(filepath.Join(dir, n), []byte(c), 0o644)
}
}
func buildReal(t *testing.T, m *Module, states *keptStates) (BuildResult, delivery.Step) {
t.Helper()
if _, err := tool(t, m, BuildTool)(map[string]any{"workspace": "ws1", "module": "modules/img", "artifact": "server"}); err != nil {
t.Fatal(err)
}
st := waitLong(t, states)
var res BuildResult
if st.State == delivery.StepPassed {
if err := json.Unmarshal(st.Result, &res); err != nil {
t.Fatal(err)
}
}
return res, st
}
// A Dockerfile that adds a file to scratch builds without any network, and the step answers the manifest digest.
func TestARealBuildAnswersTheImagesDigest(t *testing.T) {
needTools(t, "a rootless build", "buildah", "newuidmap")
m, states := realModule(t)
imageModule(t, m, "FROM scratch\nCOPY hello.txt /hello.txt\n", map[string]string{"hello.txt": "hello\n"})
res, st := buildReal(t, m, states)
if st.State != delivery.StepPassed || !digestOK.MatchString(res.Digest) {
t.Fatalf("step = %+v result = %+v", st, res)
}
}
// needBase skips unless the build node can pull the small base the isolation Dockerfiles run in.
func needBase(t *testing.T, m *Module) string {
t.Helper()
const base = "docker.io/library/busybox:1.36"
if err := os.MkdirAll(filepath.Join(m.Store, "xdg"), 0o700); err != nil {
t.Fatal(err)
}
r := m.Run(context.Background(), m.env(), "buildah", append(m.storeFlags(), "pull", base)...)
if r.Status != 0 {
t.Skipf("NOT RUN: the base %s cannot be pulled where this check runs (%s); proven live on a build node", base, firstLine(r.Stderr+r.Err))
}
return base
}
// ADR 0487 §2: the build's process has a non-zero user outside its namespace, no container runtime socket is among its
// mounts, and a step that tries to write outside its workspace fails.
func TestARealBuildIsRootlessWithNoSocketAndNoWayOut(t *testing.T) {
needTools(t, "the rootless isolation", "buildah", "newuidmap")
m, states := realModule(t)
base := needBase(t, m)
// The host side: the namespace the module makes maps the namespace's root to an id other than 0.
r := m.Run(context.Background(), m.env(), "buildah", append(m.storeFlags(), "unshare", "cat", "/proc/self/uid_map")...)
if f := strings.Fields(r.Stdout); r.Status != 0 || len(f) < 3 || f[0] != "0" || f[1] == "0" {
t.Errorf("uid_map in the module's namespace = %q (%s), want 0 mapped to a non-zero id", r.Stdout, r.Stderr)
}
// The inside: a RUN step sees root mapped to a non-zero outer id, and no runtime socket anywhere in its mounts.
imageModule(t, m, "FROM "+base+"\n"+
"RUN awk 'NR==1 { exit ($2 == 0) }' /proc/self/uid_map\n"+
"RUN ! ls /var/run/docker.sock /run/docker.sock /run/podman/podman.sock /run/containerd/containerd.sock 2>/dev/null\n"+
"RUN ! grep -E 'docker.sock|podman.sock|containerd.sock' /proc/self/mountinfo\n", nil)
if _, st := buildReal(t, m, states); st.State != delivery.StepPassed {
t.Errorf("the isolation checks inside the build failed: %+v", st)
}
// A step that writes outside its workspace fails: the context cannot be left, and the host's kernel files are not writable.
imageModule(t, m, "FROM "+base+"\nCOPY ../outside /outside\n", nil)
if _, st := buildReal(t, m, states); st.State != delivery.StepFailed {
t.Errorf("a COPY out of the context passed: %+v", st)
}
imageModule(t, m, "FROM "+base+"\nRUN echo b > /proc/sysrq-trigger\n", nil)
if _, st := buildReal(t, m, states); st.State != delivery.StepFailed {
t.Errorf("a write to the host's /proc/sysrq-trigger passed: %+v", st)
}
}
// ADR 0487 §5, §6: a step that looks for the account's credentials finds nothing: the key in the home's .ssh is in no
// mount of the build.
func TestARealBuildFindsNoCredentialsOfTheAccount(t *testing.T) {
needTools(t, "the credential paths", "buildah", "newuidmap")
m, states := realModule(t)
base := needBase(t, m)
os.MkdirAll(filepath.Join(m.Home, ".ssh"), 0o700)
os.WriteFile(filepath.Join(m.Home, ".ssh", "mesh_ed25519"), []byte("PRIVATE\n"), 0o600)
imageModule(t, m, "FROM "+base+"\n"+
"RUN ! find / -xdev -name mesh_ed25519 2>/dev/null | grep .\n"+
"RUN ! test -e /root/.ssh\n"+
"RUN ! env | grep -E 'REGISTRY_AUTH|MESH_|SSH_AUTH'\n", nil)
if _, st := buildReal(t, m, states); st.State != delivery.StepPassed {
t.Errorf("a step found a credential path of the account: %+v", st)
}
}
@@ -0,0 +1,42 @@
package main
// The module's own forge key (novox/hq ADR 0466 §2, issue 1484): mesh-sdk's key keeper, kind builder, for the
// module's account in its home. It makes ~/.ssh/mesh_ed25519 when it is missing and writes one entry to this
// module's `keys` state at <node>.<account>, which gitea's provider reads to register the key on the forge user
// that reads and pushes nothing (ADR 0466 §3).
import (
"context"
"fmt"
"os"
"os/user"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// keeperFor is this module's keeper: go/forgekey's, of kind builder, for account in home on node, whose entry lies
// at <node>.<account> of the keys state.
func keeperFor(home, node, account string, run forgekey.Runner, state forgekey.KeyPutter) *forgekey.KeyKeeper {
return &forgekey.KeyKeeper{Home: home, Node: node, Account: account, Kind: forgekey.KindBuilder, Run: run, State: state, Now: time.Now}
}
// startKeeper runs the keeper when the runtime launched this process for a node: outside a launch — a build, a
// check — there is no state to put the entry in.
func startKeeper(ctx context.Context) {
node := strings.TrimSpace(os.Getenv("MESH_NODE"))
u, err := user.Current()
if node == "" || err != nil || u.HomeDir == "" {
return
}
k := keeperFor(u.HomeDir, node, u.Username, forgekey.ExecRunner, keysState{})
k.Keep(ctx, func(f string, a ...any) { fmt.Fprintf(os.Stderr, "[mesh-build-image] "+f+"\n", a...) })
}
type keysState struct{}
func (keysState) Put(key string, value any) (uint64, error) {
return stdio.State("keys").Put(key, value)
}
@@ -0,0 +1,54 @@
// mesh-build-image: the image build module (novox/hq ADR 0487, ADR 0466 §1, ADR 0469; design 47 R4c-9, issue 1703). A
// tools bundle the node's runtime launches as the module's own account (runs-as). It builds a module's `image`
// artifact from the Dockerfile in the module's own directory with buildah, rootless, in a user namespace of its
// account and with no container runtime socket, and pushes a merged trunk commit's image to the registry from its
// own process once the build has ended. It refuses a step only when buildah cannot be run or the account has no
// subordinate ranges, and its health is then unhealthy. What it says, it says on stderr; stdout is the MCP channel.
package main
import (
"context"
"fmt"
"os"
"os/user"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// The directories the module's manifest places outside its home (ADR 0466 §1): the workspaces, and buildah's image
// store, so nothing of the home is in a build.
const (
WorkDir = "/var/lib/mesh-build-image-work"
StoreDir = "/var/lib/mesh-build-image-store"
)
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, "[mesh-build-image] "+format+"\n", a...) }
func main() {
ctx := context.Background()
m := &Module{
Node: strings.TrimSpace(os.Getenv("MESH_NODE")),
Steps: stdio.State(delivery.StepsState).Put,
ReadSteps: readOf(stdio.State(delivery.StepsState)),
Ctx: ctx,
Say: logf,
Work: WorkDir,
Store: StoreDir,
}
if u, err := user.Current(); err == nil {
m.Home, m.Account, m.UID = u.HomeDir, u.Username, u.Uid
}
m.RegistryFile = m.Home + "/registry"
m.Run = StepRunner(m.Home)
// Outside a launch by the runtime — a build, a check — there is no node and no state to write.
if m.Node != "" {
go m.Start(ctx)
}
go startKeeper(ctx)
if err := stdio.Serve("", m.Tools(m.StepTools())); err != nil {
logf("%v", err)
os.Exit(1)
}
}
@@ -0,0 +1,191 @@
package main
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// The catalogue's root and this module's directory.
const (
catalogueRoot = "../../../.."
moduleDir = "../.."
)
func readManifest(t *testing.T) catalogue.Manifest {
t.Helper()
m, err := catalogue.ReadTreeManifest(catalogueRoot, moduleDir)
if err != nil {
t.Fatalf("mesh-sdk's validator refuses module.json: %v", err)
}
return m
}
func resource(t *testing.T, m catalogue.Manifest, id string) map[string]any {
t.Helper()
for _, r := range m.Resources {
if r["id"] == id {
return r
}
}
t.Fatalf("module.json declares no resource %q", id)
return nil
}
// 1703a: the validator accepts the module (alone on a shelf too), it runs as mesh-build-image with its home at mode
// 0700, lists user-namespaces, and declares git and openssh; buildah is not its package (the buildah module's alone).
func TestManifestIsValidAndDeclaresTheAccountTheCapabilityAndThePackages(t *testing.T) {
m := readManifest(t)
if problems := catalogue.CatalogueProblems(catalogue.Shelf{m.Module: m}); len(problems) > 0 {
t.Errorf("the catalogue's rules refuse it: %v", problems)
}
if m.Module != "mesh-build-image" || m.RunsAs != "mesh-build-image" {
t.Errorf("module %q runs as %q, want mesh-build-image as itself", m.Module, m.RunsAs)
}
account := resource(t, m, "account")
if account["type"] != "user" || account["name"] != "mesh-build-image" || account["shell"] != "/usr/bin/nologin" || account["home"] != "/var/lib/mesh-build-image" {
t.Errorf("account = %v", account)
}
home := resource(t, m, "state")
if home["type"] != "directory" || home["mode"] != "0700" || home["owner"] != "mesh-build-image" || home["place"] != "." {
t.Errorf("home = %v, want its directory at mode 0700 owned by the account", home)
}
for _, c := range []string{"user-namespaces", "package-manager"} {
if !slices.Contains(m.Capabilities, c) {
t.Errorf("capabilities = %v, want %s", m.Capabilities, c)
}
}
packages := []string{}
for _, r := range m.Resources {
if r["type"] == "package" {
packages = append(packages, r["package"].(string))
}
}
slices.Sort(packages)
if want := []string{"git", "openssh"}; !slices.Equal(packages, want) {
t.Errorf("packages = %v, want %v (buildah is the buildah module's, ADR 0487 §3)", packages, want)
}
}
// The work and store directories are outside the home (ADR 0466 §1), so nothing of the home is in a build; owned by the
// account at mode 0700; and the constants of main.go are the manifest's paths.
func TestWorkAndStoreDirectoriesLieOutsideTheHome(t *testing.T) {
m := readManifest(t)
for id, want := range map[string]string{"work": WorkDir, "store": StoreDir} {
r := resource(t, m, id)
path, _ := r["path"].(string)
if path != want {
t.Errorf("%s is at %q, the program uses %q", id, path, want)
}
if path == "" || path == "/var/lib/mesh-build-image" || strings.HasPrefix(path, "/var/lib/mesh-build-image/") {
t.Errorf("%s is at %q, inside or at the home", id, path)
}
if r["owner"] != "mesh-build-image" || r["mode"] != "0700" {
t.Errorf("%s = %v, want owned by the account at mode 0700", id, r)
}
}
}
// The states are the two the module keeps, no seat is claimed, the health is its own tool, which the manifest also lists,
// and the registry's address is bound into a file of the home that the program reads.
func TestManifestKeepsItsStatesClaimsNoSeatAndDeclaresItsHealthTool(t *testing.T) {
m := readManifest(t)
states := []string{}
for _, s := range m.State {
states = append(states, s.Name)
}
slices.Sort(states)
if want := []string{"keys", "steps"}; !slices.Equal(states, want) {
t.Errorf("state = %v, want %v", states, want)
}
if len(m.Claims) != 0 {
t.Errorf("claims = %v, want no seat", m.Claims)
}
raw, _ := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if !strings.Contains(string(raw), `"tool": "`+HealthTool+`"`) {
t.Errorf("runtime-health does not name %s", HealthTool)
}
reg := resource(t, m, "registry")
if reg["path"] != "${dir:state}/registry" {
t.Errorf("registry file = %v, want ${dir:state}/registry (the program reads <home>/registry)", reg)
}
tpl, _ := os.ReadFile(filepath.Join(moduleDir, "files", "registry"))
if strings.TrimSpace(string(tpl)) != "${bound:artifact-store:at}:${bound:artifact-store:port}" {
t.Errorf("registry template = %q", tpl)
}
}
// Every tool the manifest names is served, and every tool served is named.
func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
m := readManifest(t)
served := []string{}
for _, tl := range (&Module{}).Tools((&Module{}).StepTools()) {
served = append(served, tl.Name)
}
slices.Sort(served)
named := slices.Clone(m.Tools)
slices.Sort(named)
if !slices.Equal(served, named) {
t.Errorf("served %v, manifest names %v", served, named)
}
}
// 1703f: gitea's manifest reads the keys state of mesh-build-image beside the other build modules', and the catalogue's
// validator accepts it with the owners present.
func TestGiteaReadsTheImageBuildModulesKeys(t *testing.T) {
var all []catalogue.Manifest
var gitea catalogue.Manifest
for _, name := range []string{"gitea", "mesh-build-go", "mesh-build-node", "mesh-build-image"} {
m, err := catalogue.ReadTreeManifest(catalogueRoot, filepath.Join(catalogueRoot, "modules", name))
if err != nil {
t.Fatalf("mesh-sdk's validator refuses modules/%s/module.json: %v", name, err)
}
all = append(all, m)
if name == "gitea" {
gitea = m
}
}
for _, want := range []string{"mesh-build-go.keys", "mesh-build-node.keys", "mesh-build-image.keys"} {
if !slices.Contains(gitea.Reads, want) {
t.Errorf("gitea reads %v, lacks %s", gitea.Reads, want)
}
}
if problems := catalogue.StateReadsNothingDeclares(all); len(problems) > 0 {
t.Errorf("a read names a state its owner does not keep: %v", problems)
}
}
// 1704b: over the real manifests, mesh-build-image where no module provides rootless-image-build is refused, the error
// naming the provision, the module that wants it and the module to assign (buildah); with buildah it composes. The
// forge and the registry, which other modules provide for the whole mesh, are stood in for.
func TestACompositionWithoutBuildahIsRefusedNamingItAndWithBuildahComposes(t *testing.T) {
shelf := map[string]catalogue.Manifest{
"forge": {Module: "forge", Version: "1", Provides: []catalogue.Offer{{Name: "forge-account"}}},
"store": {Module: "store", Version: "1", Provides: []catalogue.Offer{{Name: "artifact-store"}}},
}
for _, name := range []string{"mesh-build-image", "buildah", "docker"} {
m, err := catalogue.ReadTreeManifest(catalogueRoot, filepath.Join(catalogueRoot, "modules", name))
if err != nil {
t.Fatalf("modules/%s: %v", name, err)
}
shelf[m.Module] = m
}
node := catalogue.Node{Name: "builder", Site: "house",
Capabilities: map[string]bool{"package-manager": true, "user-namespaces": true}}
_, err := catalogue.Resolve(shelf, []string{"forge", "store", "docker", "mesh-build-image"}, node, catalogue.World{})
if err == nil {
t.Fatal("mesh-build-image composed on a machine without buildah (docker does not stand in for it)")
}
for _, want := range []string{`"rootless-image-build" is wanted by mesh-build-image`, "buildah"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the refusal does not say %q:\n%v", want, err)
}
}
if _, err := catalogue.Resolve(shelf, []string{"forge", "store", "buildah", "mesh-build-image"}, node, catalogue.World{}); err != nil {
t.Errorf("with buildah assigned the composition is refused: %v", err)
}
}
@@ -0,0 +1,149 @@
package main
import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"time"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/forgekey"
"git.novox.be/novox/mesh-sdk/go/health"
)
// CheckRound is how often the module looks at itself again, beside the look before every step and at every health look.
const CheckRound = 10 * time.Minute
// Module is the image build module's one running state.
type Module struct {
Node string
// Account and UID are the account the module runs as; Home is its home.
Account, UID, Home string
// Steps is the Put of the steps state; ReadSteps reads it back for the read tools.
Steps buildstep.Put
ReadSteps StateRead
// Ctx is the process's context, which a step runs under (the tool call's is cut at 30 s, the step's is not).
Ctx context.Context
Say func(format string, a ...any)
// Run runs the commands of a step (StepRunner); Work holds the workspaces and Store buildah's image store, both
// outside the account's home (ADR 0466 §1).
Run forgekey.Runner
Work, Store string
// SubUIDFile and SubGIDFile are the subordinate id files; /etc/subuid and /etc/subgid when empty.
SubUIDFile, SubGIDFile string
// RegistryFile holds the registry's address, host:port, which the manifest binds from the artifact-store provision.
RegistryFile string
// RemoteBase is where repositories are fetched from, "ssh://mesh-forge/" when empty; FetchOptions are the options of
// the SDK's fetch and merge. A test points them at a local repository (buildstep.AllowFileRemote).
RemoteBase string
FetchOptions []buildstep.Option
// HTTP talks to the registry; a client of its own when nil. RegistryWait and RegistryPause bound the wait for a
// registry held still (RegistryWait and RegistryFirstPause when zero).
HTTP *http.Client
RegistryWait time.Duration
RegistryPause time.Duration
// Now is the clock; time.Now when nil.
Now func() time.Time
}
// Look is what the module found out about its machine.
type Look struct {
Buildah string
UIDs, GIDs int
Account string
}
// look proves the machine: buildah runs, the account holds ranges, a user namespace can be made. The first thing
// missing is a *buildstep.Refusal naming it, so a step ends refused and the health says the same.
func (m *Module) look(ctx context.Context) (Look, error) {
l := Look{Account: m.Account}
var err error
if l.Buildah, err = m.buildahVersion(ctx); err != nil {
return l, &buildstep.Refusal{Text: err.Error()}
}
if l.UIDs, l.GIDs, err = m.ranges(); err != nil {
return l, &buildstep.Refusal{Text: err.Error(), Have: l.Buildah}
}
for _, d := range []string{m.Store, filepath.Join(m.Store, "xdg")} {
if err := os.MkdirAll(d, 0o700); err != nil {
return l, err
}
}
if err := m.userNamespace(ctx); err != nil {
return l, &buildstep.Refusal{Text: err.Error(), Have: l.Buildah}
}
return l, nil
}
// Check is the guard: nil when the machine can build, else the refusal.
func (m *Module) Check(ctx context.Context) error {
_, err := m.look(ctx)
return err
}
// Start looks once, as the module starts, and then every CheckRound until ctx ends; it also removes old workspaces.
func (m *Module) Start(ctx context.Context) {
for {
if err := m.Check(ctx); err != nil && m.Say != nil {
m.Say("this machine cannot build images: %v", err)
}
m.CleanWorkspaces(m.clock())
select {
case <-ctx.Done():
return
case <-time.After(CheckRound):
}
}
}
// Health is the module's health (the runtime-health tool, ADR 0366): unhealthy while it cannot build, saying why,
// healthy it names the buildah installed and the account's ranges (ADR 0487 §2: the ranges are proven here, not declared).
func (m *Module) Health(ctx context.Context) health.Answer {
l, err := m.look(ctx)
if err != nil {
return health.Unhealthy(err.Error())
}
return health.Healthy(fmt.Sprintf("buildah %s installed; %s holds %d subordinate uids and %d gids; a user namespace can be made", l.Buildah, l.Account, l.UIDs, l.GIDs))
}
// Status is what the module reports of itself.
func (m *Module) Status(ctx context.Context) map[string]any {
l, err := m.look(ctx)
out := map[string]any{"node": m.Node, "account": m.Account, "buildah": l.Buildah, "subuids": l.UIDs, "subgids": l.GIDs, "refusing": err != nil}
if err != nil {
out["why"] = err.Error()
}
return out
}
// Long is a tool that runs a long step (ADR 0469 §2): it answers at once with the step's id, and the step ends on its
// key in the steps state. The guard runs inside the step, before run, so a machine that cannot build ends every step
// refused with the cause named.
func (m *Module) Long(tool string, run func(ctx context.Context, args map[string]any) (any, error)) func(map[string]any) (any, error) {
return func(args map[string]any) (any, error) {
id, err := buildstep.Start(m.Ctx, m.Steps, m.Node, tool, func(ctx context.Context) (any, error) {
if err := m.Check(ctx); err != nil {
return nil, err
}
return run(ctx, args)
})
if err != nil {
return nil, err
}
key, _ := delivery.StepKey(m.Node, id)
return map[string]any{"step": id, "key": key, "state": delivery.StepRunning}, nil
}
}
// Short is a tool that answers in its own call; the guard runs before it, and a refusal is the call's error.
func (m *Module) Short(run func(ctx context.Context, args map[string]any) (any, error)) func(map[string]any) (any, error) {
return func(args map[string]any) (any, error) {
if err := m.Check(m.Ctx); err != nil {
return nil, err
}
return run(m.Ctx, args)
}
}
@@ -0,0 +1,118 @@
package main
import (
"context"
"os"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/forgekey"
"git.novox.be/novox/mesh-sdk/go/health"
)
// 1703a: a machine where buildah runs, the account holds ranges in both files and a namespace can be made is healthy,
// and the health names buildah and the ranges (ADR 0487 §2: proven, not declared).
func TestHealthIsHealthyWithBuildahRangesAndANamespace(t *testing.T) {
f := newFake(t)
m := moduleWith(t, f, newStates())
a := m.Health(context.Background())
if a.State != health.StateHealthy || !a.Healthy {
t.Fatalf("health = %+v, want healthy", a)
}
for _, want := range []string{"buildah 1.45.1", "mesh-build-image holds 65536 subordinate uids and 65536 gids"} {
if !strings.Contains(a.Why, want) {
t.Errorf("health says %q, lacks %q", a.Why, want)
}
}
if _, err := os.Stat(m.Store + "/xdg"); err != nil {
t.Errorf("the runtime directory inside the store was not made: %v", err)
}
}
func TestHealthIsUnhealthyWithoutBuildah(t *testing.T) {
f := newFake(t)
m := moduleWith(t, f, newStates())
m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
return forgekey.Ran{Status: 127, Err: "exec: \"buildah\": executable file not found in $PATH"}
}
a := m.Health(context.Background())
if a.Healthy || !strings.Contains(a.Why, "buildah") {
t.Errorf("health = %+v, want unhealthy naming buildah", a)
}
}
// The ranges are read from the two files, one each: without one in /etc/subuid or in /etc/subgid the account has no ids to
// map, and the health says which file.
func TestHealthIsUnhealthyWithoutARangeInEitherFile(t *testing.T) {
for file, set := range map[string]func(*Module, string){
"subuid": func(m *Module, p string) { m.SubUIDFile = p },
"subgid": func(m *Module, p string) { m.SubGIDFile = p },
} {
f := newFake(t)
m := moduleWith(t, f, newStates())
p := t.TempDir() + "/" + file
os.WriteFile(p, []byte("someone-else:100000:65536\n"), 0o644)
set(m, p)
a := m.Health(context.Background())
if a.Healthy || !strings.Contains(a.Why, p) || !strings.Contains(a.Why, "mesh-build-image") {
t.Errorf("%s: health = %+v, want unhealthy naming %s and the account", file, a, p)
}
}
}
func TestHealthIsUnhealthyWhenANamespaceCannotBeMade(t *testing.T) {
f := newFake(t)
m := moduleWith(t, f, newStates())
run := m.Run
m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
if len(args) >= 2 && args[len(args)-2] == "unshare" {
return forgekey.Ran{Status: 125, Stderr: "Error: cannot clone: Operation not permitted\n"}
}
return run(ctx, env, name, args...)
}
a := m.Health(context.Background())
if a.Healthy || !strings.Contains(a.Why, "user namespace") {
t.Errorf("health = %+v, want unhealthy naming the user namespace", a)
}
}
func TestSubRangeSumsTheAccountsLinesByNameOrUid(t *testing.T) {
p := t.TempDir() + "/subuid"
os.WriteFile(p, []byte("a:1:10\nmesh-build-image:100:65536\n1234:200000:100\nb:5:5\nbad line\n"), 0o644)
if n, err := SubRange(p, "mesh-build-image", "1234"); err != nil || n != 65636 {
t.Errorf("range = %d, %v, want 65636", n, err)
}
if n, _ := SubRange(p, "nobody", ""); n != 0 {
t.Errorf("nobody has %d", n)
}
}
// A step on a machine that cannot build ends refused with the cause named, and nothing else runs.
func TestAStepOnAMachineThatCannotBuildEndsRefused(t *testing.T) {
f := newFake(t)
states := newStates()
m := moduleWith(t, f, states)
m.SubGIDFile = "/nonexistent/subgid"
if _, err := tool(t, m, FetchTool)(map[string]any{"repository": "novox/mesh-catalog", "commit": commitSHA}); err != nil {
t.Fatal(err)
}
st := states.waitEnd(t)
if st.State != delivery.StepRefused || !strings.Contains(st.Reason, "/nonexistent/subgid") {
t.Errorf("step = %+v, want refused naming the file", st)
}
for _, l := range f.lines() {
if strings.HasPrefix(l, "git ") {
t.Errorf("a refused step ran %q", l)
}
}
}
func TestStatusSaysWhatTheMachineHas(t *testing.T) {
f := newFake(t)
m := moduleWith(t, f, newStates())
s := m.Status(context.Background())
if s["refusing"] != false || s["buildah"] != "1.45.1" || s["subuids"] != 65536 || s["account"] != "mesh-build-image" {
t.Errorf("status = %v", s)
}
}
@@ -0,0 +1,116 @@
package main
// What the module proves about its own machine before it builds (novox/hq ADR 0487 §2, issue 1703a): buildah runs,
// the account holds subordinate user and group ranges (the node-engine's useradd allocates them for every `user`
// account, so nothing declares them; this reads them), and a user namespace can be made.
import (
"bufio"
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// ProbeTimeout bounds each probe: they read the machine and fetch nothing.
const ProbeTimeout = 20 * time.Second
// Default subordinate id files.
const (
SubUIDFile = "/etc/subuid"
SubGIDFile = "/etc/subgid"
)
// buildahVersion runs `buildah --version` and answers the version, the third word of "buildah version 1.45.1 (...)".
func (m *Module) buildahVersion(ctx context.Context) (string, error) {
ctx, cancel := context.WithTimeout(ctx, ProbeTimeout)
defer cancel()
r := m.Run(ctx, m.env(), "buildah", "--version")
if r.Status != 0 || r.Err != "" {
return "", fmt.Errorf("`buildah --version` failed (is the module buildah assigned to this machine?): %s", firstLine(r.Err+"\n"+r.Stderr))
}
f := strings.Fields(r.Stdout)
if len(f) < 3 || f[0] != "buildah" || f[1] != "version" {
return "", fmt.Errorf("`buildah --version` answered %q", strings.TrimSpace(r.Stdout))
}
return f[2], nil
}
// userNamespace makes a user namespace as the account does for a build and runs `true` in it.
func (m *Module) userNamespace(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, ProbeTimeout)
defer cancel()
r := m.Run(ctx, m.env(), "buildah", append(m.storeFlags(), "unshare", "true")...)
if r.Status != 0 || r.Err != "" {
return fmt.Errorf("a user namespace cannot be made: %s", firstLine(r.Err+"\n"+r.Stderr))
}
return nil
}
// SubRange is how many subordinate ids file lists for the account (by name or by numeric uid): the sum of its lines'
// counts, zero when it has none.
func SubRange(file, account, uid string) (int, error) {
f, err := os.Open(file)
if err != nil {
return 0, err
}
defer f.Close()
total := 0
sc := bufio.NewScanner(f)
for sc.Scan() {
parts := strings.Split(strings.TrimSpace(sc.Text()), ":")
if len(parts) != 3 || (parts[0] != account && (uid == "" || parts[0] != uid)) {
continue
}
if n, err := strconv.Atoi(parts[2]); err == nil && n > 0 {
total += n
}
}
return total, sc.Err()
}
func (m *Module) subFiles() (uid, gid string) {
uid, gid = m.SubUIDFile, m.SubGIDFile
if uid == "" {
uid = SubUIDFile
}
if gid == "" {
gid = SubGIDFile
}
return
}
// ranges reads the account's subordinate ranges, or says which file lacks one.
func (m *Module) ranges() (uids, gids int, err error) {
uf, gf := m.subFiles()
for _, c := range []struct {
file string
n *int
}{{uf, &uids}, {gf, &gids}} {
n, rerr := SubRange(c.file, m.Account, m.UID)
if rerr != nil {
return 0, 0, fmt.Errorf("%s cannot be read: %v", c.file, rerr)
}
if n == 0 {
return 0, 0, fmt.Errorf("%s holds no subordinate range for the account %s: a build in a user namespace has no ids to map (the node-engine's useradd allocates them when it makes a user account)", c.file, m.Account)
}
*c.n = n
}
return uids, gids, nil
}
// storeFlags are buildah's global flags that put its image store and its runtime state in the module's store
// directory, outside the account's home.
func (m *Module) storeFlags() []string {
return []string{"--root", filepath.Join(m.Store, "root"), "--runroot", filepath.Join(m.Store, "run")}
}
// env is what buildah is given beyond the base environment: a runtime directory of its own inside the store, so
// nothing is read from the account's runtime directory, and no registry credential file anywhere (the module has none
// and REGISTRY_AUTH_FILE is never set).
func (m *Module) env() []string {
return []string{"XDG_RUNTIME_DIR=" + filepath.Join(m.Store, "xdg")}
}
@@ -0,0 +1,149 @@
package main
// The trunk push (novox/hq ADR 0487 §5; issue 1703d): after the build has ended, from the module's own process and not
// from a Dockerfile step, the image is pushed to the registry under a tag of its own and the digest is read back from
// the registry, which is the only one that can say what it serves for the reference (issue 1742). The push and the
// read wait out a registry held still by the store's nightly collection (issue 457).
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"syscall"
"time"
)
// RegistryWait is how long a call waits for a registry that refuses connections, longer than the nightly collection
// holds it still (about a minute and a half); RegistryFirstPause is the first pause, doubling to RegistryMostPause.
const (
RegistryWait = 5 * time.Minute
RegistryFirstPause = time.Second
RegistryMostPause = 10 * time.Second
)
// registry is the registry's address, host:port, as the manifest bound it into RegistryFile.
func (m *Module) registry() (string, error) {
raw, err := os.ReadFile(m.RegistryFile)
if err != nil {
return "", fmt.Errorf("the registry's address cannot be read: %w", err)
}
addr := strings.TrimSpace(string(raw))
if addr == "" || strings.Contains(addr, "${") || strings.HasPrefix(addr, ":") || strings.HasSuffix(addr, ":") {
return "", fmt.Errorf("the registry's address in %s is %q, not host:port", m.RegistryFile, addr)
}
return addr, nil
}
func (m *Module) push(ctx context.Context, workspace, module, local string, res BuildResult, tag string) (any, error) {
addr, err := m.registry()
if err != nil {
return nil, err
}
repository := module + "/" + res.Artifact
dest := addr + "/" + repository + ":" + tag
digestFile := filepath.Join(m.Work, workspace, "pushed."+tag)
argv := append(m.storeFlags(), "push", "--tls-verify=false", "--digestfile", digestFile, local, "docker://"+dest)
if err := m.waitForRegistry(ctx, addr, "buildah push "+dest, func() error {
r := m.Run(ctx, m.env(), "buildah", argv...)
if r.Status != 0 || r.Err != "" {
return errors.New(lastLine(r.Err + "\n" + r.Stderr + "\n" + r.Stdout))
}
return nil
}); err != nil {
return nil, fmt.Errorf("pushing %s: %w", dest, err)
}
var served string
if err := m.waitForRegistry(ctx, addr, "reading "+dest, func() error {
var err error
served, err = m.registryDigest(ctx, addr, repository, tag)
return err
}); err != nil {
return nil, fmt.Errorf("pushed %s and cannot read back what the registry serves for it: %w", dest, err)
}
if raw, err := os.ReadFile(digestFile); err == nil {
if pushed := strings.TrimSpace(string(raw)); pushed != "" && pushed != served {
return nil, fmt.Errorf("pushed %s as %s, but the registry serves %s for it", dest, pushed, served)
}
}
res.Digest, res.Pushed = served, true
res.Reference = addr + "/" + repository + "@" + served
return res, nil
}
// registryDigest is the digest the registry answers for repository:tag (Docker-Content-Digest of a HEAD).
func (m *Module) registryDigest(ctx context.Context, addr, repository, tag string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodHead, "http://"+addr+"/v2/"+repository+"/manifests/"+tag, nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", strings.Join([]string{
"application/vnd.oci.image.manifest.v1+json", "application/vnd.oci.image.index.v1+json",
"application/vnd.docker.distribution.manifest.v2+json", "application/vnd.docker.distribution.manifest.list.v2+json"}, ", "))
client := m.HTTP
if client == nil {
client = &http.Client{Timeout: time.Minute}
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("the registry answered %s for %s:%s", resp.Status, repository, tag)
}
d := resp.Header.Get("Docker-Content-Digest")
if !digestOK.MatchString(d) {
return "", fmt.Errorf("the registry named no digest for %s:%s (Docker-Content-Digest %q)", repository, tag, d)
}
return d, nil
}
// refused is whether an error is a connection refused: from a dial here, or as buildah said it in its output.
func refused(err error) bool {
return err != nil && (errors.Is(err, syscall.ECONNREFUSED) || strings.Contains(err.Error(), "connection refused"))
}
// waitForRegistry runs try, and while it fails because the registry at addr refuses connections, tries again with a
// growing pause until RegistryWait has passed (novox/hq issue 457). A refusal is the one error waited for: nothing
// was sent, so trying again does nothing twice. what names the call, for what the module says.
func (m *Module) waitForRegistry(ctx context.Context, addr, what string, try func() error) error {
err := try()
if !refused(err) {
return err
}
limit, pause := m.RegistryWait, m.RegistryPause
if limit == 0 {
limit = RegistryWait
}
if pause == 0 {
pause = RegistryFirstPause
}
started := time.Now()
m.say("%s: refused; waiting for the registry at %s for up to %s — the store's nightly collection holds it still for about a minute and a half (issue 457)", what, addr, limit)
for {
left := limit - time.Since(started)
if left <= 0 {
return fmt.Errorf("the registry at %s refused every connection for %s, longer than its nightly collection holds it still, so it is down, not paused: %w", addr, time.Since(started).Round(time.Millisecond), err)
}
select {
case <-ctx.Done():
return fmt.Errorf("stopped while waiting for the registry at %s: %w (last: %v)", addr, ctx.Err(), err)
case <-time.After(min(pause, left)):
}
pause = min(pause*2, RegistryMostPause)
if err = try(); !refused(err) {
m.say("%s: the registry at %s answers again after %s", what, addr, time.Since(started).Round(time.Millisecond))
return err
}
}
}
func (m *Module) say(format string, a ...any) {
if m.Say != nil {
m.Say(format, a...)
}
}
@@ -0,0 +1,143 @@
package main
import (
"context"
"os"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// 1703d/AC (ADR 0487 §5): a candidate's build leaves the registry unchanged: nothing is pushed, nothing is asked of it.
func TestACandidatesBuildLeavesTheRegistryUnchanged(t *testing.T) {
m, f, states := setup(t, same)
res, st := runBuild(t, m, states, nil) // no publish
if st.State != delivery.StepPassed || res.Pushed {
t.Fatalf("step = %+v result = %+v", st, res)
}
if len(f.called("push")) != 0 || len(f.registry) != 0 || f.pushes != 0 {
t.Errorf("a candidate's build reached the registry: pushes %d, registry %v, calls %v", f.pushes, f.registry, f.lines())
}
}
// 1703d/AC: a trunk build adds exactly one digest, under <module>/<artifact> and a tag of its own (the digest's first 12
// hex digits), after the build has ended, with TLS verification off (plain HTTP) and no credential; the key carries the
// digest the registry serves and the reference pinned by it.
func TestATrunkBuildPushesOnceAndAnswersTheRegistrysDigest(t *testing.T) {
m, f, states := setup(t, same)
res, st := runBuild(t, m, states, map[string]any{"publish": true})
if st.State != delivery.StepPassed {
t.Fatalf("step = %+v", st)
}
tag := "111111111111"
if len(f.registry) != 1 || f.registry["fixture-image/server:"+tag] != imageDigest || f.pushes != 1 {
t.Fatalf("registry = %v after %d pushes, want exactly fixture-image/server:%s", f.registry, f.pushes, tag)
}
if !res.Pushed || res.Digest != imageDigest || res.Reference != f.address()+"/fixture-image/server@"+imageDigest {
t.Errorf("result = %+v", res)
}
push := f.called("push")
if len(push) != 1 || !strings.Contains(push[0].line, "--tls-verify=false") || !strings.Contains(push[0].line, "docker://"+f.address()+"/fixture-image/server:"+tag) {
t.Fatalf("push = %v", push)
}
for _, bad := range []string{"--creds", "--authfile", "--sign"} {
if strings.Contains(push[0].line, bad) {
t.Errorf("the push carries %s", bad)
}
}
// The push comes after the build has ended: bud, then images, then push.
order := []string{}
for _, l := range f.lines() {
for _, v := range []string{" bud ", " images ", " push ", " rmi "} {
if strings.Contains(l, "buildah") && strings.Contains(l, v) {
order = append(order, strings.TrimSpace(v))
}
}
}
if strings.Join(order, ",") != "bud,images,push,rmi" {
t.Errorf("order = %v", order)
}
}
// A tag the call gives is the one used; one that is not a tag is refused.
func TestATagOfTheCallsOwn(t *testing.T) {
m, f, states := setup(t, same)
if _, st := runBuild(t, m, states, map[string]any{"publish": true, "tag": "fp-abc123"}); st.State != delivery.StepPassed {
t.Fatalf("step = %+v", st)
}
if _, ok := f.registry["fixture-image/server:fp-abc123"]; !ok || len(f.registry) != 1 {
t.Errorf("registry = %v", f.registry)
}
m2, f2, states2 := setup(t, same)
if _, st := runBuild(t, m2, states2, map[string]any{"publish": true, "tag": "a/b:c"}); st.State != delivery.StepFailed || len(f2.called("bud")) != 0 {
t.Errorf("step = %+v", st)
}
}
// issue 457: a registry held still (connection refused) is waited for, and the push then goes through once.
func TestAPushWaitsForARegistryHeldStill(t *testing.T) {
m, f, states := setup(t, same)
f.refuse = 2
res, st := runBuild(t, m, states, map[string]any{"publish": true})
if st.State != delivery.StepPassed || !res.Pushed {
t.Fatalf("step = %+v result = %+v", st, res)
}
if f.pushes != 1 || len(f.registry) != 1 || len(f.called("push")) != 3 {
t.Errorf("pushes %d, registry %v, push calls %d; want two refused and one made", f.pushes, f.registry, len(f.called("push")))
}
}
// A registry that stays down past the wait fails the step saying how long it was refused; nothing is claimed pushed.
func TestARegistryDownPastTheWaitFailsTheStep(t *testing.T) {
m, f, states := setup(t, same)
f.refuse = 1 << 20
m.RegistryWait = 50 * time.Millisecond
_, st := runBuild(t, m, states, map[string]any{"publish": true})
if st.State != delivery.StepFailed || !strings.Contains(st.Reason, "refused every connection") {
t.Errorf("step = %+v", st)
}
if len(f.registry) != 0 {
t.Errorf("registry = %v", f.registry)
}
}
// The digest the registry serves is the answer: when it differs from the one the push reported, the step fails.
func TestAnAnswerThatDisagreesWithThePushFails(t *testing.T) {
m, f, states := setup(t, same)
run := m.Run
m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
r := run(ctx, env, name, args...)
if name == "buildah" && strings.Contains(strings.Join(args, " "), " push ") {
f.mu.Lock()
for k := range f.registry {
f.registry[k] = "sha256:2222222222222222222222222222222222222222222222222222222222222222"
}
f.mu.Unlock()
}
return r
}
_, st := runBuild(t, m, states, map[string]any{"publish": true})
if st.State != delivery.StepFailed || !strings.Contains(st.Reason, "the registry serves") {
t.Errorf("step = %+v", st)
}
}
func TestTheRegistrysAddressMustBeBound(t *testing.T) {
for _, bad := range []string{"", "${bound:artifact-store:at}:5000", ":5000"} {
m, _, _ := setup(t, same)
writeFile(t, m.RegistryFile, bad)
if _, err := m.registry(); err == nil {
t.Errorf("%q was taken as the registry's address", bad)
}
}
}
func writeFile(t *testing.T, path, body string) {
t.Helper()
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
@@ -0,0 +1,164 @@
package main
// The module's read tools (novox/hq issue 1562): what the module wrote to its own `steps` and `toolchains` states,
// answered as stored. A long step ends on its key in the steps state (ADR 0469 §2) and its result, such as the
// workspace a fetch made, is only there; a module reading its own bucket needs no grant.
import (
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// The read tools, named as the module is: build_image_ and the verb.
const (
StepReadTool = "build_image_step"
StepsReadTool = "build_image_steps"
)
// StateRead reads one of the module's own states: its keys and a key's stored value (nil for a key with none).
type StateRead struct {
Keys func() ([]string, error)
Get func(key string) (json.RawMessage, error)
}
// readOf is the read of a state the process keeps.
func readOf(s stdio.KeptState) StateRead {
return StateRead{Keys: s.Keys, Get: func(key string) (json.RawMessage, error) {
e, err := s.Get(key)
if err != nil || e == nil {
return nil, err
}
return e.Value, nil
}}
}
// stepKey is the key of a step of this node: the id alone, or the whole key <node>.<id> as a step tool answered it.
func (m *Module) stepKey(id string) (string, error) {
id = strings.TrimSpace(id)
if node, rest, ok := strings.Cut(id, "."); ok {
if node != m.Node {
return "", fmt.Errorf("the step %q is of node %q, this module runs on %q", id, node, m.Node)
}
id = rest
}
return delivery.StepKey(m.Node, id)
}
// ReadStep is the stored value of this node's step id (state, reason, result).
func (m *Module) ReadStep(id string) (delivery.Step, error) {
key, err := m.stepKey(id)
if err != nil {
return delivery.Step{}, err
}
if m.ReadSteps.Get == nil {
return delivery.Step{}, fmt.Errorf("the steps state cannot be read here")
}
raw, err := m.ReadSteps.Get(key)
if err != nil {
return delivery.Step{}, err
}
if len(raw) == 0 {
return delivery.Step{}, fmt.Errorf("no step %s in the steps state", key)
}
return delivery.DecodeStep(raw)
}
// StepLine is a step without its result: the list answers these.
type StepLine struct {
ID string `json:"id"`
Tool string `json:"tool"`
State string `json:"state"`
Started string `json:"started"`
Ended string `json:"ended,omitempty"`
Reason string `json:"reason,omitempty"`
}
// RecentSteps are this node's steps, the latest started first, at most limit (ten when limit is not above zero).
func (m *Module) RecentSteps(limit int) ([]StepLine, error) {
if limit <= 0 {
limit = 10
}
if m.ReadSteps.Keys == nil || m.ReadSteps.Get == nil {
return nil, fmt.Errorf("the steps state cannot be read here")
}
keys, err := m.ReadSteps.Keys()
if err != nil {
return nil, err
}
var steps []delivery.Step
for _, k := range keys {
if node, _, err := delivery.ParseStepKey(k); err != nil || node != m.Node {
continue
}
raw, err := m.ReadSteps.Get(k)
if err != nil {
return nil, err
}
if len(raw) == 0 {
continue
}
s, err := delivery.DecodeStep(raw)
if err != nil {
continue // a value this module cannot read is not one of its own steps
}
steps = append(steps, s)
}
sort.SliceStable(steps, func(i, j int) bool { return steps[i].Started.After(steps[j].Started) })
if len(steps) > limit {
steps = steps[:limit]
}
out := make([]StepLine, 0, len(steps))
for _, s := range steps {
l := StepLine{ID: s.ID, Tool: s.Tool, State: s.State, Started: s.Started.UTC().Format("2006-01-02T15:04:05Z"), Reason: s.Reason}
if !s.Ended.IsZero() {
l.Ended = s.Ended.UTC().Format("2006-01-02T15:04:05Z")
}
out = append(out, l)
}
return out, nil
}
// ReadTools are the module's read tools. All are read-only.
func (m *Module) ReadTools() []stdio.Tool {
return []stdio.Tool{
{Name: StepReadTool,
Description: "What one step of this machine ended with, as stored in the module's steps state: its tool, state " +
"(running, passed, failed, refused), when it started and ended, why it failed or was refused, and its result " +
"(a fetch's workspace id is there). id is the step id a step tool answered, or the whole key <node>.<id>. Read-only.",
Input: map[string]any{"type": "object", "required": []string{"id"}, "properties": map[string]any{
"id": map[string]any{"type": "string", "description": "the step id, or the key <node>.<id>"}}},
Run: func(args map[string]any) (any, error) {
id, _ := args["id"].(string)
return m.ReadStep(id)
}},
{Name: StepsReadTool,
Description: "This machine's latest steps from the module's steps state, newest first, each with its id, tool, state, " +
"times and reason but not its result (read one with " + StepReadTool + "). Read-only.",
Input: map[string]any{"type": "object", "properties": map[string]any{
"limit": map[string]any{"type": "integer", "description": "how many, at most; 10 when left out"}}},
Run: func(args map[string]any) (any, error) {
steps, err := m.RecentSteps(intArg(args["limit"]))
return map[string]any{"node": m.Node, "steps": steps}, err
}},
}
}
// intArg is a tool argument that is a number, or a string of one; zero otherwise.
func intArg(v any) int {
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
case string:
i, _ := strconv.Atoi(n)
return i
}
return 0
}
@@ -0,0 +1,46 @@
package main
import (
"encoding/json"
"testing"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// The read tools answer what the steps state holds: a finished build's result is there, read by the step's id.
func TestTheReadToolsAnswerAStepsResult(t *testing.T) {
m, _, states := setup(t, same)
res, st := runBuild(t, m, states, nil)
if st.State != delivery.StepPassed {
t.Fatalf("step = %+v", st)
}
m.ReadSteps = StateRead{
Keys: func() ([]string, error) {
states.mu.Lock()
defer states.mu.Unlock()
var k []string
for key := range states.steps {
k = append(k, key)
}
return k, nil
},
Get: func(key string) (json.RawMessage, error) {
states.mu.Lock()
defer states.mu.Unlock()
v := states.steps[key]
return json.Marshal(v[len(v)-1])
},
}
got, err := m.ReadStep(st.ID)
if err != nil || got.State != delivery.StepPassed {
t.Fatalf("ReadStep = %+v, %v", got, err)
}
var back BuildResult
if err := json.Unmarshal(got.Result, &back); err != nil || back.Digest != res.Digest {
t.Errorf("result = %s, %v", got.Result, err)
}
lines, err := m.RecentSteps(0)
if err != nil || len(lines) != 1 || lines[0].Tool != BuildTool {
t.Errorf("steps = %+v, %v", lines, err)
}
}
@@ -0,0 +1,32 @@
package main
import (
"context"
"os"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// StepRunner runs the commands of a build step with an environment built from scratch (novox/hq ADR 0466 §4, issue
// 1485): PATH, HOME (the account's own home, where its ssh files and git configuration are) and a fixed locale, plus
// the words the caller passes, and nothing else of this process's environment — which holds the module's bus
// credentials and might hold a password or a token. It is mesh-sdk's clean runner (ExecRunnerClean, issue 1497) with
// that base given; a command is bound by its context alone (the bound 0), so a fetch may outlast a tool call's thirty
// seconds (the step has its own context).
func StepRunner(home string) forgekey.Runner {
run := forgekey.ExecRunnerClean(0)
return func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
return run(ctx, append(baseEnv(home), env...), name, args...)
}
}
// defaultPath is where a step looks for git, ssh and buildah when this process was given no PATH.
const defaultPath = "/usr/local/bin:/usr/bin:/bin"
func baseEnv(home string) []string {
path := os.Getenv("PATH")
if path == "" {
path = defaultPath
}
return []string{"PATH=" + path, "HOME=" + home, "LC_ALL=C"}
}
@@ -0,0 +1,4 @@
# A fixture module: a file added to an upstream base the manifest pins in build.on.
ARG DEMO_BASE
FROM ${DEMO_BASE}
COPY hello.txt /hello.txt
@@ -0,0 +1 @@
hello from the fixture module
@@ -0,0 +1,20 @@
{
"module": "fixture-image",
"version": "1",
"build": {
"on": [
{
"arg": "DEMO_BASE",
"image": "docker.io/library/busybox@sha256:0000000000000000000000000000000000000000000000000000000000000001"
}
],
"artifacts": [
{
"name": "server",
"kind": "image",
"from": "Dockerfile",
"target": "final"
}
]
}
}
@@ -0,0 +1,53 @@
package main
import (
"context"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// The tools this module serves. A tool name is the module's as build_image_ and its verb (a tool name has no dot).
const (
FetchTool = "build_image_fetch"
BuildTool = "build_image_build"
StatusTool = "build_image_status"
HealthTool = "build_image_health"
)
// StepTool is one step tool of the module: fetch or build. Long is a step that answers at once and ends on its key
// (ADR 0469 §2); the others answer in their call. Both run behind the readiness guard.
type StepTool struct {
Name string
Description string
Input map[string]any
Long bool
Run func(ctx context.Context, args map[string]any) (any, error)
}
// StepTools are the module's step tools.
func (m *Module) StepTools() []StepTool { return []StepTool{m.fetchTool(), m.buildTool()} }
// Tools is every tool the module serves: its step tools behind the guard, its read tools, its status and its health.
func (m *Module) Tools(steps []StepTool) []stdio.Tool {
tools := []stdio.Tool{}
for _, s := range steps {
t := stdio.Tool{Name: s.Name, Description: s.Description, Input: s.Input}
if s.Long {
t.Run = m.Long(s.Name, s.Run)
} else {
t.Run = m.Short(s.Run)
}
tools = append(tools, t)
}
tools = append(tools, m.ReadTools()...)
return append(tools,
stdio.Tool{Name: StatusTool,
Description: "What the image build module is on this machine: the buildah installed, the account's subordinate " +
"user and group ranges, whether a user namespace can be made, and why it refuses to build if it does. Read-only.",
Run: func(map[string]any) (any, error) { return m.Status(m.Ctx), nil }},
stdio.Tool{Name: HealthTool,
Description: "How the image build module is: unhealthy while buildah cannot be run, while its account holds no " +
"subordinate user or group range, or while a user namespace cannot be made, saying which; healthy otherwise. Read-only.",
Run: func(map[string]any) (any, error) { return m.Health(m.Ctx), nil }},
)
}
+7
View File
@@ -0,0 +1,7 @@
Host ${bound:forge-account:ssh_alias}
HostName ${bound:forge-account:ssh_host}
Port ${bound:forge-account:ssh_port}
HostKeyAlias ${bound:forge-account:ssh_alias}
User git
IdentityFile ~/.ssh/mesh_ed25519
IdentitiesOnly yes
+2
View File
@@ -0,0 +1,2 @@
[url "ssh://${bound:forge-account:ssh_alias}/"]
insteadOf = ${bound:forge-account:https_base}
@@ -0,0 +1 @@
${bound:forge-account:ssh_alias} ${bound:forge-account:host_key}
+1
View File
@@ -0,0 +1 @@
${bound:artifact-store:at}:${bound:artifact-store:port}
+5
View File
@@ -0,0 +1,5 @@
module meshbuildimage
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc h1:ZJScJSnF6LgFl55/VJ9RT/lNroVWun469sXtx0nlVYw=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
+142
View File
@@ -0,0 +1,142 @@
{
"module": "mesh-build-image",
"version": "1",
"moved": true,
"runs-as": "mesh-build-image",
"capabilities": [
"package-manager",
"user-namespaces"
],
"state": [
"keys",
"steps"
],
"tools": [
"build_image_fetch",
"build_image_build",
"build_image_step",
"build_image_steps",
"build_image_status",
"build_image_health"
],
"requires": [
"forge-account",
"rootless-image-build",
"artifact-store"
],
"own-secrets": {
"broker": "${dir:state}/broker"
},
"secrets-owner": "mesh-build-image",
"runtime-health": {
"kind": "tool",
"tool": "build_image_health",
"interval": "60s",
"timeout": "10s",
"looks": 2,
"grace": "60s"
},
"resources": [
{
"id": "account",
"type": "user",
"name": "mesh-build-image",
"shell": "/usr/bin/nologin",
"home": "/var/lib/mesh-build-image"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"owner": "mesh-build-image",
"place": ".",
"unknown": "program"
},
{
"id": "work",
"type": "directory",
"path": "/var/lib/mesh-build-image-work",
"mode": "0700",
"owner": "mesh-build-image",
"unknown": "program"
},
{
"id": "store",
"type": "directory",
"path": "/var/lib/mesh-build-image-store",
"mode": "0700",
"owner": "mesh-build-image",
"unknown": "program"
},
{
"id": "ssh-dir",
"type": "directory",
"path": "${dir:state}/.ssh",
"mode": "0700",
"owner": "mesh-build-image",
"unknown": "program"
},
{
"id": "ssh-config",
"type": "file",
"path": "${dir:ssh-dir}/config",
"owner": "mesh-build-image",
"mode": "0600",
"template": "files/forge"
},
{
"id": "known-hosts",
"type": "file",
"path": "${dir:ssh-dir}/known_hosts",
"owner": "mesh-build-image",
"mode": "0644",
"template": "files/known_hosts"
},
{
"id": "git-config",
"type": "file",
"path": "${dir:state}/.config/git/config",
"owner": "mesh-build-image",
"mode": "0644",
"template": "files/gitconfig"
},
{
"id": "registry",
"type": "file",
"path": "${dir:state}/registry",
"owner": "mesh-build-image",
"mode": "0644",
"template": "files/registry"
},
{
"id": "git",
"type": "package",
"package": "git"
},
{
"id": "openssh",
"type": "package",
"package": "openssh"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/mesh-build-image",
"binary": "mesh-build-image",
"loads": [
"mesh-build-image"
]
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
}