Issue 1585, S19: the strict parser, every manifest problem function and the judge's tables (192 declarations, about 5,200 lines) and their 244 tests leave; composition keeps its own refusals and the helpers it uses. The catalogue's tests decode the manifests they build. ParseManifest, ParseManifestWith, ParseManifestForInputs, CatalogueProblems and the rest are gone; mesh-delivery-indexer/judge holds them. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
303 lines
14 KiB
Go
303 lines
14 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// Defends novox/hq ADR 0203 (the account's environment is one module's, and every module
|
|
// contributes to it) and ADR 0204 (shell code in named slots, placed by the login shell's holder).
|
|
|
|
// contributors is a fixed set of contributions, in no particular order: what the renderings are
|
|
// asserted against byte for byte. go-toolchain and zsh both put ~/.local/bin on PATH, which is the
|
|
// ordinary case of two modules sharing a directory, and is written once.
|
|
func contributors() []Manifest {
|
|
return []Manifest{
|
|
{Module: "zsh", Environment: &Environment{
|
|
Variables: map[string]string{"XDG_CONFIG_HOME": "${machine:account-home}/.config", "EDITOR": "vim"},
|
|
Path: []PathEntry{
|
|
{Entry: "${machine:account-home}/.local/bin", At: PathAtStart},
|
|
{Entry: "${machine:account-home}/bin", At: PathAtStart},
|
|
{Entry: "/opt/scripts", At: PathAtEnd},
|
|
},
|
|
}},
|
|
{Module: "go-toolchain", Environment: &Environment{
|
|
Variables: map[string]string{"GOPATH": "${machine:account-home}/go"},
|
|
Path: []PathEntry{
|
|
{Entry: "${machine:account-home}/go/bin", At: PathAtStart},
|
|
{Entry: "/usr/local/go/bin", At: PathAtStart},
|
|
{Entry: "${machine:account-home}/.local/bin", At: PathAtStart},
|
|
},
|
|
}},
|
|
{Module: "agent", Environment: &Environment{
|
|
Variables: map[string]string{"DISABLE_AUTOUPDATER": "1"},
|
|
Path: []PathEntry{{Entry: "/opt/agent/bin", At: PathAtEnd}},
|
|
}},
|
|
// A module contributing nothing is in the set and writes nothing.
|
|
{Module: "postgres"},
|
|
}
|
|
}
|
|
|
|
var operatorFacts = map[string]string{"name": "workstation", "account": "op", "account-home": "/home/op"}
|
|
|
|
// The final PATH this set composes, around whatever the account had: the start entries in module
|
|
// order and then declared order, the account's own, then the end entries.
|
|
const composedPOSIX = `# agent
|
|
export DISABLE_AUTOUPDATER='1'
|
|
# go-toolchain
|
|
export GOPATH='/home/op/go'
|
|
# zsh
|
|
export EDITOR='vim'
|
|
export XDG_CONFIG_HOME='/home/op/.config'
|
|
# zsh
|
|
case ":${PATH}:" in *':/home/op/bin:'*) ;; *) PATH='/home/op/bin'"${PATH:+:${PATH}}" ;; esac
|
|
# go-toolchain
|
|
case ":${PATH}:" in *':/home/op/.local/bin:'*) ;; *) PATH='/home/op/.local/bin'"${PATH:+:${PATH}}" ;; esac
|
|
case ":${PATH}:" in *':/usr/local/go/bin:'*) ;; *) PATH='/usr/local/go/bin'"${PATH:+:${PATH}}" ;; esac
|
|
case ":${PATH}:" in *':/home/op/go/bin:'*) ;; *) PATH='/home/op/go/bin'"${PATH:+:${PATH}}" ;; esac
|
|
# agent
|
|
case ":${PATH}:" in *':/opt/agent/bin:'*) ;; *) PATH="${PATH:+${PATH}:}"'/opt/agent/bin' ;; esac
|
|
# zsh
|
|
case ":${PATH}:" in *':/opt/scripts:'*) ;; *) PATH="${PATH:+${PATH}:}"'/opt/scripts' ;; esac
|
|
export PATH
|
|
`
|
|
|
|
const composedSystemd = `# agent
|
|
DISABLE_AUTOUPDATER=1
|
|
# go-toolchain
|
|
GOPATH=/home/op/go
|
|
# zsh
|
|
EDITOR=vim
|
|
XDG_CONFIG_HOME=/home/op/.config
|
|
# go-toolchain, zsh
|
|
PATH=/home/op/go/bin:/usr/local/go/bin:/home/op/.local/bin:/home/op/bin${PATH:+:$PATH}
|
|
# agent, zsh
|
|
PATH=${PATH:+$PATH:}/opt/agent/bin:/opt/scripts
|
|
`
|
|
|
|
func TestTheEnvironmentRendersForAPOSIXShellByteForByte(t *testing.T) {
|
|
env, err := environmentOn(contributors(), operatorFacts)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := env.posix(); got != composedPOSIX {
|
|
t.Fatalf("the POSIX rendering is\n%s\nnot\n%s", got, composedPOSIX)
|
|
}
|
|
}
|
|
|
|
func TestTheEnvironmentRendersForTheServiceManagerByteForByte(t *testing.T) {
|
|
env, err := environmentOn(contributors(), operatorFacts)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := env.systemd(); got != composedSystemd {
|
|
t.Fatalf("the environment.d rendering is\n%s\nnot\n%s", got, composedSystemd)
|
|
}
|
|
}
|
|
|
|
// Sourcing twice changes nothing (ADR 0203 §3): a login shell that starts another reads the file
|
|
// again, and a PATH that grew each time would be the symptom. Run by a real `sh`, because the claim
|
|
// is about what a shell does with the file, not about what the file looks like.
|
|
|
|
// The environment.d rendering, read by the service manager's own generator where this machine has
|
|
// one — the same reader an account's user manager runs, so the PATH it composes is the one asserted.
|
|
//
|
|
// The generator also reads the machine's own environment.d (/etc, /run, /usr/lib, /usr/local/lib), and
|
|
// no option points it elsewhere: a desktop whose snapd appends its bin directory failed this, on main,
|
|
// for a file the mesh never wrote (novox/hq issue 432). So the generator is run twice, once without the
|
|
// mesh's file, and what the machine's own files make of PATH and set is held out of the verdict.
|
|
//
|
|
// A machine without the generator — the build seat's toolchain image holds no systemd — cannot judge
|
|
// this and says so: there the rendering is held byte for byte by
|
|
// TestTheEnvironmentRendersForTheServiceManagerByteForByte, and this reading only where systemd runs.
|
|
|
|
// Nothing contributed renders nothing, in both formats — not an empty `export PATH`.
|
|
func TestNoContributionsRenderNothing(t *testing.T) {
|
|
env, err := environmentOn([]Manifest{{Module: "postgres"}}, operatorFacts)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if env.posix() != "" || env.systemd() != "" {
|
|
t.Fatalf("an empty environment rendered %q and %q", env.posix(), env.systemd())
|
|
}
|
|
}
|
|
|
|
// A ${machine:…} fact the machine does not have is refused naming the module, as a file's is.
|
|
func TestAContributedFactTheMachineLacksIsRefused(t *testing.T) {
|
|
_, err := environmentOn(contributors(), map[string]string{"name": "server"})
|
|
if err == nil || !strings.Contains(err.Error(), "go-toolchain sets GOPATH") ||
|
|
!strings.Contains(err.Error(), "${machine:account-home}") {
|
|
t.Fatalf("a missing account home was not refused by name: %v", err)
|
|
}
|
|
}
|
|
|
|
// ADR 0203 §5: two modules setting one variable are refused, both named — neither silently wins.
|
|
func TestAVariableTwoModulesSetIsRefusedNamingBoth(t *testing.T) {
|
|
modules := append(contributors(), Manifest{Module: "neovim", Environment: &Environment{
|
|
Variables: map[string]string{"EDITOR": "nvim"}}})
|
|
_, err := environmentOn(modules, operatorFacts)
|
|
if err == nil || err.Error() != "neovim and zsh both set EDITOR on this machine; the account has one "+
|
|
"environment, so one of them must stop setting it (novox/hq ADR 0203)" {
|
|
t.Fatalf("a variable set twice was not refused naming both: %v", err)
|
|
}
|
|
// And at composition, whether or not the node holds the environment.
|
|
r := Resolution{Node: "workstation", Account: "op", Modules: modules}
|
|
if _, err := r.Declaration(Rendering{}); err == nil || !strings.Contains(err.Error(), "neovim and zsh both set EDITOR") {
|
|
t.Fatalf("composition accepted a variable set twice: %v", err)
|
|
}
|
|
}
|
|
|
|
// shells contributes code for several shells and slots, in no order.
|
|
func shells() []Manifest {
|
|
return []Manifest{
|
|
{Module: "zsh-syntax-highlighting", Shell: []ShellCode{
|
|
{For: "zsh", Slot: "last", Code: "source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh"},
|
|
}},
|
|
{Module: "powerlevel10k", Shell: []ShellCode{
|
|
{For: "zsh", Slot: "first", Code: "if [[ -r \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\" ]]; then\n" +
|
|
" source \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\"\nfi\n"},
|
|
{For: "zsh", Slot: "normal", Code: "source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme"},
|
|
{For: "zsh", Slot: "normal", Code: "[[ -f ~/.local/share/powerlevel10k/p10k.zsh ]] && source ~/.local/share/powerlevel10k/p10k.zsh"},
|
|
}},
|
|
{Module: "zsh-autosuggestions", Shell: []ShellCode{
|
|
{For: "zsh", Slot: "normal", Code: "source /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh"},
|
|
{For: "bash", Slot: "normal", Code: "echo not for zsh"},
|
|
}},
|
|
{Module: "direnv", Shell: []ShellCode{
|
|
{For: "fish", Slot: "last", Code: "direnv hook fish | source"},
|
|
{For: "bash", Slot: "last", Code: "eval \"$(direnv hook bash)\""},
|
|
}},
|
|
}
|
|
}
|
|
|
|
// ADR 0204 §3: a slot holds that shell's code only, in module order, each module's pieces in the
|
|
// order it declared them under a line naming it; empty when nothing is contributed.
|
|
func TestShellCodeLandsInItsSlotInModuleOrderForItsShellOnly(t *testing.T) {
|
|
if got, want := shellCode(shells(), "zsh", "normal"), "# powerlevel10k\n"+
|
|
"source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme\n"+
|
|
"[[ -f ~/.local/share/powerlevel10k/p10k.zsh ]] && source ~/.local/share/powerlevel10k/p10k.zsh\n"+
|
|
"# zsh-autosuggestions\n"+
|
|
"source /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh\n"; got != want {
|
|
t.Fatalf("zsh's normal slot is\n%s\nnot\n%s", got, want)
|
|
}
|
|
if got, want := shellCode(shells(), "zsh", "last"), "# zsh-syntax-highlighting\n"+
|
|
"source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh\n"; got != want {
|
|
t.Fatalf("zsh's last slot is\n%s\nnot\n%s", got, want)
|
|
}
|
|
if got, want := shellCode(shells(), "bash", "last"), "# direnv\neval \"$(direnv hook bash)\"\n"; got != want {
|
|
t.Fatalf("bash's last slot is %q, not %q", got, want)
|
|
}
|
|
if got := shellCode(shells(), "fish", "first"); got != "" {
|
|
t.Fatalf("a slot nobody contributed to holds %q", got)
|
|
}
|
|
}
|
|
|
|
// The holder of node-login-shell, as WP3's zsh module writes its block, with its own zsh around the
|
|
// slots — which holds `${…}` of the shell's own that no mesh pass may touch either.
|
|
func zshHolder() Manifest {
|
|
return Manifest{Module: "zsh", Claims: []Claim{{Name: LoginShellSeat, Scope: ScopeNode}},
|
|
Resources: []map[string]any{
|
|
{"id": "zshrc", "type": "file", "path": "${machine:account-home}/.zshrc", "content": "" +
|
|
"${shell:zsh:first}" +
|
|
"PROMPT='%n@%m ${PWD/#$HOME/~} '\n" +
|
|
"${shell:zsh:normal}" +
|
|
"alias ll='ls -l'\n" +
|
|
"${shell:zsh:last}"},
|
|
}}
|
|
}
|
|
|
|
// The case the ordering exists for: contributed zsh code full of `${…}` reaches the file byte for
|
|
// byte, because the shell's code is placed after every other placeholder pass and in one pass — a
|
|
// scanner for the mesh's placeholders that ran after it would read `${XDG_CACHE_HOME:-…}` and
|
|
// `${(%):-%n}` as the mesh's, or fill a `${machine:…}` some module wrote for its shell to see.
|
|
func TestShellCodeReachesTheHoldersFileByteForByte(t *testing.T) {
|
|
modules := append(shells(), zshHolder(), Manifest{Module: "sly", Shell: []ShellCode{
|
|
{For: "zsh", Slot: "last", Code: "echo ${machine:account-home} ${secret:x} ${shell:zsh:first} ${environment:posix}"},
|
|
}})
|
|
r := Resolution{Node: "workstation", Account: "op", Modules: modules}
|
|
out, err := r.Declaration(Rendering{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var zshrc map[string]any
|
|
for _, res := range out {
|
|
if res["id"] == "zsh.zshrc" {
|
|
zshrc = res
|
|
}
|
|
}
|
|
if zshrc == nil {
|
|
t.Fatalf("the holder's file was not composed: %v", out)
|
|
}
|
|
if zshrc["path"] != "/home/op/.zshrc" {
|
|
t.Fatalf("the holder's own placeholders were not filled first: %v", zshrc["path"])
|
|
}
|
|
want := "# powerlevel10k\n" +
|
|
"if [[ -r \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\" ]]; then\n" +
|
|
" source \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\"\nfi\n" +
|
|
"PROMPT='%n@%m ${PWD/#$HOME/~} '\n" +
|
|
"# powerlevel10k\n" +
|
|
"source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme\n" +
|
|
"[[ -f ~/.local/share/powerlevel10k/p10k.zsh ]] && source ~/.local/share/powerlevel10k/p10k.zsh\n" +
|
|
"# zsh-autosuggestions\n" +
|
|
"source /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh\n" +
|
|
"alias ll='ls -l'\n" +
|
|
"# sly\n" +
|
|
"echo ${machine:account-home} ${secret:x} ${shell:zsh:first} ${environment:posix}\n" +
|
|
"# zsh-syntax-highlighting\n" +
|
|
"source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh\n"
|
|
if got := zshrc["content"]; got != want {
|
|
t.Fatalf("the holder's .zshrc is\n%s\nnot\n%s", got, want)
|
|
}
|
|
}
|
|
|
|
// The holder of node-environment places both renderings, and they are the same as rendered alone.
|
|
func TestTheEnvironmentHolderPlacesBothRenderings(t *testing.T) {
|
|
holder := Manifest{Module: "node-env", Claims: []Claim{{Name: EnvironmentSeat, Scope: ScopeNode}},
|
|
Resources: []map[string]any{
|
|
{"id": "posix", "type": "file", "path": "${machine:account-home}/.config/mesh/environment.sh",
|
|
"content": "# The mesh's environment.\n${environment:posix}"},
|
|
{"id": "systemd", "type": "file", "path": "${machine:account-home}/.config/environment.d/50-mesh.conf",
|
|
"content": "${environment:systemd}"},
|
|
}}
|
|
r := Resolution{Node: "workstation", Account: "op", Modules: append(contributors(), holder)}
|
|
out, err := r.Declaration(Rendering{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
by := map[string]any{}
|
|
for _, res := range out {
|
|
by[res["id"].(string)] = res["content"]
|
|
}
|
|
if by["node-env.posix"] != "# The mesh's environment.\n"+composedPOSIX {
|
|
t.Fatalf("the POSIX file is\n%v", by["node-env.posix"])
|
|
}
|
|
if by["node-env.systemd"] != composedSystemd {
|
|
t.Fatalf("the environment.d file is\n%v", by["node-env.systemd"])
|
|
}
|
|
}
|
|
|
|
// ADR 0203 §1 and ADR 0204 §1: both seats are the mesh's own, held once per machine; the login
|
|
// shell's contract is `execute`, described and with a schema an agent can call.
|
|
func TestTheSeatTableCarriesTheEnvironmentAndTheLoginShell(t *testing.T) {
|
|
env, ok := SeatNamed("node-environment")
|
|
if !ok || env.Scope != ScopeNode || env.Decision != "novox/hq ADR 0203" ||
|
|
len(env.Serves)+len(env.Accepts)+len(env.Emits) != 0 || env.Delivers != "" {
|
|
t.Fatalf("node-environment is not a node seat with no protocol: %+v (defined %v)", env, ok)
|
|
}
|
|
shell, ok := SeatNamed("node-login-shell")
|
|
if !ok || shell.Scope != ScopeNode || shell.Decision != "novox/hq ADR 0204" {
|
|
t.Fatalf("node-login-shell is not a node seat: %+v (defined %v)", shell, ok)
|
|
}
|
|
if len(shell.Serves) != 1 || shell.Serves[0].Name != "execute" || shell.Serves[0].Description == "" {
|
|
t.Fatalf("the login shell serves %+v, not execute alone", shell.Serves)
|
|
}
|
|
props, _ := shell.Serves[0].Input["properties"].(map[string]any)
|
|
required, _ := shell.Serves[0].Input["required"].([]string)
|
|
if _, has := props["command"]; !has || len(required) != 1 || required[0] != "command" {
|
|
t.Fatalf("execute does not require a command: %v", shell.Serves[0].Input)
|
|
}
|
|
if _, has := props["timeout_seconds"]; !has {
|
|
t.Fatalf("execute takes no timeout: %v", props)
|
|
}
|
|
}
|