package main import ( "encoding/json" "os" "path/filepath" "strings" "testing" ) // The module's directory, where its manifest and its files/ are. const moduleDir = "../.." // templateText is the text a file resource places on the machine: its template's, in the module's files/ // (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test. func templateText(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(moduleDir, filepath.FromSlash(name))) if err != nil { t.Fatalf("resource %v names %s: %v", r["id"], name, err) } return string(raw) } // No file resource carries its text inline: each names its template in files/, and the build packs files/ // into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) if err != nil { t.Fatal(err) } var m struct { Resources []map[string]any `json:"resources"` Build struct { Artifacts []map[string]any `json:"artifacts"` } `json:"build"` } if err := json.Unmarshal(raw, &m); err != nil { t.Fatal(err) } for _, r := range m.Resources { if _, inline := r["content"]; inline { t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) } if r["type"] == "file" { templateText(t, r) } } packs := 0 for _, a := range m.Build.Artifacts { if a["kind"] == "archive" && a["from"] == "files" { packs++ if a["name"] != "files" { t.Errorf("the templates archive is named %v, not files", a["name"]) } } } if packs != 1 { t.Errorf("the build packs files/ into %d archives, not one", packs) } } // Issue 955, area S2: the module keeps the keys state and requires the forge's account provision. It declares // no provision `ssh-key`: the state serves the public half (novox/hq ADR 0400 §1). func TestManifestKeepsKeysAndRequiresForgeAccount(t *testing.T) { raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) if err != nil { t.Fatal(err) } var m struct { State []string `json:"state"` Requires []any `json:"requires"` Provides []any `json:"provides"` } if err := json.Unmarshal(raw, &m); err != nil { t.Fatal(err) } if len(m.State) != 1 || m.State[0] != "keys" { t.Errorf("state = %v, want [keys]", m.State) } if len(m.Requires) != 1 || m.Requires[0] != "forge-account" { t.Errorf("requires = %v, want [forge-account]", m.Requires) } if len(m.Provides) != 0 { t.Errorf("provides = %v, want none (no ssh-key provision yet)", m.Provides) } } // Issue 955, area S3: the three files the forge account fills in — known_hosts, the forge's Host, git's rewrite. var sampleBound = map[string]string{ "known_host": "[novox.internal]:222 ssh-ed25519 AAAAC3Nza", "ssh_host": "novox.internal", "ssh_port": "222", "ssh_base": "ssh://git@novox.internal:222/", "https_base": "https://git.novox.be/", } // renderBound fills ${bound:forge-account:} with the sample values, as the provisioner client does. func renderBound(t *testing.T, s string) string { t.Helper() for k, v := range sampleBound { s = strings.ReplaceAll(s, "${bound:forge-account:"+k+"}", v) } if strings.Contains(s, "${") { t.Errorf("unfilled placeholder in %q", s) } return s } func resourceByPath(t *testing.T, path string) map[string]any { t.Helper() raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) if err != nil { t.Fatal(err) } var m struct { Resources []map[string]any `json:"resources"` } if err := json.Unmarshal(raw, &m); err != nil { t.Fatal(err) } for _, r := range m.Resources { if r["path"] == path { if r["owner"] != "${machine:account}" { t.Errorf("%s is owned by %v, not the machine's account", path, r["owner"]) } return r } } t.Fatalf("no resource at %s", path) return nil } func TestForgeFilesRenderFromTheBoundValues(t *testing.T) { home := "${machine:account-home}" kh := resourceByPath(t, home+"/.ssh/known_hosts") if kh["into"] != "block" { t.Errorf("known_hosts is %v, want a block", kh["into"]) } if got := strings.TrimSpace(renderBound(t, templateText(t, kh))); got != "[novox.internal]:222 ssh-ed25519 AAAAC3Nza" { t.Errorf("known_hosts block = %q", got) } forge := resourceByPath(t, home+"/.ssh/config.d/10-forge") if forge["into"] != nil { t.Errorf("10-forge is a block, want the whole file") } want := "Host novox.internal\n Port 222\n User git\n IdentityFile ~/.ssh/mesh_ed25519\n IdentitiesOnly yes\n" if got := renderBound(t, templateText(t, forge)); got != want { t.Errorf("10-forge = %q, want %q", got, want) } resourceByPath(t, home+"/.config/git") // the directory g := resourceByPath(t, home+"/.config/git/config") if g["into"] != "block" { t.Errorf("git config is %v, want a block", g["into"]) } wantGit := "[url \"ssh://git@novox.internal:222/\"]\n\tinsteadOf = https://git.novox.be/\n" if got := renderBound(t, templateText(t, g)); got != wantGit { t.Errorf("git config block = %q, want %q", got, wantGit) } } // The mesh's region in ~/.ssh/config brings in config.d/*, so 10-forge is read. func TestConfigIncludesTheDropInDirectory(t *testing.T) { raw, err := os.ReadFile(filepath.Join(moduleDir, "files", "config")) if err != nil { t.Fatal(err) } if !strings.Contains(string(raw), "Include ~/.ssh/config.d/*") { t.Errorf("files/config does not include config.d/*") } }