package builder import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "path/filepath" "sort" "strings" "github.com/novox/mesh-controller/internal/catalogue" ) // 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 // prefix is the module's directory within its repository, empty at the root. prefix string // own is the module's build source in its own repository, relative to the module's directory, and // whole when an artifact's is not known (novox/hq ADR 0267). own map[string]bool ownIsAll bool // read is, per context (repository and ref), the build source read there; nil for one read whole. read map[string]map[string]bool readAs map[string]catalogue.ArtifactContext } func newSourceInputs(module string) *sourceInputs { return &sourceInputs{module: module, contexts: map[string]string{}, toolchains: map[string]string{}, own: map[string]bool{}, read: map[string]map[string]bool{}, readAs: map[string]catalogue.ArtifactContext{}} } // ownHas adds entries, relative to the module's directory, to its build source in its own repository. func (s *sourceInputs) ownHas(entries ...string) { if s == nil { return } for _, e := range entries { s.own[e] = true } } // ownWhole says an artifact's build source in the module's own repository is not known: the module's // whole directory is its source, as it was before. func (s *sourceInputs) ownWhole() { if s != nil { s.ownIsAll = true } } func contextKey(c catalogue.ArtifactContext) string { return c.Repository + "#" + c.Ref } // readIn adds entries to the build source read in a context; one read whole stays whole. func (s *sourceInputs) readIn(c catalogue.ArtifactContext, entries []string) { if s == nil { return } key := contextKey(c) s.readAs[key] = c set, known := s.read[key] if known && set == nil { return } if set == nil { set = map[string]bool{} s.read[key] = set } for _, e := range entries { set[e] = true } } // readWhole says a context is read whole by an artifact. func (s *sourceInputs) readWhole(c catalogue.ArtifactContext) { if s == nil { return } key := contextKey(c) s.readAs[key] = c s.read[key] = nil } // buildSources is what the build says it was made from, per repository: its own (module.json always, // and every artifact's) unless an artifact's is not known, and each context not read whole. func (s *sourceInputs) buildSources() []BuildSource { if s == nil { return nil } var out []BuildSource if !s.ownIsAll { own := []string{withPrefix(s.prefix, ManifestName)} for e := range s.own { own = append(own, withPrefix(s.prefix, e)) } sort.Strings(own) out = append(out, BuildSource{Paths: compactSorted(own)}) } var keys []string for k := range s.read { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { set := s.read[k] if set == nil { continue } var paths []string for e := range set { paths = append(paths, e) } sort.Strings(paths) c := s.readAs[k] out = append(out, BuildSource{Repository: c.Repository, Ref: c.Ref, Paths: paths}) } return out } // withPrefix is an entry relative to the module's directory made relative to its repository's root. func withPrefix(prefix, entry string) string { entry = strings.TrimPrefix(entry, "./") if prefix == "" { if entry == "" { return "./" } return entry } if entry == "" { return prefix + "/" } return prefix + "/" + entry } func compactSorted(in []string) []string { var out []string for i, e := range in { if i == 0 || e != in[i-1] { out = append(out, e) } } return out } // 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]) }