A login the mesh set is given back, and undeclaring one no longer stops the apply (hq issue 225)

A user had no removal, so an undeclared one failed as an orphan and
aborted every apply after. Removal now keeps the account, gives back
the shell recorded when the mesh first changed it if it is still the
mesh's and still usable, and says why otherwise (hq ADR 0176 §2).
A shell is refused before it is set unless it is executable and listed
in /etc/shells, since usermod succeeds on a missing one.
This commit is contained in:
jochen
2026-10-04 03:57:34 +02:00
parent 27fb22fddb
commit 5d5dccdd55
5 changed files with 486 additions and 19 deletions
+56
View File
@@ -19,7 +19,9 @@ import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
@@ -125,6 +127,60 @@ func GroupsOf(ctx context.Context, run Runner, name string) ([]string, error) {
return strings.Fields(out), nil
}
// shells is where the machine lists the shells a login may have (shells(5)). A variable so a test
// can point it at a list of its own; ShellsIn is how.
var shells = "/etc/shells"
// ShellsIn points where the machine's shells are listed at a file a test owns, until the returned
// function puts it back. Nothing outside a test calls it.
func ShellsIn(list string) (restore func()) {
was := shells
shells = list
return func() { shells = was }
}
// UsableShell says why a path cannot be an account's login shell, or nil when it can.
//
// **Asked before a shell is set, because nothing after it would say.** `usermod --shell` only
// warns about a shell that is missing or not executable, and succeeds; the host's read-back
// compares the user database's string, which then matches. So an account could be pointed at a
// shell that is not there, and console, ssh and display-manager logins all fail — after a
// failed package install, say, which does not stop the resources after it (novox/hq issue 225).
//
// Listed among the machine's shells as well as executable, because that list is what login
// services check: an unlisted shell is one ssh and the display manager may refuse.
//
// **Except a shell that refuses a login.** nologin and false are how a service's account says it
// is not a login at all, and no distribution lists them — the controller's own account has one.
// Requiring them listed would refuse every service account; they are still required to exist.
func UsableShell(path string) error {
if !filepath.IsAbs(path) {
return fmt.Errorf("the shell %q is not an absolute path", path)
}
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("the shell %s is not on this machine: %w", path, err)
}
if !info.Mode().IsRegular() || info.Mode().Perm()&0o111 == 0 {
return fmt.Errorf("the shell %s is not an executable file", path)
}
if base := filepath.Base(path); base == "nologin" || base == "false" {
return nil
}
raw, err := os.ReadFile(shells)
if err != nil {
// Unreadable is not "not listed": the two are told apart, as the user database's are.
return fmt.Errorf("the machine's shells (%s) could not be read, so %s cannot be checked: %w",
shells, path, err)
}
for _, line := range strings.Split(string(raw), "\n") {
if line = strings.TrimSpace(line); line == path {
return nil
}
}
return fmt.Errorf("the shell %s is not listed in %s, so logins may refuse it", path, shells)
}
// Supports reports whether this host can apply a shape.
func Supports(s System, t declaration.Type) bool {
for _, shape := range s.Shapes() {