package builder import ( "archive/tar" "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "sort" "strings" "github.com/novox/mesh-controller/internal/catalogue" ) // A module moves only when its program changes (novox/hq issue 721). A merge check builds every module the delivery // plan moves; a module whose every artifact comes out the same as the build the mesh holds of it — the same files, // the same modes and owners, the same entrypoint and environment — is said unchanged, and the walk leaves it where it // is. **The whole image is compared, never its main binary alone**: a module whose image differs in any file moves. // What is ignored is only what a rebuild cannot help changing: each file's time, the image's labels and its creation // time. **A comparison that cannot be made says nothing**, and the module is built and moved as before. // sameAsHeld says whether every artifact made is the same as the build the mesh holds of the module: the sentence // that says so and, per artifact, the held reference it was compared with. Empty when any artifact differs, is not // an image, has no held build, or could not be compared. func sameAsHeld(ctx context.Context, run Runner, dir, module string, made []catalogue.Built, held map[string]string) (string, map[string]string, error) { if len(made) == 0 { return "", nil, nil } against := map[string]string{} for _, m := range made { if m.Kind != "image" || m.Reference == "" { return "", nil, nil } was := held[module+"/"+m.Name] if was == "" { return "", nil, nil } if digestPart(was) != "" && digestPart(was) == digestPart(m.Reference) { against[m.Name] = was continue } now, err := imageContent(ctx, run, dir, m.Reference) if err != nil { return "", nil, fmt.Errorf("%s's new image: %w", m.Name, err) } before, err := imageContent(ctx, run, dir, was) if err != nil { return "", nil, fmt.Errorf("%s's held image: %w", m.Name, err) } if now != before { return "", nil, nil } against[m.Name] = was } var names []string for name, ref := range against { names = append(names, name+" "+shortRef(ref)) } sort.Strings(names) return "unchanged: its program is the same as running build " + strings.Join(names, ", "), against, nil } // digestPart is a reference's "sha256:…", empty when it is not pinned. func digestPart(ref string) string { if i := strings.LastIndex(ref, "@"); i >= 0 { return ref[i+1:] } return "" } // shortRef names a reference by its digest's first twelve characters when it has one. func shortRef(ref string) string { if d := strings.TrimPrefix(digestPart(ref), "sha256:"); d != "" { return twelve(d) } return ref } // imageContent is an image's content as one digest: its platform, its configuration without labels, and every file // of its filesystem by path, type, mode, owner, link, extended attributes and bytes — never by time. func imageContent(ctx context.Context, run Runner, dir, ref string) (string, error) { if _, err := run(ctx, dir, "docker", "pull", "--quiet", ref); err != nil { return "", fmt.Errorf("pulling %s: %w", ref, err) } config, err := run(ctx, dir, "docker", "image", "inspect", "--format", "{{.Os}}/{{.Architecture}}/{{.Variant}} {{json .Config}}", ref) if err != nil { return "", fmt.Errorf("reading %s's configuration: %w", ref, err) } said, err := configContent(config) if err != nil { return "", fmt.Errorf("reading %s's configuration: %w", ref, err) } id, err := run(ctx, dir, "docker", "create", "--entrypoint", "/", ref) if err != nil { return "", fmt.Errorf("opening %s's filesystem: %w", ref, err) } id = strings.TrimSpace(id) if id == "" { return "", errors.New("the container engine named no container for " + ref) } defer func() { _, _ = run(context.WithoutCancel(ctx), dir, "docker", "rm", "--force", id) }() if err := os.MkdirAll(dir, 0o700); err != nil { return "", err } file := filepath.Join(dir, "content-"+twelve(id)+".tar") defer os.Remove(file) if _, err := run(ctx, dir, "docker", "export", "--output", file, id); err != nil { return "", fmt.Errorf("reading %s's filesystem: %w", ref, err) } f, err := os.Open(file) if err != nil { return "", err } defer f.Close() files, err := treeContent(f) if err != nil { return "", fmt.Errorf("reading %s's filesystem: %w", ref, err) } sum := sha256.Sum256([]byte(said + "\n" + files)) return "sha256:" + hex.EncodeToString(sum[:]), nil } // configContent is an image's platform and configuration as compared: Labels, the parent's id and the hostname the // builder ran under are left out — the first holds the commit, the others no part of what runs. func configContent(inspected string) (string, error) { platform, raw, ok := strings.Cut(strings.TrimSpace(inspected), " ") if !ok { return "", fmt.Errorf("unexpected inspection %q", inspected) } var config map[string]json.RawMessage if err := json.Unmarshal([]byte(raw), &config); err != nil { return "", err } for _, k := range []string{"Labels", "Image", "Hostname", "Domainname"} { delete(config, k) } out, err := json.Marshal(config) // map keys are sorted if err != nil { return "", err } return platform + " " + string(out), nil } // treeContent is a filesystem tarball's entries, sorted by path, each with everything but its times. func treeContent(r io.Reader) (string, error) { tr := tar.NewReader(r) var lines []string for { h, err := tr.Next() if err == io.EOF { break } if err != nil { return "", err } sum := sha256.New() if _, err := io.Copy(sum, tr); err != nil { return "", err } var xattrs []string for k, v := range h.PAXRecords { if strings.HasPrefix(k, "SCHILY.xattr.") { xattrs = append(xattrs, k+"="+hex.EncodeToString([]byte(v))) } } sort.Strings(xattrs) lines = append(lines, fmt.Sprintf("%q %c %o %d:%d %q %d,%d %d %x %s", strings.TrimPrefix(h.Name, "./"), h.Typeflag, h.Mode, h.Uid, h.Gid, h.Linkname, h.Devmajor, h.Devminor, h.Size, sum.Sum(nil), strings.Join(xattrs, ";"))) } sort.Strings(lines) return strings.Join(lines, "\n"), nil }