diff --git a/sdk/buildstep/toolchain.go b/sdk/buildstep/toolchain.go new file mode 100644 index 00000000..9b655926 --- /dev/null +++ b/sdk/buildstep/toolchain.go @@ -0,0 +1,81 @@ +package buildstep + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "time" +) + +// ToolchainVersion is the layout version of a toolchains value. +const ToolchainVersion = 1 + +// Toolchain is the value of a build module's state toolchains under the key .: the version of the +// language's toolchain installed on the node, the build module's recipe version, and when the module saw them. It is +// the part build-step's `Toolchain`, the layout every node-build seat gives that state; the three build modules share +// this neutral form (a seat's generated type is one package per seat). A test reads each seat's fixture strictly into it. +type Toolchain struct { + V int `json:"v,omitempty"` + Language string `json:"language"` + Version string `json:"version"` + Recipe string `json:"recipe"` + At time.Time `json:"at,omitzero"` +} + +func checkToolchainLanguage(language string) error { + switch language { + case "go", "typescript": + return nil + } + return fmt.Errorf("buildstep: the toolchain language %q is not go or typescript", language) +} + +// ToolchainKey is the key of a node's toolchain of one language: .. It refuses an empty node, a node +// with a dot and a language other than go or typescript. +func ToolchainKey(node, language string) (string, error) { + if node == "" || strings.ContainsAny(node, ". ") { + return "", fmt.Errorf("buildstep: %q is not a node name", node) + } + if err := checkToolchainLanguage(language); err != nil { + return "", err + } + return node + "." + language, nil +} + +func (t Toolchain) check() error { + if err := checkToolchainLanguage(t.Language); err != nil { + return err + } + if t.Version == "" || t.Recipe == "" { + return fmt.Errorf("buildstep: a %s toolchain needs its version and its recipe", t.Language) + } + return nil +} + +// EncodeToolchain is t as JSON; it refuses a language other than go or typescript and an empty version or recipe. +func EncodeToolchain(t Toolchain) ([]byte, error) { + if err := t.check(); err != nil { + return nil, err + } + t.V = ToolchainVersion + return json.Marshal(t) +} + +// DecodeToolchain reads a toolchains value, refusing an unknown field, another layout version and what +// EncodeToolchain refuses. +func DecodeToolchain(data []byte) (Toolchain, error) { + var t Toolchain + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + if err := dec.Decode(&t); err != nil { + return Toolchain{}, fmt.Errorf("buildstep: a toolchain does not read: %w", err) + } + if t.V != ToolchainVersion { + return Toolchain{}, fmt.Errorf("buildstep: a toolchain of version %d, this reads %d", t.V, ToolchainVersion) + } + if err := t.check(); err != nil { + return Toolchain{}, err + } + return t, nil +} diff --git a/sdk/buildstep/toolchain_test.go b/sdk/buildstep/toolchain_test.go new file mode 100644 index 00000000..3cff6eea --- /dev/null +++ b/sdk/buildstep/toolchain_test.go @@ -0,0 +1,80 @@ +package buildstep + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// The toolchains value and key are the layout all three node-build seats generate: each seat's fixture of the state +// reads strictly into Toolchain, and the key is the seat's key form .. +func TestTheToolchainsValueAndKeyAreTheSeatsLayout(t *testing.T) { + read := 0 + for _, seat := range []string{"node-build-go", "node-build-image", "node-build-node"} { + raw, err := os.ReadFile(filepath.Join("..", "..", "contracts", "seats", seat, "fixtures", "states.json")) + if err != nil { + t.Fatal(err) + } + var fixtures []struct { + Of string `json:"of"` + Given json.RawMessage `json:"given"` + } + if err := json.Unmarshal(raw, &fixtures); err != nil { + t.Fatal(err) + } + for _, f := range fixtures { + if f.Of != "state:toolchains/." { + continue + } + if back, err := DecodeToolchain(f.Given); err != nil || back.Version == "" { + t.Errorf("%s: the fixture reads as %+v, %v", seat, back, err) + } + if _, err := EncodeToolchain(mustToolchain(t, f.Given)); err != nil { + t.Errorf("%s: %v", seat, err) + } + dec := json.NewDecoder(bytes.NewReader(f.Given)) + dec.DisallowUnknownFields() + if err := dec.Decode(&Toolchain{}); err != nil { + t.Errorf("%s: %v", seat, err) + } + read++ + } + } + if read != 3 { + t.Errorf("%d toolchain fixtures read, want 3", read) + } + raw, err := EncodeToolchain(Toolchain{Language: "go", Version: "go1.27.1", Recipe: "3", At: time.Date(2026, 11, 5, 10, 0, 0, 0, time.UTC)}) + if err != nil { + t.Fatal(err) + } + if back, err := DecodeToolchain(raw); err != nil || back.Version != "go1.27.1" || back.At.IsZero() { + t.Errorf("the value reads back as %+v, %v", back, err) + } + if key, err := ToolchainKey("ace", "typescript"); err != nil || key != "ace.typescript" { + t.Errorf("key %q, %v", key, err) + } + for _, bad := range [][2]string{{"", "go"}, {"a.b", "go"}, {"ace", "rust"}} { + if _, err := ToolchainKey(bad[0], bad[1]); err == nil { + t.Errorf("the key of %v was made", bad) + } + } + if _, err := EncodeToolchain(Toolchain{Language: "go", Version: "go1.27"}); err == nil { + t.Error("a toolchain without its recipe was written") + } + if _, err := DecodeToolchain([]byte(`{"v":1,"language":"go","version":"x","recipe":"1","extra":1}`)); err == nil || !strings.Contains(err.Error(), "extra") { + t.Errorf("a field the layout lacks was read: %v", err) + } +} + +func mustToolchain(t *testing.T, raw []byte) Toolchain { + t.Helper() + var tc Toolchain + if err := json.Unmarshal(raw, &tc); err != nil { + t.Fatal(err) + } + return tc +}