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.
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 the Ansible module'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 (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), Ansible's own int(floor(expires)) // 86400 (or -1, lchage's clear-value, for a negative timestamp).
Live-verified: the Ansible 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
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 Ansible's comma-split garbage does.
Parses a single getent passwd <name> line:
"name:password:uid:gid:comment:home:shell"
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 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"(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 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 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 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 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 -
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.