Read a rebuild of an unchanged source as no move, whatever image digest it made (hq issue 280)
mesh/merge-gate error: the check could not run: a throwaway postgres:17-alpine could not be raised: docker run --label mesh.build=build-1791317509716888018…
mesh/delivery delivered
mesh/merge-gate error: the check could not run: a throwaway postgres:17-alpine could not be raised: docker run --label mesh.build=build-1791317509716888018…
mesh/delivery delivered
An image is not byte-reproducible, so ADR 0236's 'same artifacts is no move' never held for one: a catalogue merge that did not touch the bus rebuilt it, and every send to the control node waited for a planned bus upgrade. The builder now records a source fingerprint per build (module tree, context trees, bases and toolchains by digest). A rebuild with the fingerprint of the build it repeats is registered with that build's artifacts, handed to modules standing on it, holds no push, demands no bus step, and a plan sends and gates nothing for it. Identical artifacts remain a second way to be no move.
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A build's source fingerprint: what it was made from, hashed (novox/hq issue 280).
|
||||
//
|
||||
// **An image is not byte-reproducible.** Two builds of one source make two image digests, so the rule
|
||||
// "a rebuild that made the same artifacts is no move" (novox/hq ADR 0236) held for archives and bundles
|
||||
// and never for an image: a merge that rebuilt the bus without touching it made a "new" bus build, and
|
||||
// every send to the machine running it was refused until a planned bus upgrade — for a bus nothing had
|
||||
// changed. What a build is made from is reproducible, so that is what is fingerprinted:
|
||||
//
|
||||
// - the git tree of the module's directory at the commit built — every file the build reads, its
|
||||
// module.json and its recipes among them, since the recipe and the compiler see that directory only;
|
||||
// - the git tree of each other repository an artifact's context is cloned from (ArtifactContext);
|
||||
// - each base it was handed, by digest — a module's artifact or a declared vendor image (build.on);
|
||||
// - for a bundle, the toolchain it was compiled in: the compiler image's digest and the builder's own
|
||||
// recipe for that language.
|
||||
//
|
||||
// **No fingerprint where the source does not pin the build.** A build that resolves packages from the
|
||||
// mesh's package registry at build time — a `package` artifact, a TypeScript bundle with packages of
|
||||
// its own, an image whose recipe reads the registry credential — takes whatever the registry holds
|
||||
// then, so the same source can be a different program; it records none, and only its artifacts can
|
||||
// say it is the same. A recipe that fetches from the internet without a pin is the recipe's choice
|
||||
// (novox/hq ADR 0097 refuses the unpinned bases; what a RUN step downloads is not seen here).
|
||||
|
||||
// sourcePrefix names the fingerprint's form, so a later form is never compared equal to this one.
|
||||
const sourcePrefix = "src1:"
|
||||
|
||||
// sourceInputs collects what one build was made from.
|
||||
type sourceInputs struct {
|
||||
module string
|
||||
// tree is the git tree of the module's directory at the commit built.
|
||||
tree string
|
||||
// bases are the digests of what the build was handed to stand on.
|
||||
bases []string
|
||||
// contexts are, per artifact, the git tree of the repository its context was cloned from.
|
||||
contexts map[string]string
|
||||
// toolchains are, per bundle artifact, the compiler image's digest and the recipe's hash.
|
||||
toolchains map[string]string
|
||||
// unpinned is why this build has no fingerprint: empty when it has one.
|
||||
unpinned string
|
||||
}
|
||||
|
||||
func newSourceInputs(module string) *sourceInputs {
|
||||
return &sourceInputs{module: module, contexts: map[string]string{}, toolchains: map[string]string{}}
|
||||
}
|
||||
|
||||
// notPinned marks the build as one its source does not pin; the first reason stands.
|
||||
func (s *sourceInputs) notPinned(why string) {
|
||||
if s != nil && s.unpinned == "" {
|
||||
s.unpinned = why
|
||||
}
|
||||
}
|
||||
|
||||
// fingerprint is the build's source fingerprint, or empty when the source does not pin the build.
|
||||
func (s *sourceInputs) fingerprint() string {
|
||||
if s == nil || s.unpinned != "" || s.tree == "" {
|
||||
return ""
|
||||
}
|
||||
var lines []string
|
||||
lines = append(lines, "module "+s.module, "tree "+s.tree)
|
||||
bases := map[string]bool{}
|
||||
for _, b := range s.bases {
|
||||
bases[referenceDigest(b)] = true
|
||||
}
|
||||
for b := range bases {
|
||||
lines = append(lines, "base "+b)
|
||||
}
|
||||
for a, t := range s.contexts {
|
||||
lines = append(lines, "context "+a+" "+t)
|
||||
}
|
||||
for a, t := range s.toolchains {
|
||||
lines = append(lines, "toolchain "+a+" "+t)
|
||||
}
|
||||
// The module and its tree first, the rest in a fixed order.
|
||||
sort.Strings(lines[2:])
|
||||
sum := sha256.Sum256([]byte(strings.Join(lines, "\n")))
|
||||
return sourcePrefix + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// referenceDigest is a reference's digest — `sha256:…` — so the registry address it was copied into does not
|
||||
// enter the fingerprint; the reference itself when it carries none.
|
||||
func referenceDigest(reference string) string {
|
||||
if _, digest, pinned := strings.Cut(reference, "@"); pinned && digest != "" {
|
||||
return digest
|
||||
}
|
||||
return reference
|
||||
}
|
||||
|
||||
// gitTree is the git tree of a directory of a clone at its checked-out commit: the whole tree for an
|
||||
// empty path.
|
||||
func gitTree(ctx context.Context, run Runner, clone, path string) (string, error) {
|
||||
spec := "HEAD^{tree}"
|
||||
if rel := strings.Trim(filepath.ToSlash(filepath.Clean(path)), "/"); path != "" && rel != "" && rel != "." {
|
||||
spec = "HEAD:" + rel
|
||||
}
|
||||
out, err := run(ctx, clone, "git", "rev-parse", spec)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tree := strings.TrimSpace(out)
|
||||
if tree == "" {
|
||||
return "", fmt.Errorf("git named no tree for %s", spec)
|
||||
}
|
||||
return tree, nil
|
||||
}
|
||||
|
||||
// toolchainOf is what a bundle's compile adds to its fingerprint: the compiler image by digest and
|
||||
// the builder's recipe for the language, hashed, so a builder that compiles differently is a change.
|
||||
func toolchainOf(chain Toolchain, base string) string {
|
||||
recipe, _ := json.Marshal(chain)
|
||||
sum := sha256.Sum256(recipe)
|
||||
return chain.Language + " " + referenceDigest(base) + " " + hex.EncodeToString(sum[:8])
|
||||
}
|
||||
Reference in New Issue
Block a user