Skip to content

Add dry run support #1

@mrled

Description

@mrled

Plan: Dryrun/Check Mode for Progfiguration

Context

Progfiguration lacks a way to preview what changes would be made without applying them. This is standard in tools like Ansible (--check), Terraform (plan), and Puppet (--noop). Adding it would allow safe auditing of what a run would do before committing.

The challenge: roles are procedural Python; they mix read operations with write operations, and system changes happen through two separate paths:

  1. self.localhost.* method calls (file writes, dir creation, templates, etc.) — fully controllable
  2. Direct magicrun() or subprocess.run() calls — not controllable without invasive changes

The recommended approach is a dryrun flag on LocalhostLinux, which intercepts all writes at the abstraction layer. Direct subprocess calls are explicitly not covered and are documented as a limitation.

Approach

Add dryrun: bool = False to LocalhostLinux. All write methods check this flag and log instead of executing. A new localhost.run() method wraps magicrun with the same flag, giving roles a dryrun-safe subprocess caller. Inject a dryrun localhost before roles are instantiated. Expose via a --dryrun CLI flag.

Coverage:

  • self.localhost.* calls: ~129 call sites, fully intercepted
  • self.localhost.users.* calls: intercepted (with changes to localusers.py)
  • magicrun() direct calls: ~105 call sites, not intercepted (roles can migrate to self.localhost.run())
  • subprocess.run() direct calls: ~93 call sites, not intercepted

Files to Modify

1. submod/progfiguration/src/progfiguration/cmd.py

Add a DryrunResult class that mimics MagicPopen with empty stdout/stderr and returncode=0:

class DryrunResult:
    """Fake MagicPopen return for dryrun mode"""
    def __init__(self, cmd):
        self.cmd = cmd
        self.returncode = 0
        self.stdout = io.StringIO("")
        self.stderr = io.StringIO("")

2. submod/progfiguration/src/progfiguration/localhost/__init__.py

  • Add dryrun: bool = False to LocalhostLinux.__init__(self, nodename="localhost", dryrun=False) and store as self.dryrun
  • Add localhost.run() method that calls magicrun or returns DryrunResult:
    def run(self, cmd, print_output=True, log_output=False, check=True, **kwargs):
        if self.dryrun:
            logger.info(f"[DRYRUN] Would run: {cmd}")
            return DryrunResult(cmd)
        return magicrun(cmd, print_output=print_output, log_output=log_output, check=check, **kwargs)
  • Gate all write methods with if self.dryrun: logger.info("[DRYRUN] ..."); return:
    • chown() — log path and owner:group
    • set_file_contents() — log path, content length, owner/group/mode
    • makedirs() — log path and owner/group/mode
    • cp() — log src → dest and owner/group/mode
    • _template_backend() or template()/temple() — log src → dest
    • touch() — log path and owner/group/mode
    • write_sudoers() — log path (skip both validation subprocess call and write)
    • linesinfile() — perform the read to compute what lines would be added; log them; skip the write
  • Read-only methods (get_file_contents, get_umask, uptime, get_user_primary_group) run normally

3. submod/progfiguration/src/progfiguration/localhost/localusers.py

  • Pass dryrun-awareness to write methods. LocalhostUsers has a reference to its parent localhost (self.localhost), so add checks:
    if self.localhost.dryrun:
        logger.info(f"[DRYRUN] Would add_service_account {username}")
        return
    Gate: add_service_account(), add_group(), add_user_to_group()
    Don't gate: user_exists(), group_exists(), getent_user(), getent_group()

4. submod/progfiguration/src/progfiguration/sitehelpers/memhosts.py

Add optional localhost parameter to MemoryHostStore.__init__() for injection:

def __init__(
    self,
    groups,
    node_function_map,
    function_role_map,
    localhost=None,  # new
):
    self.localhost = localhost if localhost is not None else LocalhostLinux()

This is backward-compatible; existing sites that don't pass localhost behave identically.

5. submod/progfiguration/src/progfiguration/cli/progfiguration_site_cmd.py

  • Add --dryrun flag to the apply subcommand parser
  • Modify _action_apply() to accept dryrun: bool = False
  • At the start of _action_apply(), before any roles are instantiated:
    if dryrun:
        hoststore.localhost = LocalhostLinux(dryrun=True)
        print("=== DRYRUN MODE: no changes will be made ===")
    (The _node_roles cache is guaranteed empty at this point since roles haven't been iterated yet)
  • Print a completion notice with a warning about uncovered subprocess calls

Limitations to Document

  • Roles that call magicrun() directly (module-level import) or subprocess.run() directly are not intercepted in dryrun mode. They will execute for real.
  • Notable blacksite roles using direct subprocess: k3s, blockdevparty, datadisk_v1/v2, synergycontroller, capthook
  • Long-term migration path: roles can switch from magicrun(...) to self.localhost.run(...) to become dryrun-safe

Deliverable

Write this plan to submod/progfiguration/PLAN.md as the first step of implementation.

Verification

  1. Run progfiguration apply --dryrun <nodename> on a node — should print [DRYRUN] lines for each file write/mkdir/template operation and exit without any changes
  2. Verify no files were actually written (e.g. check mtimes on key config files)
  3. Verify that read operations in roles still work (e.g. calculations() that reads existing state)
  4. For roles that use magicrun() directly, confirm they DO still execute in dryrun mode (expected behavior per limitations above)
  5. Verify backward compatibility: apply without --dryrun behaves identically to before

Out of Scope

  • Remote deploy dryrun (deploy apply --dryrun) — simple follow-up: pass --dryrun through to the remote pyz args
  • Migrating existing blacksite roles from magicrun() to self.localhost.run()
  • Structured change report output (diff format)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions