Merge pull request 'A resource can name the version of the build it uses' (#156) from feat/142-a-version-can-reach-a-path into main
This commit was merged in pull request #156.
This commit is contained in:
@@ -94,12 +94,43 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
|||||||
m.Module, r["id"], named)
|
m.Module, r["id"], named)
|
||||||
case ArtifactImage, ArtifactUpstream:
|
case ArtifactImage, ArtifactUpstream:
|
||||||
filled["image"] = artifact.Reference
|
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:
|
case ArtifactArchive, ArtifactBundle:
|
||||||
// The same on the wire: both are bytes fetched by digest and unpacked. They differ in
|
// 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
|
// how they were made — one packed as it stood, the other compiled first — and a
|
||||||
// machine has no reason to care which.
|
// machine has no reason to care which.
|
||||||
filled["source"] = artifact.Reference
|
filled["source"] = artifact.Reference
|
||||||
filled["digest"] = artifact.Digest
|
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/<version>/` 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:
|
default:
|
||||||
return Manifest{}, fmt.Errorf("%s: %q is a %q, and an artifact is %q, %q, %q or %q",
|
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,
|
m.Module, named, artifact.Kind, ArtifactImage, ArtifactArchive, ArtifactUpstream,
|
||||||
@@ -259,3 +290,28 @@ func compilesToABinary(language string) bool {
|
|||||||
return false
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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/<version>/` 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user