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) } }