mesh/merge-gate head c7e892ca63b3 merged into trunk 82649bc770d1 — pass: builds mesh-build-go, mesh-build-node, sent nowhere; no bus step; every machine co…
mesh/repo-check head c7e892ca63b3 merged into trunk 82649bc770d1 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
258 lines
8.5 KiB
Go
258 lines
8.5 KiB
Go
package main
|
|
|
|
// The forge side of a build module (novox/hq ADR 0466 §2 and §4, issue 1484). This file is the same for every build
|
|
// module: mesh-build-node takes a copy and changes the three constants below.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
|
)
|
|
|
|
const (
|
|
forgeModule = "mesh-build-go"
|
|
forgeHome = "/var/lib/mesh-build-go"
|
|
forgeModuleD = "../.." // the module's directory, where module.json and files/ are
|
|
)
|
|
|
|
type forgeManifest struct {
|
|
Moved bool `json:"moved"`
|
|
Requires []any `json:"requires"`
|
|
State []any `json:"state"`
|
|
Resources []map[string]any `json:"resources"`
|
|
Build struct {
|
|
Artifacts []map[string]any `json:"artifacts"`
|
|
} `json:"build"`
|
|
}
|
|
|
|
func readForgeManifest(t *testing.T) forgeManifest {
|
|
t.Helper()
|
|
raw, err := os.ReadFile(filepath.Join(forgeModuleD, "module.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var m forgeManifest
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return m
|
|
}
|
|
|
|
// The forge files are filled on the node by the provisioner's client, never by the controller (ADR 0466 §4, ADR 0400
|
|
// §5, issue 1537): the forge serves a module's entry only after the module has run and reported its key (ADR 0400
|
|
// §2), so a controller filling them would refuse the machine until then, and the module could never run.
|
|
func TestTheForgeFilesAreFilledOnTheNodeSoTheModuleCanRunBeforeItsEntry(t *testing.T) {
|
|
if !readForgeManifest(t).Moved {
|
|
t.Errorf("%s's manifest does not say \"moved\": true, so the controller fills its forge files and refuses "+
|
|
"every machine it is assigned to until the forge serves an entry it makes only after the module ran", forgeModule)
|
|
}
|
|
}
|
|
|
|
func forgeTemplate(t *testing.T, r map[string]any) string {
|
|
t.Helper()
|
|
name, _ := r["template"].(string)
|
|
if name == "" {
|
|
t.Fatalf("resource %v names no template", r["id"])
|
|
}
|
|
raw, err := os.ReadFile(filepath.Join(forgeModuleD, filepath.FromSlash(name)))
|
|
if err != nil {
|
|
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
|
|
}
|
|
return string(raw)
|
|
}
|
|
|
|
func forgeResource(t *testing.T, m forgeManifest, id string) map[string]any {
|
|
t.Helper()
|
|
for _, r := range m.Resources {
|
|
if r["id"] == id {
|
|
if r["owner"] != forgeModule {
|
|
t.Errorf("%s is owned by %v, not the module's own account", id, r["owner"])
|
|
}
|
|
if p, _ := r["path"].(string); !strings.HasPrefix(p, "${dir:") || strings.Contains(p, "account-home") {
|
|
t.Errorf("%s is at %q: a path of the account's own home (${dir:...}), never the operator's", id, p)
|
|
}
|
|
return r
|
|
}
|
|
}
|
|
t.Fatalf("no resource %q", id)
|
|
return nil
|
|
}
|
|
|
|
var forgeSample = map[string]string{
|
|
"known_host": "[novox.internal]:222 ssh-ed25519 AAAAC3Nza",
|
|
"host_key": "ssh-ed25519 AAAAC3Nza",
|
|
"ssh_host": "novox.internal",
|
|
"ssh_alias": "mesh-forge",
|
|
"ssh_port": "222",
|
|
"ssh_base": "ssh://git@novox.internal:222/",
|
|
"https_base": "https://git.novox.be/",
|
|
}
|
|
|
|
// forgeRender fills ${bound:forge-account:<key>} with the sample values, as the provisioner client does.
|
|
func forgeRender(t *testing.T, s string) string {
|
|
t.Helper()
|
|
for k, v := range forgeSample {
|
|
s = strings.ReplaceAll(s, "${bound:forge-account:"+k+"}", v)
|
|
}
|
|
if strings.Contains(s, "${") {
|
|
t.Errorf("unfilled placeholder in %q", s)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// 1484/AC1: given sample bound values, the ssh config has Host mesh-forge, IdentitiesOnly yes and the key under the
|
|
// module's own home; known_hosts carries the host key; git's config rewrites https_base to ssh://mesh-forge/; no
|
|
// ${ is left.
|
|
func TestForgeFilesRenderFromTheBoundValuesInTheModulesOwnHome(t *testing.T) {
|
|
m := readForgeManifest(t)
|
|
|
|
cfg := forgeResource(t, m, "ssh-config")
|
|
want := "Host mesh-forge\n HostName novox.internal\n Port 222\n HostKeyAlias mesh-forge\n User git\n IdentityFile ~/.ssh/mesh_ed25519\n IdentitiesOnly yes\n"
|
|
if got := forgeRender(t, forgeTemplate(t, cfg)); got != want {
|
|
t.Errorf("ssh config = %q, want %q", got, want)
|
|
}
|
|
if cfg["path"] != "${dir:ssh-dir}/config" || cfg["mode"] != "0600" || cfg["into"] != nil {
|
|
t.Errorf("ssh config resource = %v, want the whole file ${dir:ssh-dir}/config at mode 0600", cfg)
|
|
}
|
|
|
|
kh := forgeResource(t, m, "known-hosts")
|
|
if got := strings.TrimSpace(forgeRender(t, forgeTemplate(t, kh))); got != "mesh-forge ssh-ed25519 AAAAC3Nza" {
|
|
t.Errorf("known_hosts = %q", got)
|
|
}
|
|
|
|
g := forgeResource(t, m, "git-config")
|
|
wantGit := "[url \"ssh://mesh-forge/\"]\n\tinsteadOf = https://git.novox.be/\n"
|
|
if got := forgeRender(t, forgeTemplate(t, g)); got != wantGit {
|
|
t.Errorf("git config = %q, want %q", got, wantGit)
|
|
}
|
|
|
|
dir := forgeResource(t, m, "ssh-dir")
|
|
if dir["mode"] != "0700" || dir["path"] != "${dir:state}/.ssh" {
|
|
t.Errorf("ssh-dir = %v, want ${dir:state}/.ssh at mode 0700", dir)
|
|
}
|
|
}
|
|
|
|
// 1484/AC2: the module requires forge-account, keeps the state keys, and every file resource takes its text from a
|
|
// template in the files/ archive (novox/hq ADR 0349 §2).
|
|
func TestManifestRequiresForgeAccountKeepsKeysAndTakesFilesFromTemplates(t *testing.T) {
|
|
m := readForgeManifest(t)
|
|
if len(m.Requires) != 1 || m.Requires[0] != "forge-account" {
|
|
t.Errorf("requires = %v, want [forge-account]", m.Requires)
|
|
}
|
|
keeps := false
|
|
for _, s := range m.State {
|
|
if s == "keys" {
|
|
keeps = true
|
|
}
|
|
}
|
|
if !keeps {
|
|
t.Errorf("state = %v lacks keys", m.State)
|
|
}
|
|
files := 0
|
|
for _, r := range m.Resources {
|
|
if _, inline := r["content"]; inline {
|
|
t.Errorf("resource %v keeps its text inline", r["id"])
|
|
}
|
|
if r["type"] == "file" {
|
|
files++
|
|
forgeTemplate(t, r)
|
|
}
|
|
}
|
|
if files == 0 {
|
|
t.Errorf("no file resource: the forge's ssh files are missing")
|
|
}
|
|
packs := 0
|
|
for _, a := range m.Build.Artifacts {
|
|
if a["kind"] == "archive" && a["from"] == "files" && a["name"] == "files" {
|
|
packs++
|
|
}
|
|
}
|
|
if packs != 1 {
|
|
t.Errorf("the build packs files/ into %d archives named files, not one", packs)
|
|
}
|
|
}
|
|
|
|
// forgeEd25519Line is a public key line with a well-formed ed25519 blob.
|
|
func forgeEd25519Line(comment string) string {
|
|
blob := []byte{}
|
|
for _, part := range [][]byte{[]byte("ssh-ed25519"), make([]byte, 32)} {
|
|
l := make([]byte, 4)
|
|
binary.BigEndian.PutUint32(l, uint32(len(part)))
|
|
blob = append(append(blob, l...), part...)
|
|
}
|
|
return "ssh-ed25519 " + base64.StdEncoding.EncodeToString(blob) + " " + comment
|
|
}
|
|
|
|
type forgeKeys struct{ puts map[string][]byte }
|
|
|
|
func (f *forgeKeys) Put(key string, value any) (uint64, error) {
|
|
raw, _ := json.Marshal(value)
|
|
if f.puts == nil {
|
|
f.puts = map[string][]byte{}
|
|
}
|
|
f.puts[key] = raw
|
|
return uint64(len(f.puts)), nil
|
|
}
|
|
|
|
// forgeKeygen stands in for ssh-keygen: it writes the pair the command names.
|
|
func forgeKeygen(_ context.Context, _ []string, name string, args ...string) forgekey.Ran {
|
|
var file, comment string
|
|
for i, a := range args {
|
|
switch a {
|
|
case "-f":
|
|
file = args[i+1]
|
|
case "-C":
|
|
comment = args[i+1]
|
|
}
|
|
}
|
|
if name != "ssh-keygen" || file == "" {
|
|
return forgekey.Ran{Status: 1, Stderr: "unexpected"}
|
|
}
|
|
os.WriteFile(file, []byte("PRIVATE\n"), 0o600)
|
|
os.WriteFile(file+".pub", []byte(forgeEd25519Line(comment)+"\n"), 0o644)
|
|
return forgekey.Ran{}
|
|
}
|
|
|
|
// 1484/AC3: given the bundle started with an empty home, the keeper writes <node>.<module> with kind builder, and
|
|
// the key is in the home's .ssh.
|
|
func TestTheKeeperWritesTheModulesEntryWithKindBuilder(t *testing.T) {
|
|
home, st := t.TempDir(), &forgeKeys{}
|
|
if err := keeperFor(home, "ace", forgeModule, forgeKeygen, st).Once(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(home, ".ssh", "mesh_ed25519")); err != nil {
|
|
t.Errorf("no key in an empty home: %v", err)
|
|
}
|
|
raw, ok := st.puts["ace."+forgeModule]
|
|
if !ok || len(st.puts) != 1 {
|
|
t.Fatalf("entries = %v, want one at ace.%s", st.puts, forgeModule)
|
|
}
|
|
var e forgekey.KeyEntry
|
|
if err := json.Unmarshal(raw, &e); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if e.Kind != "builder" || e.Node != "ace" || e.Account != forgeModule || !strings.HasPrefix(e.Public, "ssh-ed25519 ") {
|
|
t.Errorf("entry = %s", raw)
|
|
}
|
|
if strings.Contains(strings.ToUpper(string(raw)), "PRIVATE") {
|
|
t.Errorf("the entry carries private material: %s", raw)
|
|
}
|
|
}
|
|
|
|
// The home the manifest places is the one the account is given, so the keeper's home and the templates' agree.
|
|
func TestTheAccountsHomeIsTheOneTheManifestNames(t *testing.T) {
|
|
m := readForgeManifest(t)
|
|
for _, r := range m.Resources {
|
|
if r["type"] == "user" && r["name"] == forgeModule && r["home"] != forgeHome {
|
|
t.Errorf("the account's home is %v, want %s", r["home"], forgeHome)
|
|
}
|
|
}
|
|
}
|