package builder import ( "archive/tar" "compress/gzip" "context" "crypto/sha256" "encoding/hex" "fmt" "io" "os" "os/exec" "path/filepath" "sort" "strings" "github.com/novox/mesh-control/internal/catalogue" ) // Turning a repository into artifacts the mesh can pin. // // **This runs on a node, not in the control plane.** Building needs a container runtime and a // working tree, and the control plane deliberately cannot run commands on a machine — what it may // send is bounded by the declaration language (novox/hq ADR 0005), and "run this build" is not in // it. So the builder is something a node runs *as a module*, given work over the broker like // anything else, and this package is what it does when it gets some. // // The alternative — the control plane holding a docker socket — would make it the one component // that can do anything on a machine, which is the property the whole design is arranged to avoid. // Runner runs a command in a directory and returns what it said. Injected so the tests do not // need docker and git, and so the failure of either is reported rather than assumed. type Runner func(ctx context.Context, dir string, name string, args ...string) (string, error) // Publisher puts an artifact somewhere a machine can fetch it, and says how to refer to it. type Publisher interface { // PublishImage pushes a locally built image and returns a reference pinned by digest. PublishImage(ctx context.Context, localTag, repository string) (string, error) // PublishArchive stores bytes and returns where to fetch them from. PublishArchive(ctx context.Context, repository string, body []byte, digest string) (string, error) } // Result is everything one build produced. type Result struct { // Manifest is the module as the mesh should hold it: artifacts resolved to digests. Manifest catalogue.Manifest // Commit is what was built, so "is this current?" is answerable without building again. Commit string // Built is each artifact, for reporting. Built []catalogue.Built } // Build clones a repository at a ref, reads its manifest, produces what it declares, publishes // each, and returns the manifest the mesh should hold. // // **Nothing is published until everything is built.** A module whose image succeeded and whose // archive failed would otherwise leave half of itself in the store under a digest the mesh never // records — reachable, unreferenced, and indistinguishable from something in use. func Build(ctx context.Context, run Runner, publish Publisher, repository, path, ref, workspace string) (Result, error) { // Made rather than required. A builder that fails because the directory it was told to work // in does not exist is a builder that needs a setup step nobody documented. if err := os.MkdirAll(workspace, 0o755); err != nil { return Result{}, err } tree := filepath.Join(workspace, "source") if err := os.RemoveAll(tree); err != nil { return Result{}, err } // A fresh clone every time rather than a fetch into a tree that is already there. A build // that reuses a working tree can succeed because of something a previous build left behind, // and that is a build nobody can reproduce. if _, err := run(ctx, workspace, "git", "clone", "--quiet", repository, tree); err != nil { return Result{}, fmt.Errorf("cannot clone %s: %w", repository, err) } if ref != "" { if _, err := run(ctx, tree, "git", "checkout", "--quiet", ref); err != nil { return Result{}, fmt.Errorf("%s has no %s: %w", repository, ref, err) } } commit, err := run(ctx, tree, "git", "rev-parse", "HEAD") if err != nil { return Result{}, err } commit = strings.TrimSpace(commit) // A module is a repository and a path within it (novox/hq ADR 0069). The ordinary case is an // empty path, meaning the repository's root; a repository holding several modules names each // by its own directory, which is what the catalogue is and what the system this replaces has // always done. within, err := inside(tree, path) if err != nil { return Result{}, err } raw, err := os.ReadFile(filepath.Join(within, ManifestName)) if err != nil { return Result{}, fmt.Errorf( "%s has no %s at %s, so there is nothing saying what it is: %w", repository, ManifestName, describe(path), err) } manifest, err := catalogue.ParseManifest(raw) if err != nil { return Result{}, err } var built []catalogue.Built if manifest.Build != nil { artifacts := append([]catalogue.Artifact{}, manifest.Build.Artifacts...) // Ordered, so two builds of one commit do the same work in the same sequence and their // logs can be compared. sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name }) for _, a := range artifacts { made, err := one(ctx, run, publish, manifest.Module, within, commit, a) if err != nil { return Result{}, err } built = append(built, made) } } resolved, err := manifest.Resolve(built) if err != nil { return Result{}, err } return Result{Manifest: resolved, Commit: commit, Built: built}, nil } // inside resolves a module's path within a clone, and refuses one that leaves it. // // **A build reads only its own tree.** A path of `../../etc` would otherwise make a build read — // and an archive artifact publish — whatever the build machine happens to hold, which is the one // thing a machine that builds other people's repositories must not do. func inside(tree, path string) (string, error) { if path == "" { return tree, nil } if filepath.IsAbs(path) { return "", fmt.Errorf( "a module's path is inside its repository, and %q is an absolute path", path) } within := filepath.Join(tree, path) rel, err := filepath.Rel(tree, within) if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { return "", fmt.Errorf( "%q leaves the repository, and a build reads only its own tree", path) } return within, nil } // describe says where a manifest was looked for, in words a person can act on. func describe(path string) string { if path == "" { return "its root" } return path } // ManifestName is the one file a module repository must have. // // At the root, and named the same in every repository. A convention somebody can look for beats a // setting somebody has to find. const ManifestName = "module.json" func one(ctx context.Context, run Runner, publish Publisher, module, tree, commit string, a catalogue.Artifact) (catalogue.Built, error) { switch a.Kind { case catalogue.ArtifactUpstream: // Mirrored, not built. Pulled by the reference the module names and pushed under a name // of the mesh's own, so what a machine fetches is pinned by a digest this registry // assigned rather than by a tag somebody else can move. if _, err := run(ctx, tree, "docker", "pull", a.From); err != nil { return catalogue.Built{}, fmt.Errorf("%s: cannot fetch %s: %w", module, a.From, err) } reference, err := publish.PublishImage(ctx, a.From, module+"/"+a.Name) if err != nil { return catalogue.Built{}, err } return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: reference}, nil case catalogue.ArtifactImage: // Tagged by commit rather than by version, because a version is what a person calls a // release and a commit is what was actually built. The mesh pins the digest anyway; this // is only so a person looking at the build node can tell what is there. local := fmt.Sprintf("%s-%s:%s", module, a.Name, short(commit)) if _, err := run(ctx, tree, "docker", "build", "-f", a.From, "-t", local, "."); err != nil { return catalogue.Built{}, fmt.Errorf("%s: building %s failed: %w", module, a.Name, err) } reference, err := publish.PublishImage(ctx, local, module+"/"+a.Name) if err != nil { return catalogue.Built{}, err } return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: reference}, nil case catalogue.ArtifactArchive: body, err := pack(filepath.Join(tree, a.From)) if err != nil { return catalogue.Built{}, fmt.Errorf("%s: packing %s failed: %w", module, a.Name, err) } sum := sha256.Sum256(body) digest := "sha256:" + hex.EncodeToString(sum[:]) where, err := publish.PublishArchive(ctx, module+"/"+a.Name, body, digest) if err != nil { return catalogue.Built{}, err } return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: where, Digest: digest}, nil } return catalogue.Built{}, fmt.Errorf("%s: %q is a %q, which is not something this builds", module, a.Name, a.Kind) } // pack tars and gzips a directory. // // **Deterministically**: entries sorted, and no timestamps, uid, gid or original names carried // through. Two builds of one commit must produce one digest, or nothing downstream can tell "this // changed" from "this was built again" — and every rebuild would look like a change to every // machine holding it. func pack(root string) ([]byte, error) { info, err := os.Stat(root) if err != nil { return nil, err } if !info.IsDir() { return nil, fmt.Errorf("%s is not a directory", root) } var paths []string err = filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() || !info.Mode().IsRegular() { // Only files. A symlink or a device in an archive is refused by the host that unpacks // it, so putting one in would build something that cannot be applied. if !info.IsDir() && !info.Mode().IsRegular() { return fmt.Errorf("%s is neither a file nor a directory, and an archive carries "+ "only those", path) } return nil } paths = append(paths, path) return nil }) if err != nil { return nil, err } // filepath.Walk is documented to walk in lexical order, so this is belt and braces rather // than load-bearing — and no test distinguishes it, which is worth saying rather than // implying otherwise. It stays because the cost is nothing and the failure it guards against // is silent: an archive whose digest changes because the traversal did. sort.Strings(paths) var out strings.Builder zipped := gzip.NewWriter(&stringWriter{&out}) writer := tar.NewWriter(zipped) for _, path := range paths { body, err := os.ReadFile(path) if err != nil { return nil, err } relative, err := filepath.Rel(root, path) if err != nil { return nil, err } info, err := os.Stat(path) if err != nil { return nil, err } mode := int64(info.Mode().Perm()) if err := writer.WriteHeader(&tar.Header{ Name: filepath.ToSlash(relative), Mode: mode, Size: int64(len(body)), Typeflag: tar.TypeReg, // Everything else left at its zero value on purpose — see the note above. }); err != nil { return nil, err } if _, err := writer.Write(body); err != nil { return nil, err } } if err := writer.Close(); err != nil { return nil, err } if err := zipped.Close(); err != nil { return nil, err } return []byte(out.String()), nil } type stringWriter struct{ to *strings.Builder } func (w *stringWriter) Write(p []byte) (int, error) { return w.to.Write(p) } func short(commit string) string { if len(commit) > 8 { return commit[:8] } return commit } // Command is a Runner that actually runs things. func Command(ctx context.Context, dir, name string, args ...string) (string, error) { cmd := exec.CommandContext(ctx, name, args...) cmd.Dir = dir out, err := cmd.CombinedOutput() if err != nil { return string(out), fmt.Errorf("%s %s: %w\n%s", name, strings.Join(args, " "), err, strings.TrimSpace(string(out))) } return string(out), nil } var _ io.Writer = (*stringWriter)(nil)