cubes: user:add gets optional PUBKEY, space-separated GROUPS, exists guard

PUBKEY defaults to empty now — empty means no key is authorised, and some
users need none. GROUPS was always split on whitespace by deploy.py, so the
comma-separated prompt label and README were documenting a bug; both now say
space-separated. The deploy script checks the Users fact up front and noops
when the user exists, since rerunning reset the password and overwrote
~/.config/fish.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ce5atB2tXDXyz2jd9s1bqE
This commit is contained in:
Benjamin Diedrichsen
2026-09-02 13:36:51 +02:00
co-authored by Claude Fable 5
parent f1cc9effa0
commit 643d7379ba
3 changed files with 86 additions and 78 deletions
@@ -47,22 +47,19 @@ This cube creates a new user account with a modern shell environment (Fish), SSH
credential nobody had seen, and replaying that run produced a different one. credential nobody had seen, and replaying that run produced a different one.
- **GROUPS** (string, default: `''`) - **GROUPS** (string, default: `''`)
- Comma-separated list of additional groups (e.g., `"docker,sudo"`) - Space-separated list of additional groups (e.g., `"docker sudo"`)
- Common groups: - Common groups:
- `docker` - Run Docker without sudo - `docker` - Run Docker without sudo
- `sudo` - Administrative privileges - `sudo` - Administrative privileges
- `www-data` - Web server file access - `www-data` - Web server file access
- **PUBKEY** (string, **required** — no default) - **PUBKEY** (string, default: `''`)
- SSH public key to authorize for the user - SSH public key to authorize for the user
- Should be your public key for passwordless SSH access - Empty (the default) authorizes no key at all — the account is created with
- There is deliberately no default. It used to be a specific personal key, so password login only. Some users simply do not need one.
accepting the default authorized *someone else's* key on the new account. - The default is deliberately empty, never a specific key. It used to be a
No key would be a sensible guess, so the cube asks instead. personal key, so accepting the default authorized *someone else's* key on
- Because it is required, `--use-defaults` refuses to run this cube unless the new account.
`PUBKEY` comes from `env` in `.nopyrc.json`, a dependency, or a hook.
- Submitting an empty value at the prompt authorizes no key at all (the account
is still created, with password login only).
## Dependencies ## Dependencies
@@ -87,6 +84,9 @@ After deployment:
## Notes ## Notes
- If the user already exists, the cube does nothing at all — rerunning it would
reset the password and overwrite `~/.config/fish`, so an existing account is
left untouched.
- The user's home directory is created at `/home/{USER}` - The user's home directory is created at `/home/{USER}`
- Fish configuration is stored in `/home/{USER}/.config/fish/` - Fish configuration is stored in `/home/{USER}/.config/fish/`
- Oh My Fish provides package management: `omf install <package>` - Oh My Fish provides package management: `omf install <package>`
@@ -1,6 +1,6 @@
from pyinfra import host from pyinfra import host
from pyinfra.operations import server, files, apt from pyinfra.operations import server, files, apt
from io import StringIO from pyinfra.facts.server import Users
# Define the username, password, and public key for the new admin user # Define the username, password, and public key for the new admin user
USER = host.data.USER USER = host.data.USER
@@ -11,22 +11,28 @@ PASSWORD = host.data.PASSWORD
# line, so an absent key means no key rather than a blank one. # line, so an absent key means no key rather than a blank one.
PUBKEY = host.data.PUBKEY PUBKEY = host.data.PUBKEY
PUBKEYS = [PUBKEY] if PUBKEY and str(PUBKEY).strip() else [] PUBKEYS = [PUBKEY] if PUBKEY and str(PUBKEY).strip() else []
GROUPS = list(filter(None, map(str.strip, str(host.data.GROUPS).split()))) GROUPS = str(host.data.GROUPS).split()
FISH_PATH = "/usr/bin/fish" FISH_PATH = "/usr/bin/fish"
FISH_CONFIG_DIR = f"{HOME_DIR}/.config/fish" FISH_CONFIG_DIR = f"{HOME_DIR}/.config/fish"
FISH_CONFIG_FILE = f"{FISH_CONFIG_DIR}/config.fish" FISH_CONFIG_FILE = f"{FISH_CONFIG_DIR}/config.fish"
FISH_RC_DIR = f"{FISH_CONFIG_DIR}/rc" FISH_RC_DIR = f"{FISH_CONFIG_DIR}/rc"
SSH_AGENT_SCRIPT = f"{FISH_RC_DIR}/ssh-agent.fish" SSH_AGENT_SCRIPT = f"{FISH_RC_DIR}/ssh-agent.fish"
apt.packages( # An existing user is left entirely alone — everything below would reset the
# password and overwrite ~/.config/fish, clobbering whatever the user has
# changed since their account was created.
if host.get_fact(Users).get(USER):
host.noop(f"User {USER} already exists")
else:
apt.packages(
name='Ensure fish shell is installed', name='Ensure fish shell is installed',
packages=[ 'fish'], packages=[ 'fish'],
_sudo=True _sudo=True
) )
# Ensure the user exists with a login shell # Ensure the user exists with a login shell
server.user( server.user(
name=f"Create user {USER} [{GROUPS}]", name=f"Create user {USER} [{GROUPS}]",
present=True, present=True,
user=USER, user=USER,
@@ -36,9 +42,9 @@ server.user(
shell=FISH_PATH, shell=FISH_PATH,
public_keys=PUBKEYS, public_keys=PUBKEYS,
_sudo=True _sudo=True
) )
for dir in [f"{HOME_DIR}/.ssh", FISH_RC_DIR, TMP_DIR]: for dir in [f"{HOME_DIR}/.ssh", FISH_RC_DIR, TMP_DIR]:
files.directory( files.directory(
name=f"Ensure {dir} directory exists", name=f"Ensure {dir} directory exists",
path=dir, path=dir,
@@ -51,16 +57,16 @@ for dir in [f"{HOME_DIR}/.ssh", FISH_RC_DIR, TMP_DIR]:
_use_sudo_login=True _use_sudo_login=True
) )
files.file( files.file(
name="Ensure .ssh/config exists", name="Ensure .ssh/config exists",
path=f"{HOME_DIR}/.ssh/config", path=f"{HOME_DIR}/.ssh/config",
present=True, present=True,
user=USER, user=USER,
group=USER, group=USER,
_sudo=True _sudo=True
) )
server.shell( server.shell(
name=f"Install OMF(Oh My Fish) for {USER}", name=f"Install OMF(Oh My Fish) for {USER}",
commands=[ commands=[
f"curl https://raw.githubusercontent.com/oh-my-fish/oh-my-fish/master/bin/install > install-omf", f"curl https://raw.githubusercontent.com/oh-my-fish/oh-my-fish/master/bin/install > install-omf",
@@ -69,9 +75,9 @@ server.shell(
_sudo=True, _sudo=True,
_sudo_user=USER, _sudo_user=USER,
_use_sudo_login=True _use_sudo_login=True
) )
files.put( files.put(
name="Add SSH agent auto-load script to Fish rc directory", name="Add SSH agent auto-load script to Fish rc directory",
src="ssh-agent.fish", src="ssh-agent.fish",
dest=SSH_AGENT_SCRIPT, dest=SSH_AGENT_SCRIPT,
@@ -80,9 +86,9 @@ files.put(
mode="755", # Make it executable mode="755", # Make it executable
_sudo=True, _sudo=True,
) )
files.put( files.put(
name="Add custom config.fish", name="Add custom config.fish",
src="config.fish", src="config.fish",
dest=FISH_CONFIG_FILE, dest=FISH_CONFIG_FILE,
@@ -90,4 +96,4 @@ files.put(
group=USER, group=USER,
mode="755", # Make it executable mode="755", # Make it executable
_sudo=True, _sudo=True,
) )
@@ -17,12 +17,14 @@ export default Manifest({
PASSWORD: z.string().describe('Password for the new user account').default('changeme'), PASSWORD: z.string().describe('Password for the new user account').default('changeme'),
GROUPS: z GROUPS: z
.string() .string()
.describe('Comma-separated list of additional groups (e.g., "docker,sudo")') .describe('Space-separated list of additional groups (e.g., "docker sudo")')
.default(''),
// Empty by default, never a specific key: this used to carry a personal
// key, which meant an unattended run authorised someone else's key on the
// new account. Empty means no key is authorised — some users need none.
PUBKEY: z
.string()
.describe('SSH public key to authorize for the user (empty for none)')
.default(''), .default(''),
// No default on purpose. This used to carry a specific personal key, which
// meant an unattended run authorised someone else's key on the new account.
// Leaving it required makes `--use-defaults` refuse by name instead of
// guessing, and there is no key that would be a sensible guess.
PUBKEY: z.string().describe('SSH public key to authorize for the user'),
}), }),
}); });