diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index a825a3a..bece43e 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -857,6 +857,28 @@ func checkResources(modules []Manifest) []string { // does not exist is the manifest's own problem, refused where it was made. dirs := dirsFor(m, Rendering{}) for _, r := range m.Resources { + if fmt.Sprint(r["type"]) == "user" { + // **An account is shared; what it is set to is not.** Several modules may need one + // login: the shell's module sets its shell, the container runtime's puts it in the + // `docker` group. The host only ever adds groups — it never takes the account out of + // one, not even when the resource that named it is undeclared — so groups from + // several modules cannot contradict each other and are not owned. A shell or a home + // is one value, and two modules setting it would each be undone by the other's + // apply: each stays one module's per node, and two are refused naming both. + name, _ := r["name"].(string) + for _, field := range []string{"shell", "home"} { + if v, ok := r[field].(string); !ok || v == "" || name == "" { + continue + } + key := "user " + field + " " + name + if other, taken := owner[key]; taken && other != m.Module { + problems = append(problems, fmt.Sprintf( + "%s and %s both set the %s of the user %q", other, m.Module, field, name)) + } + owner[key] = m.Module + } + continue + } for _, field := range []string{"path", "unit", "name", "package"} { value, ok := r[field].(string) if !ok || value == "" { diff --git a/internal/catalogue/shared_account_test.go b/internal/catalogue/shared_account_test.go new file mode 100644 index 0000000..16a7f74 --- /dev/null +++ b/internal/catalogue/shared_account_test.go @@ -0,0 +1,42 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// One account, several modules: the shell's module sets its shell, the container runtime's adds it +// to a group. Groups are only ever added by the host, so they are contributed; a shell or a home is +// one value, owned by one module per node. + +func userResource(fields map[string]any) map[string]any { + r := map[string]any{"id": "operator", "type": "user", "name": "op"} + for k, v := range fields { + r[k] = v + } + return r +} + +func TestAShellAndAGroupOnOneAccountFromTwoModulesResolve(t *testing.T) { + zsh := Manifest{Module: "zsh", Resources: []map[string]any{userResource(map[string]any{"shell": "/usr/bin/zsh"})}} + docker := Manifest{Module: "docker", Resources: []map[string]any{userResource(map[string]any{"groups": []any{"docker"}})}} + other := Manifest{Module: "media", Resources: []map[string]any{userResource(map[string]any{"groups": []any{"video"}})}} + if problems := checkResources([]Manifest{zsh, docker, other}); len(problems) != 0 { + t.Fatalf("a shell and two modules' groups on one account were refused: %v", problems) + } + if _, err := Resolve(shelf(zsh, docker, other), []string{"zsh", "docker", "media"}, workstation(), World{}); err != nil { + t.Fatalf("the three did not resolve together: %v", err) + } +} + +func TestTwoModulesSettingOneAccountsShellOrHomeAreRefused(t *testing.T) { + for _, field := range []string{"shell", "home"} { + a := Manifest{Module: "zsh", Resources: []map[string]any{userResource(map[string]any{field: "/one"})}} + b := Manifest{Module: "fish", Resources: []map[string]any{userResource(map[string]any{field: "/two", "groups": []any{"x"}})}} + problems := checkResources([]Manifest{a, b}) + want := `zsh and fish both set the ` + field + ` of the user "op"` + if len(problems) != 1 || !strings.Contains(problems[0], want) { + t.Errorf("two modules setting %s gave %v, want %q", field, problems, want) + } + } +}