Add mesh-build-image: rootless image builds with buildah, pushed by tag from trunk only (issue 1703)
This commit is contained in:
@@ -27,3 +27,4 @@ modules/mesh-provisioner-client/cmd/mesh-provisioner-client/mesh-provisioner-cli
|
||||
modules/brother/cmd/brother/brother
|
||||
modules/mesh-build-go/cmd/mesh-build-go/mesh-build-go
|
||||
modules/mesh-config/mesh-config
|
||||
modules/mesh-build-image/cmd/mesh-build-image/mesh-build-image
|
||||
|
||||
@@ -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"}
|
||||
}
|
||||
+4
@@ -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
|
||||
+1
@@ -0,0 +1 @@
|
||||
hello from the fixture module
|
||||
Vendored
+20
@@ -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 }},
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -0,0 +1 @@
|
||||
${bound:artifact-store:at}:${bound:artifact-store:port}
|
||||
@@ -0,0 +1,5 @@
|
||||
module meshbuildimage
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc
|
||||
@@ -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=
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user