From 3600f2cf16173cd132d33b7eb39dfb51efd01874 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 30 Sep 2026 10:03:55 +0200 Subject: [PATCH] A resource can name the version of the build it uses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit novox/hq 04-ISSUES/142, and the second of the two things ADR 0141's own insight named: "a version cannot reach the path". A component is unpacked into a directory named for its version so it can read its own version from its path — and an archive named a fixed path in the manifest with nothing interpolating the build into it, so nothing could ask for .../versions// and every machine took a hand-placed fallback. A resource using an archive or a bundle may now say ${version} in any of its values. No artifact name in the reference: the resource already says which artifact it is for, and a second name is a second thing to keep in step. The version is the artifact's digest, short, and not the commit. Two builds of one commit are meant to be the same bytes — every toolchain here is -trimpath for that reason — so a content-addressed version means an unchanged build resolves to the path it already had. A commit-named path would move for an identical binary and recreate everything that reads it. An image is refused one, with a reason: an image is not unpacked, so it has no versioned place. Left alone it would reach a machine as literal text and be created as a directory called ${version}. --- internal/catalogue/build.go | 56 +++++++++++ internal/catalogue/version_in_a_path_test.go | 97 ++++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 internal/catalogue/version_in_a_path_test.go diff --git a/internal/catalogue/build.go b/internal/catalogue/build.go index 90b4bbb..cf73864 100644 --- a/internal/catalogue/build.go +++ b/internal/catalogue/build.go @@ -94,12 +94,43 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) { m.Module, r["id"], named) case ArtifactImage, ArtifactUpstream: filled["image"] = artifact.Reference + // An image is not unpacked anywhere, so it has no directory to be named for its + // version and `${version}` has nothing to mean. Refused rather than left as literal + // text in a path, which is how it would reach a machine and be created as a directory + // called `${version}`. + for key, value := range filled { + if text, isText := value.(string); isText && strings.Contains(text, versionRef) { + return Manifest{}, fmt.Errorf( + "%s: %v says %s in %q, and %q is an image — an image is not unpacked, so "+ + "it has no versioned place. %s is for an archive or a bundle", + m.Module, r["id"], versionRef, key, named, versionRef) + } + } case ArtifactArchive, ArtifactBundle: // The same on the wire: both are bytes fetched by digest and unpacked. They differ in // how they were made — one packed as it stood, the other compiled first — and a // machine has no reason to care which. filled["source"] = artifact.Reference filled["digest"] = artifact.Digest + // **And `${version}`, so a resource can name a place that is this build's alone** + // (novox/hq ADR 0141, 04-ISSUES/142). A component is unpacked into a directory named + // for its version so it can read its own version from its path — and until this, + // nothing could compose that path: an archive named a fixed one in the manifest and + // nothing interpolated the build into it, so nothing could ask for + // `…/versions//` and every machine took a hand-placed fallback. + // + // The version is the artifact's own digest, short. Not the commit: two builds of one + // commit are meant to be the same bytes (the toolchains are `-trimpath` for this), and + // a content-addressed version means an unchanged build resolves to the path it already + // had — so re-composing a declaration moves nothing, where a commit would move the + // path of an identical binary and recreate everything that reads it. + for key, value := range filled { + text, isText := value.(string) + if !isText || !strings.Contains(text, versionRef) { + continue + } + filled[key] = strings.ReplaceAll(text, versionRef, versionOf(artifact.Digest)) + } default: return Manifest{}, fmt.Errorf("%s: %q is a %q, and an artifact is %q, %q, %q or %q", m.Module, named, artifact.Kind, ArtifactImage, ArtifactArchive, ArtifactUpstream, @@ -259,3 +290,28 @@ func compilesToABinary(language string) bool { return false } } + +// versionRef is how a resource names the version of the artifact it uses: ${version}. +// +// No artifact name in it, because the resource already says which artifact it is for — a second +// name would be a second thing to keep in step with the first. +const versionRef = "${version}" + +// versionOf is an artifact's version as a path names it: its digest, short. +// +// **Content-addressed on purpose.** The alternative is the commit a build came from, and two builds +// of one commit are meant to produce the same bytes — every toolchain here is `-trimpath` for that +// reason. A commit-named path would move for an identical binary, and everything reading that path +// would be recreated for a change that is not one. A digest-named path moves exactly when the bytes +// do. +// +// Twelve hex characters: enough that two of this mesh's builds will not collide, short enough to +// read in a path and in a journal line. The `sha256:` prefix goes, because a directory name carrying +// a colon is a directory name people quote wrong. +func versionOf(digest string) string { + hex := strings.TrimPrefix(strings.TrimSpace(digest), "sha256:") + if len(hex) > 12 { + return hex[:12] + } + return hex +} diff --git a/internal/catalogue/version_in_a_path_test.go b/internal/catalogue/version_in_a_path_test.go new file mode 100644 index 0000000..7f53675 --- /dev/null +++ b/internal/catalogue/version_in_a_path_test.go @@ -0,0 +1,97 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// A component is unpacked into a directory named for its version, so it can read its own version from +// its path (novox/hq ADR 0141, 0142). Until this, nothing could compose that path: an archive named a +// fixed one and nothing interpolated the build into it, so nothing could ask for +// `…/versions//` and every machine took a hand-placed fallback (04-ISSUES/142). + +const aDigest = "sha256:ad62528c47c7b4a71cf814473f5de52a061348ce9521f707b0171a10fa6b247f" + +func TestAnArchivePathCanNameTheBuildsOwnVersion(t *testing.T) { + m := Manifest{ + Module: "mesh-host", + Build: &Build{Artifacts: []Artifact{{Name: "host-arch", Kind: ArtifactBundle, Language: "go", System: "arch"}}}, + Resources: []map[string]any{{ + "id": "next", "type": "archive", "artifact": "host-arch", + "path": "/usr/lib/nox-mesh-host/versions/${version}", + }}, + } + got, err := m.Resolve([]Built{{Name: "host-arch", Kind: ArtifactBundle, + Reference: "artifact-store://mesh-host/host-arch", Digest: aDigest}}) + if err != nil { + t.Fatal(err) + } + path, _ := got.Resources[0]["path"].(string) + if strings.Contains(path, "${version}") { + t.Fatalf("the version was not resolved: %q", path) + } + if path != "/usr/lib/nox-mesh-host/versions/ad62528c47c7" { + t.Fatalf("the path resolved to %q", path) + } + // The artifact key goes, as it does for every resolved resource: it is a build-time word and the + // host has never heard of it. + if _, still := got.Resources[0]["artifact"]; still { + t.Fatal("the artifact key survived resolution") + } +} + +func TestTheVersionIsTheDigestSoAnUnchangedBuildKeepsItsPath(t *testing.T) { + // The alternative is the commit, and two builds of one commit are meant to be the same bytes — + // every toolchain here is -trimpath for that reason. A commit-named path would move for an + // identical binary and recreate everything reading it. + first := versionOf(aDigest) + again := versionOf(aDigest) + if first != again || first == "" { + t.Fatalf("the same bytes produced %q and %q", first, again) + } + if other := versionOf("sha256:" + strings.Repeat("b", 64)); other == first { + t.Fatal("different bytes produced the same version") + } + // A path is read by people and quoted by shells. + if strings.ContainsAny(first, ":/ ") { + t.Fatalf("the version is not safe in a path: %q", first) + } +} + +func TestAnImageIsRefusedAVersionedPlace(t *testing.T) { + // An image is not unpacked, so it has no directory to be named for its version. Left as literal + // text it would reach a machine and be created as a directory called ${version}. + m := Manifest{ + Module: "something", + Build: &Build{Artifacts: []Artifact{{Name: "server", Kind: ArtifactImage, From: "Dockerfile"}}}, + Resources: []map[string]any{{ + "id": "where", "type": "directory", "artifact": "server", + "path": "/var/lib/something/${version}", + }}, + } + _, err := m.Resolve([]Built{{Name: "server", Kind: ArtifactImage, Reference: "registry/x@" + aDigest}}) + if err == nil { + t.Fatal("an image was given a versioned place") + } + if !strings.Contains(err.Error(), "not unpacked") { + t.Fatalf("the refusal does not say why: %v", err) + } +} + +func TestAResourceWithoutAVersionReferenceIsUntouched(t *testing.T) { + m := Manifest{ + Module: "mesh-host", + Build: &Build{Artifacts: []Artifact{{Name: "host-arch", Kind: ArtifactBundle, Language: "go", System: "arch"}}}, + Resources: []map[string]any{{ + "id": "next", "type": "archive", "artifact": "host-arch", "path": "/usr/lib/fixed", + }}, + } + got, err := m.Resolve([]Built{{Name: "host-arch", Kind: ArtifactBundle, + Reference: "artifact-store://mesh-host/host-arch", Digest: aDigest}}) + if err != nil { + t.Fatal(err) + } + if path, _ := got.Resources[0]["path"].(string); path != "/usr/lib/fixed" { + t.Fatalf("a path naming no version became %q", path) + } +} -- 2.54.0