233 lines
9.9 KiB
Go
233 lines
9.9 KiB
Go
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 ""
|
|
}
|
|
|
|
// qualifyImage names a base image in full, as the controller's parseReference reads it: a name without a registry host
|
|
// is the public hub's (docker.io), a single name under `library/`. buildah, with this machine's registry configuration,
|
|
// has no unqualified-search registry and refuses a short name (issue 1824); a name that has a host is kept.
|
|
func qualifyImage(ref string) string {
|
|
name := ref
|
|
if at := strings.Index(name, "@"); at >= 0 {
|
|
name = name[:at]
|
|
}
|
|
if slash := strings.Index(name, "/"); slash >= 0 && (strings.ContainsAny(name[:slash], ".:") || name[:slash] == "localhost") {
|
|
return ref
|
|
}
|
|
if !strings.Contains(name, "/") {
|
|
return "docker.io/library/" + ref
|
|
}
|
|
return "docker.io/" + ref
|
|
}
|
|
|
|
// 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+"="+qualifyImage(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 ""
|
|
}
|