Files
mesh-controller/internal/builder/same_program.go
T
mesh-tools 97a2d841c7 Land feature 57's controller work on its release branch
The controller plans config, templates and moved providers' provisions,
hands bus accounts to the vault and judges the new manifest words, on the
feature's SDK and node-engine validator (feature 57, config and provisioning).
2026-10-15 21:27:33 +00:00

181 lines
6.0 KiB
Go

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
}