diff --git a/plugins/module_utils/_kopia.py b/plugins/module_utils/_kopia.py index 4cb65897d4..75d9b93a1e 100644 --- a/plugins/module_utils/_kopia.py +++ b/plugins/module_utils/_kopia.py @@ -14,73 +14,102 @@ from ansible_collections.community.general.plugins.module_utils._cmd_runner impo if t.TYPE_CHECKING: from ansible.module_utils.basic import AnsibleModule -# Maps kopia_repository module state values to kopia CLI subcommands. +# Maps state values across all kopia modules to their kopia CLI subcommands. # Used with cmd_runner_fmt.as_map() for the 'state' arg format. -REPOSITORY_STATE_MAP = { +STATE_MAP = { + # kopia_repository "created": "create", "connected": "connect", "disconnected": "disconnect", "synced": "sync-to", "throttled": "throttle", + # kopia_snapshot + "deleted": "delete", + "expired": "expire", + "listed": "list", + "verified": "verify", + # kopia_policy + "set": "set", + "shown": "show", } -# Maps backend provider names to their CLI flag names. -# Each provider emits its name as a positional sub-subcommand, followed by -# --flag value pairs for each non-None param. +# Maps backend provider names to their CLI parameter definitions. +# Each provider maps param_name -> (flag, type) where type is: +# "str" - emit --flag=value (skip if None) +# "bool" - emit --flag only when value is True (skip if False or None) +# "list" - emit --flag item once per item in the list (skip if empty or None) # The "server" provider is intentionally absent: `kopia repository connect server` # uses top-level flags (--url, --server-cert-fingerprint) rather than backend flags, # so fmt_backend() returns [] for it and those flags are passed separately. -_PROVIDER_BACKEND_MAP = { +_PROVIDER_BACKEND_MAP: dict[str, dict[str, tuple[str, str]]] = { "azure": { - "container": "--container", - "storage_account": "--storage-account", - "storage_key": "--storage-key", - "sas_token": "--sas-token", - "storage_domain": "--storage-domain", - "prefix": "--prefix", + "container": ("--container", "str"), + "storage_account": ("--storage-account", "str"), + "storage_key": ("--storage-key", "str"), + "sas_token": ("--sas-token", "str"), + "storage_domain": ("--storage-domain", "str"), + "prefix": ("--prefix", "str"), + "client_id": ("--client-id", "str"), + "client_secret": ("--client-secret", "str"), + "tenant_id": ("--tenant-id", "str"), + "azure_federated_token_file": ("--azure-federated-token-file", "str"), }, "b2": { - "bucket": "--bucket", - "access_key": "--key-id", - "secret_access_key": "--key", - "prefix": "--prefix", + "bucket": ("--bucket", "str"), + "access_key": ("--key-id", "str"), + "secret_access_key": ("--key", "str"), + "prefix": ("--prefix", "str"), }, "filesystem": { - "path": "--path", + "path": ("--path", "str"), }, "gcs": { - "bucket": "--bucket", - "credentials_file": "--credentials-file", - "prefix": "--prefix", + "bucket": ("--bucket", "str"), + "credentials_file": ("--credentials-file", "str"), + "prefix": ("--prefix", "str"), + "embed_credentials": ("--embed-credentials", "bool"), + "read_only": ("--read-only", "bool"), }, "gdrive": { - "folder_id": "--folder-id", - "credentials_file": "--credentials-file", + "folder_id": ("--folder-id", "str"), + "credentials_file": ("--credentials-file", "str"), + "read_only": ("--read-only", "bool"), }, "rclone": { - "path": "--remote-path", + "path": ("--remote-path", "str"), + "rclone_exe": ("--rclone-exe", "str"), + "rclone_args": ("--rclone-args", "list"), + "rclone_env": ("--rclone-env", "list"), + "embed_rclone_config": ("--embed-rclone-config", "bool"), }, "s3": { - "bucket": "--bucket", - "access_key": "--access-key", - "secret_access_key": "--secret-access-key", - "endpoint": "--endpoint", - "region": "--region", - "prefix": "--prefix", - "session_token": "--session-token", + "bucket": ("--bucket", "str"), + "access_key": ("--access-key", "str"), + "secret_access_key": ("--secret-access-key", "str"), + "endpoint": ("--endpoint", "str"), + "region": ("--region", "str"), + "prefix": ("--prefix", "str"), + "session_token": ("--session-token", "str"), }, "sftp": { - "path": "--path", - "host": "--host", - "username": "--username", - "port": "--port", - "keyfile": "--keyfile", - "known_hosts": "--known-hosts", + "path": ("--path", "str"), + "host": ("--host", "str"), + "username": ("--username", "str"), + "port": ("--port", "str"), + "keyfile": ("--keyfile", "str"), + "known_hosts": ("--known-hosts", "str"), + "sftp_password": ("--sftp-password", "str"), + "key_data": ("--key-data", "str"), + "known_hosts_data": ("--known-hosts-data", "str"), + "embed_credentials": ("--embed-credentials", "bool"), + "external": ("--external", "bool"), + "ssh_command": ("--ssh-command", "str"), + "ssh_args": ("--ssh-args", "list"), }, "webdav": { - "url": "--url", - "webdav_username": "--webdav-username", - "webdav_password": "--webdav-password", + "url": ("--url", "str"), + "webdav_username": ("--webdav-username", "str"), + "webdav_password": ("--webdav-password", "str"), }, } @@ -96,7 +125,12 @@ def fmt_backend(value): """Format the backend dict into positional + flag arguments for kopia CLI. For most providers the output is: - [provider_name, --flag1, value1, --flag2, value2, ...] + [provider_name, --flag1=value1, --flag2, ...] + + Param types: + str - emits --flag=value; skipped when value is None. + bool - emits --flag when True; skipped when False or None. + list - emits --flag item once per item; skipped when empty or None. For the "server" provider, returns [] because server connect uses top-level flags (--url, --server-cert-fingerprint) passed separately. @@ -105,10 +139,18 @@ def fmt_backend(value): if provider == "server": return [] result = [provider] - for param_name, flag in _PROVIDER_BACKEND_MAP[provider].items(): + for param_name, (flag, kind) in _PROVIDER_BACKEND_MAP[provider].items(): param_value = value.get(param_name) - if param_value is not None: - result.append(f"{flag}={param_value}") + if kind == "str": + if param_value is not None: + result.append(f"{flag}={param_value}") + elif kind == "bool": + if param_value: + result.append(flag) + elif kind == "list": + if param_value: + for item in param_value: + result.extend([flag, item]) return result @@ -143,7 +185,7 @@ def kopia_runner(module: AnsibleModule, extra_formats: dict | None = None, **kwa cli_action=cmd_runner_fmt.as_list(), status=cmd_runner_fmt.as_fixed("repository", "status"), get_throttle=cmd_runner_fmt.as_fixed("repository", "throttle", "get"), - state=cmd_runner_fmt.as_map(REPOSITORY_STATE_MAP), + state=cmd_runner_fmt.as_map(STATE_MAP), backend=cmd_runner_fmt.as_func(fmt_backend), password=cmd_runner_fmt.as_opt_eq_val("--password"), fingerprint_tls=cmd_runner_fmt.as_opt_eq_val("--server-cert-fingerprint"), diff --git a/tests/unit/plugins/module_utils/test__kopia.py b/tests/unit/plugins/module_utils/test__kopia.py index 4837d2c528..4b4adaab27 100644 --- a/tests/unit/plugins/module_utils/test__kopia.py +++ b/tests/unit/plugins/module_utils/test__kopia.py @@ -8,7 +8,7 @@ import pytest from ansible_collections.community.general.plugins.module_utils._kopia import ( KOPIA_COMMON_ARGUMENT_SPEC, - REPOSITORY_STATE_MAP, + STATE_MAP, fmt_backend, ) @@ -34,22 +34,35 @@ def test_common_argument_spec_only_two_keys(): # --------------------------------------------------------------------------- -# REPOSITORY_STATE_MAP +# STATE_MAP # --------------------------------------------------------------------------- -def test_repository_state_map_entries(): - assert REPOSITORY_STATE_MAP["created"] == "create" - assert REPOSITORY_STATE_MAP["connected"] == "connect" - assert REPOSITORY_STATE_MAP["disconnected"] == "disconnect" - assert REPOSITORY_STATE_MAP["synced"] == "sync-to" - assert REPOSITORY_STATE_MAP["throttled"] == "throttle" +def test_state_map_repository_entries(): + assert STATE_MAP["created"] == "create" + assert STATE_MAP["connected"] == "connect" + assert STATE_MAP["disconnected"] == "disconnect" + assert STATE_MAP["synced"] == "sync-to" + assert STATE_MAP["throttled"] == "throttle" + + +def test_state_map_snapshot_entries(): + assert STATE_MAP["deleted"] == "delete" + assert STATE_MAP["expired"] == "expire" + assert STATE_MAP["listed"] == "list" + assert STATE_MAP["verified"] == "verify" + + +def test_state_map_policy_entries(): + assert STATE_MAP["set"] == "set" + assert STATE_MAP["shown"] == "show" # --------------------------------------------------------------------------- # fmt_backend # --------------------------------------------------------------------------- + TC_FMT_BACKEND = dict( server=( {"provider": "server"}, @@ -113,29 +126,132 @@ TC_FMT_BACKEND = dict( {"provider": "azure", "container": "my-container", "storage_account": "myaccount"}, ["azure", "--container=my-container", "--storage-account=myaccount"], ), + azure_service_principal=( + { + "provider": "azure", + "container": "my-container", + "storage_account": "myaccount", + "client_id": "cid", + "client_secret": "csecret", + "tenant_id": "tid", + }, + [ + "azure", + "--container=my-container", + "--storage-account=myaccount", + "--client-id=cid", + "--client-secret=csecret", + "--tenant-id=tid", + ], + ), + azure_federated_token=( + { + "provider": "azure", + "container": "my-container", + "storage_account": "myaccount", + "azure_federated_token_file": "/var/run/secrets/azure/token", + }, + [ + "azure", + "--container=my-container", + "--storage-account=myaccount", + "--azure-federated-token-file=/var/run/secrets/azure/token", + ], + ), gcs_full=( - {"provider": "gcs", "bucket": "my-bucket", "credentials_file": "/etc/gcs.json", "prefix": "kopia/"}, + { + "provider": "gcs", + "bucket": "my-bucket", + "credentials_file": "/etc/gcs.json", + "prefix": "kopia/", + }, ["gcs", "--bucket=my-bucket", "--credentials-file=/etc/gcs.json", "--prefix=kopia/"], ), + gcs_embed_credentials=( + { + "provider": "gcs", + "bucket": "my-bucket", + "embed_credentials": True, + }, + ["gcs", "--bucket=my-bucket", "--embed-credentials"], + ), + gcs_embed_credentials_false=( + { + "provider": "gcs", + "bucket": "my-bucket", + "embed_credentials": False, + }, + ["gcs", "--bucket=my-bucket"], + ), + gcs_read_only=( + { + "provider": "gcs", + "bucket": "my-bucket", + "read_only": True, + }, + ["gcs", "--bucket=my-bucket", "--read-only"], + ), gdrive=( {"provider": "gdrive", "folder_id": "abc123", "credentials_file": "/etc/gdrive.json"}, ["gdrive", "--folder-id=abc123", "--credentials-file=/etc/gdrive.json"], ), + gdrive_read_only=( + {"provider": "gdrive", "folder_id": "abc123", "read_only": True}, + ["gdrive", "--folder-id=abc123", "--read-only"], + ), b2_full=( {"provider": "b2", "bucket": "my-b2-bucket", "access_key": "kid", "secret_access_key": "sec"}, ["b2", "--bucket=my-b2-bucket", "--key-id=kid", "--key=sec"], ), - rclone=( + rclone_minimal=( {"provider": "rclone", "path": "remote:backup"}, ["rclone", "--remote-path=remote:backup"], ), + rclone_with_exe=( + {"provider": "rclone", "path": "remote:backup", "rclone_exe": "/usr/local/bin/rclone"}, + ["rclone", "--remote-path=remote:backup", "--rclone-exe=/usr/local/bin/rclone"], + ), + rclone_with_args=( + { + "provider": "rclone", + "path": "remote:backup", + "rclone_args": ["--transfers=4", "--checkers=8"], + }, + [ + "rclone", + "--remote-path=remote:backup", + "--rclone-args", + "--transfers=4", + "--rclone-args", + "--checkers=8", + ], + ), + rclone_with_env=( + { + "provider": "rclone", + "path": "remote:backup", + "rclone_env": ["RCLONE_CONFIG=/etc/rclone.conf", "HOME=/root"], + }, + [ + "rclone", + "--remote-path=remote:backup", + "--rclone-env", + "RCLONE_CONFIG=/etc/rclone.conf", + "--rclone-env", + "HOME=/root", + ], + ), + rclone_embed_config=( + {"provider": "rclone", "path": "remote:backup", "embed_rclone_config": True}, + ["rclone", "--remote-path=remote:backup", "--embed-rclone-config"], + ), sftp_full=( { "provider": "sftp", "path": "/backup", "host": "sftp.example.com", "username": "admin", - "port": "22", + "port": 22, "keyfile": "/root/.ssh/id_rsa", "known_hosts": "/root/.ssh/known_hosts", }, @@ -149,6 +265,79 @@ TC_FMT_BACKEND = dict( "--known-hosts=/root/.ssh/known_hosts", ], ), + sftp_password=( + { + "provider": "sftp", + "path": "/backup", + "host": "sftp.example.com", + "username": "admin", + "sftp_password": "s3cr3t", + }, + [ + "sftp", + "--path=/backup", + "--host=sftp.example.com", + "--username=admin", + "--sftp-password=s3cr3t", + ], + ), + sftp_key_data=( + { + "provider": "sftp", + "path": "/backup", + "host": "sftp.example.com", + "username": "admin", + "key_data": "-----BEGIN RSA PRIVATE KEY-----\n...", + "known_hosts_data": "sftp.example.com ssh-rsa AAAA...", + }, + [ + "sftp", + "--path=/backup", + "--host=sftp.example.com", + "--username=admin", + "--key-data=-----BEGIN RSA PRIVATE KEY-----\n...", + "--known-hosts-data=sftp.example.com ssh-rsa AAAA...", + ], + ), + sftp_embed_credentials=( + { + "provider": "sftp", + "path": "/backup", + "host": "sftp.example.com", + "username": "admin", + "embed_credentials": True, + }, + [ + "sftp", + "--path=/backup", + "--host=sftp.example.com", + "--username=admin", + "--embed-credentials", + ], + ), + sftp_external=( + { + "provider": "sftp", + "path": "/backup", + "host": "sftp.example.com", + "username": "admin", + "external": True, + "ssh_command": "/usr/bin/ssh", + "ssh_args": ["-o", "StrictHostKeyChecking=no"], + }, + [ + "sftp", + "--path=/backup", + "--host=sftp.example.com", + "--username=admin", + "--external", + "--ssh-command=/usr/bin/ssh", + "--ssh-args", + "-o", + "--ssh-args", + "StrictHostKeyChecking=no", + ], + ), webdav_full=( { "provider": "webdav", @@ -162,6 +351,14 @@ TC_FMT_BACKEND = dict( {"provider": "s3", "bucket": "b", "access_key": None, "secret_access_key": None}, ["s3", "--bucket=b"], ), + bool_false_skipped=( + {"provider": "gcs", "bucket": "b", "embed_credentials": False, "read_only": False}, + ["gcs", "--bucket=b"], + ), + empty_list_skipped=( + {"provider": "rclone", "path": "remote:b", "rclone_args": []}, + ["rclone", "--remote-path=remote:b"], + ), ) TC_FMT_BACKEND_IDS = sorted(TC_FMT_BACKEND.keys())