diff --git a/internal/catalogue/build.go b/internal/catalogue/build.go index 9ff4336..94d54be 100644 --- a/internal/catalogue/build.go +++ b/internal/catalogue/build.go @@ -153,6 +153,26 @@ func (b *Build) problems(module string) []string { "%s: %q is a bundle and says no language, so nothing can choose a compiler "+ "for it", module, a.Name)) } + // **A system, for a language that compiles to a binary** (novox/hq ADR 0142). A binary + // is pinned to one operating system at link time so a host refuses to touch a machine + // it was not built for (novox/hq ADR 0005); an artifact that says nothing would be + // compiled for whatever the build machine happened to be, which reads as portable and + // is not. + if compiled := compilesToABinary(a.Language); compiled && strings.TrimSpace(a.System) == "" { + problems = append(problems, fmt.Sprintf( + "%s: %q is compiled to a binary and says no system, so it would be built for "+ + "whatever the build machine happens to be. Declare one artifact per "+ + "system: %s", module, a.Name, spokenSystems())) + } else if !compiled && strings.TrimSpace(a.System) != "" { + problems = append(problems, fmt.Sprintf( + "%s: %q names the system %q and is written in %q, which compiles to code that "+ + "runs anywhere — a system that decides nothing reads as though it did", + module, a.Name, a.System, a.Language)) + } else if compiled && !knownSystem(a.System) { + problems = append(problems, fmt.Sprintf( + "%s: %q is built for %q, and a system is %s", + module, a.Name, a.System, spokenSystems())) + } } else { if a.From == "" { problems = append(problems, fmt.Sprintf( @@ -193,3 +213,42 @@ func oneOrOther(n int) string { } return "them" } + +// Systems the mesh builds binaries for, which is the set a host may be pinned to (novox/hq ADR 0005). +// +// **A closed list, and the host's own, not the compiler's.** These are not the values a Go toolchain +// would call an operating system — the difference between two of them is a C library, not a kernel. +// They are what a machine reports itself to be and what a host is linked to refuse, so the list that +// matters is the one the host understands. +var systems = []string{"alpine", "android", "arch"} + +// knownSystem is whether the mesh builds for it. +func knownSystem(system string) bool { + want := strings.ToLower(strings.TrimSpace(system)) + for _, s := range systems { + if s == want { + return true + } + } + return false +} + +// spokenSystems is the list as a refusal says it, so a reader is one edit from right. +func spokenSystems() string { + return strings.Join(systems, ", ") +} + +// compilesToABinary is whether this language's bundle is a binary for one operating system rather +// than code that runs wherever its interpreter does. +// +// **Asked of the language, not of the artifact.** A module says what it is written in; what that +// implies is the mesh's to know, exactly as the compiler is (novox/hq ADR 0142). Asking the artifact +// would let two artifacts in one language disagree about whether they are portable. +func compilesToABinary(language string) bool { + switch strings.ToLower(strings.TrimSpace(language)) { + case "go": + return true + default: + return false + } +} diff --git a/internal/catalogue/build_system_test.go b/internal/catalogue/build_system_test.go new file mode 100644 index 0000000..e1a97dd --- /dev/null +++ b/internal/catalogue/build_system_test.go @@ -0,0 +1,82 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// bundleFor is a manifest whose one artifact is a bundle in the given language and system. +func bundleFor(language, system string) Manifest { + return Manifest{Module: "a-component", Build: &Build{Artifacts: []Artifact{ + {Name: "binary", Kind: ArtifactBundle, Language: language, System: system}, + }}} +} + +func problemsOf(t *testing.T, m Manifest) string { + t.Helper() + return strings.Join(m.Build.problems(m.Module), "\n") +} + +// **A language that compiles to a binary must say which system.** +// +// A binary is pinned to one operating system at link time, so a host refuses to touch a machine it +// was not built for. An artifact that says nothing would be compiled for whatever the build machine +// happened to be — which reads as portable and is not, and is the fault this check exists for. +func TestABinaryMustSayWhichSystemItIsFor(t *testing.T) { + got := problemsOf(t, bundleFor("go", "")) + if !strings.Contains(got, "says no system") { + t.Fatalf("a compiled bundle with no system was accepted:\n%s", got) + } + // And the refusal names what it could have said, so a reader is one edit from right. + for _, system := range []string{"alpine", "android", "arch"} { + if !strings.Contains(got, system) { + t.Fatalf("the refusal does not name %q as a choice:\n%s", system, got) + } + } +} + +func TestABinaryThatNamesASystemIsAccepted(t *testing.T) { + if got := problemsOf(t, bundleFor("go", "arch")); got != "" { + t.Fatalf("a compiled bundle naming a system was refused:\n%s", got) + } +} + +// A system the mesh does not build for is refused where it is written. These are the host's own +// names, not a compiler's: the difference between two of them is a C library rather than a kernel, +// so a value that looks like an operating system to a toolchain is still wrong here. +func TestASystemTheMeshDoesNotBuildForIsRefused(t *testing.T) { + for _, wrong := range []string{"linux", "debian", "darwin"} { + got := problemsOf(t, bundleFor("go", wrong)) + if !strings.Contains(got, "and a system is") { + t.Fatalf("%q was accepted as a system:\n%s", wrong, got) + } + } +} + +// **And a language that runs anywhere must not name one.** A system that decides nothing reads as +// though it did, which is the same fault as a restriction that restricts nothing (novox/hq ADR 0045). +func TestAPortableBundleMayNotNameASystem(t *testing.T) { + got := problemsOf(t, bundleFor("typescript", "arch")) + if !strings.Contains(got, "runs anywhere") { + t.Fatalf("a portable bundle was allowed to name a system:\n%s", got) + } +} + +func TestAPortableBundleNamingNoSystemIsAccepted(t *testing.T) { + if got := problemsOf(t, bundleFor("typescript", "")); got != "" { + t.Fatalf("an ordinary bundle was refused:\n%s", got) + } +} + +// One component, one artifact per system: the shape the mesh's own binaries are declared in, and the +// reason the target is the artifact's rather than the recipe's. +func TestOneArtifactPerSystemIsAccepted(t *testing.T) { + m := Manifest{Module: "the-host", Build: &Build{Artifacts: []Artifact{ + {Name: "arch", Kind: ArtifactBundle, Language: "go", System: "arch"}, + {Name: "alpine", Kind: ArtifactBundle, Language: "go", System: "alpine"}, + {Name: "android", Kind: ArtifactBundle, Language: "go", System: "android"}, + }}} + if got := problemsOf(t, m); got != "" { + t.Fatalf("one artifact per system was refused:\n%s", got) + } +} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index aa5af59..3d0276c 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -571,6 +571,21 @@ type Artifact struct { // image built from this same module's own repository, the same as every other artifact. Context *ArtifactContext `json:"context,omitempty"` + // System is the operating system this artifact is compiled for, for a bundle whose output is a + // binary rather than portable code (novox/hq ADR 0142). + // + // **Named by the artifact, not by the recipe.** A toolchain deliberately accepts nothing from + // the module — anything a module could override there it would be writing a Dockerfile to + // override — and yet a compiled binary is per operating system, pinned at link time so a host + // refuses to touch a machine it was not built for (novox/hq ADR 0005). The way out is that the + // target is a property of the artifact: one artifact declared per system, one build each, and + // the recipe stays the mesh's. + // + // Empty for a bundle whose output runs anywhere, which is every interpreted language, and for + // every other kind. A bundle in a language that compiles to a binary must say one, because + // "compiled for whatever the build machine happened to be" is the fault this exists to prevent. + System string `json:"system,omitempty"` + // Language is what this module's code is written in, for a bundle. // // **Declared, never guessed.** Inferring it from what files happen to be present makes a