Krikri::PluginHelpers::UserState
UserState - pure logic for parsing getent passwd output and
deciding what (if anything) needs to change to reconcile a user
account with its desired state. No I/O here: the plugin itself calls
getent/useradd/etc and hands the results in as plain strings.
Class methods
chage -m <min> -M <max> -W <warn> <name> flags needed to
reconcile the account's current password-ageing fields with the
desired ones - only ever emitted for a param that was actually
given (non-nil) and differs from the current value, same
changed_flag convention every other *_flags helper here uses.
Real Ansible's own usermod-path idempotency check compares
/etc/shadow's own expire field (whole days since epoch) against
the requested timestamp's own day-since-epoch, NOT a full-
precision timestamp comparison - a expires: value that maps to
the SAME calendar day as what's already set is treated as
unchanged, matching int(math.floor(expires)) // 86400 against
current_expires (itself already whole days from /etc/shadow).
expires:'s own Unix-timestamp-to-useradd/usermod--e-value
conversion - verified against real ansible/modules/user.py's own
source: time.gmtime(timestamp) then strftime('%Y-%m-%d', ...)
(UTC, matching time.gmtime's own UTC-not-local semantics) for a
non-negative timestamp; a NEGATIVE timestamp (real Ansible's own
documented "-1 to remove" convention) maps to the empty string,
useradd/usermod -e '' being how those commands themselves
clear an existing expiration date.
local: true's expires conversion - unlike the normal path's
useradd/usermod -e YYYY-MM-DD, libuser's lchage takes whole DAYS
since epoch (-E), real Ansible's own int(floor(expires)) // 86400 (or -1, lchage's clear-value, for a negative timestamp).
Live-verified: the real module emits lchage -E 21915 for
expires: 1893456000.
groups: "{{ list_var }}" (a full-value bare-variable
substitution of a real multi-item list, as opposed to a literal
YAML groups: list - already comma-joined by the parser before
this ever runs) arrives as the double-quoted JSON the wire
serialized it to (see substitute_task_params's whole-single-span
comment) - parse it back into real elements to comma-join for
useradd -G. Previously the whole bracketed string went into
useradd -G as ONE malformed group-list argument and useradd
failed "group ... does not exist" for the bracket-stuck pieces.
Found via kostiantyn-nemchenko.mongodb_exporter's own
groups: "{{ mongodb_exporter_system_groups }}" on user:'s
create (useradd) path.
ONLY valid JSON, though - never a Python-repr repair pass: a
value that merely LOOKS like a container (a literal
groups: "['a']" string, or a {% if %}...{% else %}['a']{% endif %} block's rendered output) is a plain STRING in real
ansible-core - native typing requires the template's whole AST
to be one output node wrapping one expression, so block-tag
output is never re-parsed (live-verified vs ansible-playbook
2.19.11, see apt.cr's parse_package_names). The repr-looking
string passes through unchanged and useradd -G fails on it
exactly like real Ansible's comma-split garbage does.
Parses a single getent passwd <name> line:
"name:password:uid:gid:comment:home:shell"
Real Ansible compares password hashes with a leading ! (its own
lock-marker prefix) stripped from both sides before comparing -
verified against its own info[1].lstrip('!') != self.password.lstrip('!')
- so locking/unlocking alone never looks like a password change.
usermod flags to reconcile an existing account's password/lock
state - verified against real Ansible's own modify_user_usermod
logic (including the real, easy-to-miss detail that a queued -L/
-U gets folded into -p '!hash'/-p hash instead, rather than
coexisting with it, whenever both a password update and a lock
state are requested together - -p and -L/-U are mutually
exclusive usermod flags).
update_password: "on_create"(real Ansible's other allowed value, vs. the default"always") means an existing account's password is never touched here at all, only at creation time - this codebase's own mysql_user.cr already documents the same simplification for MySQL: unlike real Ansible, this can't compare a candidate cleartext password to a stored hash (the caller is always expected to pass an already-hashed value, same as real Ansible itself requires), so "unchanged" here means "the given hash already matches what's stored," not "the account's password is already this."
/etc/shadow's own account-expiration field (the 8th
colon-separated field, days since epoch) - nil if the account
has no expiration set or no shadow entry at all.
Extracts the /etc/shadow password-hash field (the 2nd
colon-separated field) for name from a real /etc/shadow's
full content - verified against real Ansible's own
parse_shadow_file (the fallback it uses when Python's spwd
module isn't available, which is the only shadow-reading strategy
this codebase replicates - no getspnam-equivalent libc binding
is needed just to read a field this codebase can already get by
shelling cat /etc/shadow, the same remote_exec local/remote
split every other plugin already uses). nil if the account has no
shadow entry at all (matches real Ansible's own empty-string
default becoming an empty comparison target).
useradd argument list for a brand new account. Desired values that are nil are simply omitted, letting useradd apply its own defaults.
useradd -p <hash> (or -p '!<hash>' when password_lock: true -
real Ansible's own convention for "set this password, but start
the account locked").
usermod flags needed to reconcile an existing account with the desired attributes. Only attributes that were actually requested (non-nil) and differ from the current value produce a flag.