mesh/merge-gate head 32bd8b5d5fb2 merged into trunk c2ec0a601acb — pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every …
mesh/repo-check head 32bd8b5d5fb2 merged into trunk c2ec0a601acb — fail: its merge-check.sh failed: --- FAIL: TestNoReaderAnswersEmptyForAnError (2.20s)
mesh/delivery-group group fix/a-module-moves-only-when-its-program-changes rejected: a member's own check failed
mesh/delivery superseded: a newer head of the same pull request
A one-sentence change in a package mesh-builder imports but never reaches moved build-agent. A merge check now compares each module it builds with the build the mesh holds, the whole image without file times, labels or creation time, and a walk is cut without a module every check of its pull requests found the same, while the mesh still holds that build. A comparison that fails moves the module as before.
181 lines
6.0 KiB
Go
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
|
|
}
|