Feature 57 (config and provisioning): modules declare config and provisions, their files rendered from templates; synced with main (issues of feature 57, 874)

This commit is contained in:
2026-10-16 07:37:43 +00:00
parent 1a2c917abf
commit 9652b2d5ce
771 changed files with 52063 additions and 1774 deletions
@@ -14,6 +14,8 @@ import (
type manifest struct {
Module string `json:"module"`
Version string `json:"version"`
Moved bool `json:"moved"`
Items []item `json:"items"`
Capabilities []string `json:"capabilities"`
Requires []string `json:"requires"`
Claims []claim `json:"claims"`
@@ -44,6 +46,53 @@ type shellCode struct {
Code string `json:"code"`
}
// An item of a collection (novox/hq ADR 0349 clause 13): its text is the template file beside the manifest.
type item struct {
Collection string `json:"collection"`
Name string `json:"name"`
Attributes map[string]string `json:"attributes"`
Template string `json:"template"`
}
// shell is the code the module adds to a shell's or the session's start, read from the templates of its items of
// the collections shell:<file>:<slot>, in the order they are declared.
func (m manifest) shell(t *testing.T) []shellCode {
t.Helper()
var out []shellCode
for _, it := range m.Items {
parts := strings.Split(it.Collection, ":")
if len(parts) != 3 || parts[0] != "shell" {
continue
}
text, err := os.ReadFile(filepath.Join("..", "..", it.Template))
if err != nil {
t.Fatal(err)
}
out = append(out, shellCode{For: parts[1], Slot: parts[2], Code: string(text)})
}
return out
}
// environmentOf is the variables the module sets, read from the templates of its items of the environment.
func (m manifest) environmentOf(t *testing.T) *environment {
t.Helper()
var env *environment
for _, it := range m.Items {
if it.Collection != "environment" || it.Attributes["kind"] != "variable" {
continue
}
text, err := os.ReadFile(filepath.Join("..", "..", it.Template))
if err != nil {
t.Fatal(err)
}
if env == nil {
env = &environment{Variables: map[string]string{}}
}
env.Variables[it.Attributes["name"]] = strings.TrimSuffix(string(text), "\n")
}
return env
}
func readManifest(t *testing.T) manifest {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
@@ -56,6 +105,7 @@ func readManifest(t *testing.T) manifest {
if err := dec.Decode(&m); err != nil {
t.Fatalf("module.json: %v", err)
}
m.Environment = m.environmentOf(t)
return m
}
@@ -83,26 +133,66 @@ func (m manifest) packages() (present, absent []string) {
return present, absent
}
// sameAsSource checks that a file resource's content is byte for byte the module's source file, so
// the readable file in the repository is what the machine gets.
func (m manifest) sameAsSource(t *testing.T, id, source string) {
// fromTemplate checks that a file resource takes its text from its template file in the module's
// repository (novox/hq ADR 0349 §2): it names the template, carries no text of its own beside it, and the
// template is there. The readable file in the repository is the one place the machine's text is written.
func (m manifest) fromTemplate(t *testing.T, id, template string) {
t.Helper()
want, err := os.ReadFile(filepath.Join("..", "..", source))
if err != nil {
t.Fatal(err)
}
r := m.resource(t, id)
if r["type"] != "file" {
t.Fatalf("%s is a %v, not a file", id, r["type"])
}
if got, _ := r["content"].(string); got != string(want) {
t.Fatalf("resource %s's content is not %s: edit the source and copy it into module.json", id, source)
if r["template"] != template {
t.Fatalf("resource %s does not take its text from %s: template %v", id, template, r["template"])
}
if _, inline := r["content"]; inline {
t.Fatalf("resource %s keeps its text inline beside its template: a file's text is in one place", id)
}
if _, err := os.Stat(filepath.Join("..", "..", template)); err != nil {
t.Fatalf("resource %s names %s: %v", id, template, err)
}
if r["owner"] != "${machine:account}" && !strings.HasPrefix(r["path"].(string), "/etc/") {
t.Fatalf("%s under the home is the account's", id)
}
}
// everyFileIsATemplate checks that no file resource carries its text inline, and that the build packs
// files/ into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
func (m manifest) everyFileIsATemplate(t *testing.T) {
t.Helper()
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"])
}
}
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)
}
}
// text is the text a file resource places on the machine: its template file's.
func (m manifest) text(t *testing.T, id string) string {
t.Helper()
name, _ := m.resource(t, id)["template"].(string)
if name == "" {
t.Fatalf("resource %s names no template", id)
}
raw, err := os.ReadFile(filepath.Join("..", "..", name))
if err != nil {
t.Fatal(err)
}
return string(raw)
}
// checkTheToolsAgree checks that the manifest lists the module's own tools exactly, that the bundle
// serves each seat verb the claims promise as <seat>.<verb>, and that the Go bundle is declared.
func checkTheToolsAgree(t *testing.T, m manifest) {
@@ -29,13 +29,13 @@ func TestThePAMLinesAreBlocksWrittenIntoTheStacks(t *testing.T) {
"pam-login": {"/etc/pam.d/login", "files/pam/login"},
"pam-passwd": {"/etc/pam.d/passwd", "files/pam/passwd"},
} {
m.sameAsSource(t, id, want.source)
m.fromTemplate(t, id, want.source)
r := m.resource(t, id)
if r["path"] != want.path || r["into"] != "block" || r["at"] != "end" || r["owner"] != nil {
t.Errorf("%s: %v", id, r)
}
}
login := m.resource(t, "pam-login")["content"].(string)
login := m.text(t, "pam-login")
if !strings.Contains(login, "\nauth optional pam_gnome_keyring.so\n") ||
!strings.Contains(login, "\nsession optional pam_gnome_keyring.so auto_start\n") {
t.Fatalf("%s", login)
@@ -44,10 +44,10 @@ func TestThePAMLinesAreBlocksWrittenIntoTheStacks(t *testing.T) {
func TestItStartsNoDaemonAndOnlyNamesTheAgentsSocket(t *testing.T) {
m := readManifest(t)
if len(m.Shell) != 1 || m.Shell[0].For != "xinitrc" || m.Shell[0].Slot != "first" {
t.Fatalf("%+v", m.Shell)
if len(m.shell(t)) != 1 || m.shell(t)[0].For != "xinitrc" || m.shell(t)[0].Slot != "first" {
t.Fatalf("%+v", m.shell(t))
}
code := m.Shell[0].Code
code := m.shell(t)[0].Code
if strings.Contains(code, "gnome-keyring-daemon") || strings.Contains(code, "--unlock") ||
!strings.Contains(code, `SSH_AUTH_SOCK="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/gcr/ssh"`) {
t.Fatalf("%q", code)
@@ -62,3 +62,8 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) {
checkTheToolsAgree(t, m)
checkNoSecretsOrInstallationNames(t)
}
// No file resource carries its text inline (novox/hq ADR 0349 §2).
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
readManifest(t).everyFileIsATemplate(t)
}
@@ -0,0 +1,5 @@
# The ssh agent (module gnome-keyring, novox/hq ADR 0208): gcr's, which the account's service manager
# starts on first use from its socket. Named here, for the session and every terminal it starts, until
# the account's environment can say a path under the runtime directory (see the module's README).
SSH_AUTH_SOCK="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/gcr/ssh"
export SSH_AUTH_SOCK
+12 -6
View File
@@ -1,6 +1,7 @@
{
"module": "gnome-keyring",
"version": "1",
"moved": true,
"capabilities": [
"package-manager"
],
@@ -16,11 +17,11 @@
"gnome_keyring_collections",
"gnome_keyring_ssh_keys"
],
"shell": [
"items": [
{
"for": "xinitrc",
"slot": "first",
"code": "# The ssh agent (module gnome-keyring, novox/hq ADR 0208): gcr's, which the account's service manager\n# starts on first use from its socket. Named here, for the session and every terminal it starts, until\n# the account's environment can say a path under the runtime directory (see the module's README).\nSSH_AUTH_SOCK=\"${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/gcr/ssh\"\nexport SSH_AUTH_SOCK\n"
"collection": "shell:xinitrc:first",
"name": "xinitrc-first",
"template": "files/xinitrc-first"
}
],
"resources": [
@@ -46,7 +47,7 @@
"mode": "0644",
"into": "block",
"at": "end",
"content": "# The login keyring (module gnome-keyring, novox/hq ADR 0208, ADR 0102): the password typed at the\n# login screen unlocks the keyring, and the login session starts the keyring daemon with it.\nauth optional pam_gnome_keyring.so\nsession optional pam_gnome_keyring.so auto_start\n"
"template": "files/pam/login"
},
{
"id": "pam-passwd",
@@ -55,7 +56,7 @@
"mode": "0644",
"into": "block",
"at": "end",
"content": "# The login keyring (module gnome-keyring, novox/hq ADR 0208, ADR 0102): changing the account's\n# password changes the login keyring's with it, so the next login still unlocks it.\npassword optional pam_gnome_keyring.so\n"
"template": "files/pam/passwd"
}
],
"build": {
@@ -70,6 +71,11 @@
"loads": [
"gnome-keyring-tools"
]
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}