diff options
author | Brett Holman <brett.holman@canonical.com> | 2022-11-10 12:15:20 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-11-10 13:15:20 -0600 |
commit | e6c51ba62daf623859653f73a685923531c406d7 (patch) | |
tree | d360d4ed16f77154463dc1eb7506dcca75b022f4 | |
parent | 892ad9e573177b9c7b6f06c2dca12b1224803be6 (diff) | |
download | cloud-init-git-e6c51ba62daf623859653f73a685923531c406d7.tar.gz |
Ansible Control Module (#1778)
Configure and run ansible controller instance during boot
New Features:
-------------
- pull one or more playbooks from (optionally private) repositories using deploy keys
- playbook execution: execute one or more playbooks during boot
- support for ansible pip install as custom user
- new example docs showing how to bootstrap nodes for control by ansible
- example demonstrating ansible controller server setup
- example using ansible to launch and configure instances using a supported datasource (LXD)
Config Files and SSH Keys:
--------------------------
Some existing modules already provide desirable behavior for
writing out inventory configuration, ansible.cfg, and other
configuration files. See examples for more details.
-rw-r--r-- | cloudinit/config/cc_ansible.py | 87 | ||||
-rw-r--r-- | cloudinit/config/schemas/schema-cloud-config-v1.json | 119 | ||||
-rw-r--r-- | cloudinit/distros/__init__.py | 21 | ||||
-rw-r--r-- | doc/examples/cloud-config-ansible-controller.txt | 140 | ||||
-rw-r--r-- | doc/examples/cloud-config-ansible-managed.txt | 64 | ||||
-rw-r--r-- | doc/examples/cloud-config-ansible-pull.txt (renamed from doc/examples/cloud-config-ansible.txt) | 2 | ||||
-rw-r--r-- | doc/rtd/topics/examples.rst | 20 | ||||
-rw-r--r-- | tests/integration_tests/modules/test_ansible.py | 180 | ||||
-rw-r--r-- | tests/unittests/config/test_cc_ansible.py | 184 | ||||
-rw-r--r-- | tests/unittests/util.py | 5 |
10 files changed, 749 insertions, 73 deletions
diff --git a/cloudinit/config/cc_ansible.py b/cloudinit/config/cc_ansible.py index 6aadbfae..8bd8789f 100644 --- a/cloudinit/config/cc_ansible.py +++ b/cloudinit/config/cc_ansible.py @@ -38,7 +38,6 @@ meta: MetaSchema = { "examples": [ dedent( """\ - #cloud-config ansible: install-method: distro pull: @@ -48,7 +47,6 @@ meta: MetaSchema = { ), dedent( """\ - #cloud-config ansible: package-name: ansible-core install-method: pip @@ -70,10 +68,15 @@ class AnsiblePull(abc.ABC): self.cmd_pull = ["ansible-pull"] self.cmd_version = ["ansible-pull", "--version"] self.distro = distro - self.env = os.environ.copy() + self.env = os.environ + self.run_user: Optional[str] = None + + # some ansible modules directly reference os.environ["HOME"] + # and cloud-init might not have that set, default: /root + self.env["HOME"] = self.env.get("HOME", "/root") def get_version(self) -> Optional[Version]: - stdout, _ = self.subp(self.cmd_version) + stdout, _ = self.do_as(self.cmd_version) first_line = stdout.splitlines().pop(0) matches = re.search(r"([\d\.]+)", first_line) if matches: @@ -82,13 +85,18 @@ class AnsiblePull(abc.ABC): return None def pull(self, *args) -> str: - stdout, _ = self.subp([*self.cmd_pull, *args]) + stdout, _ = self.do_as([*self.cmd_pull, *args]) return stdout def check_deps(self): if not self.is_installed(): raise ValueError("command: ansible is not installed") + def do_as(self, command: list, **kwargs): + if not self.run_user: + return self.subp(command, **kwargs) + return self.distro.do_as(command, self.run_user, **kwargs) + def subp(self, command, **kwargs): return subp(command, env=self.env, **kwargs) @@ -102,10 +110,15 @@ class AnsiblePull(abc.ABC): class AnsiblePullPip(AnsiblePull): - def __init__(self, distro: Distro): + def __init__(self, distro: Distro, user: Optional[str]): super().__init__(distro) + self.run_user = user - ansible_path = "/root/.local/bin/" + # Add pip install site to PATH + user_base, _ = self.do_as( + [sys.executable, "-c", "'import site; print(site.getuserbase())'"] + ) + ansible_path = f"{user_base}/bin/" old_path = self.env.get("PATH") if old_path: self.env["PATH"] = ":".join([old_path, ansible_path]) @@ -122,16 +135,16 @@ class AnsiblePullPip(AnsiblePull): import pip # type: ignore # noqa: F401 except ImportError: self.distro.install_packages(self.distro.pip_package_name) - - self.subp([sys.executable, "-m", "pip", "install", pkg_name]) + cmd = [sys.executable, "-m", "pip", "install"] + if self.run_user: + cmd.append("--user") + self.do_as([*cmd, "--upgrade", "pip"]) + self.do_as([*cmd, pkg_name]) def is_installed(self) -> bool: - stdout, _ = self.subp([sys.executable, "-m", "pip", "list"]) + stdout, _ = self.do_as([sys.executable, "-m", "pip", "list"]) return "ansible" in stdout - def subp(self, command, **kwargs): - return subp(args=command, env=self.env, **kwargs) - class AnsiblePullDistro(AnsiblePull): def install(self, pkg_name: str): @@ -147,7 +160,10 @@ def handle( ) -> None: ansible_cfg: dict = cfg.get("ansible", {}) + ansible_user = ansible_cfg.get("run-user") install_method = ansible_cfg.get("install-method") + setup_controller = ansible_cfg.get("setup_controller") + galaxy_cfg = ansible_cfg.get("galaxy") pull_cfg = ansible_cfg.get("pull") package_name = ansible_cfg.get("package-name", "") @@ -158,7 +174,7 @@ def handle( distro: Distro = cloud.distro if install_method == "pip": - ansible = AnsiblePullPip(distro) + ansible = AnsiblePullPip(distro, ansible_user) else: ansible = AnsiblePullDistro(distro) ansible.install(package_name) @@ -174,17 +190,32 @@ def handle( if pull_cfg: run_ansible_pull(ansible, deepcopy(pull_cfg)) + if setup_controller: + ansible_controller(setup_controller, ansible) + def validate_config(cfg: dict): - required_keys = { + required_keys = ( "install-method", "package-name", - "pull/url", - "pull/playbook-name", - } + ) for key in required_keys: if not get_cfg_by_path(cfg, key): - raise ValueError(f"Invalid value config key: '{key}'") + raise ValueError(f"Missing required key '{key}' from {cfg}") + if cfg.get("pull"): + for key in "pull/url", "pull/playbook-name": + if not get_cfg_by_path(cfg, key): + raise ValueError(f"Missing required key '{key}' from {cfg}") + + controller_cfg = cfg.get("setup_controller") + if controller_cfg: + if not any( + [ + controller_cfg.get("repositories"), + controller_cfg.get("run_ansible"), + ] + ): + raise ValueError(f"Missing required key from {controller_cfg}") install = cfg["install-method"] if install not in ("pip", "distro"): @@ -226,4 +257,20 @@ def ansible_galaxy(cfg: dict, ansible: AnsiblePull): if not actions: LOG.warning("Invalid config: %s", cfg) for command in actions: - ansible.subp(command) + ansible.do_as(command) + + +def ansible_controller(cfg: dict, ansible: AnsiblePull): + for repository in cfg.get("repositories", []): + ansible.do_as( + ["git", "clone", repository["source"], repository["path"]] + ) + for args in cfg.get("run_ansible", []): + playbook_dir = args.pop("playbook-dir") + playbook_name = args.pop("playbook-name") + command = [ + "ansible-playbook", + playbook_name, + *[f"--{key}={value}" for key, value in filter_args(args).items()], + ] + ansible.do_as(command, cwd=playbook_dir) diff --git a/cloudinit/config/schemas/schema-cloud-config-v1.json b/cloudinit/config/schemas/schema-cloud-config-v1.json index 48656938..ba78f9bc 100644 --- a/cloudinit/config/schemas/schema-cloud-config-v1.json +++ b/cloudinit/config/schemas/schema-cloud-config-v1.json @@ -393,10 +393,129 @@ ], "description": "The type of installation for ansible. It can be one of the following values:\n\n - ``distro``\n - ``pip``" }, + "run-user": { + "type": "string", + "description": "User to run module commands as. If install-method: pip, the pip install runs as this user as well." + }, "ansible_config": { "description": "Sets the ANSIBLE_CONFIG environment variable. If set, overrides default config.", "type": "string" }, + "setup_controller": { + "type": "object", + "additionalProperties": false, + "properties": { + "repositories": { + "type": "array", + "items": { + "required": ["path", "source"], + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "type": "string" + }, + "source": { + "type": "string" + } + } + } + }, + "run_ansible": { + "type": "array", + "items": { + "properties": { + "playbook-name": { + "type": "string" + }, + "playbook-dir": { + "type": "string" + }, + "become-password-file": { + "type": "string" + }, + "connection-password-file": { + "type": "string" + }, + "list-hosts": { + "type": "boolean", + "default": false + }, + "syntax-check": { + "type": "boolean", + "default": false + }, + "timeout": { + "type": "number", + "minimum": 0 + }, + "vault-id": { + "type": "string" + }, + "vault-password-file": { + "type": "string" + }, + "background": { + "type": "number", + "minimum": 0 + }, + "check": { + "type": "boolean", + "default": false + }, + "diff": { + "type": "boolean", + "default": false + }, + "module-path": { + "type": "string" + }, + "poll": { + "type": "number", + "minimum": 0 + }, + "args": { + "type": "string" + }, + "extra-vars": { + "type": "string" + }, + "forks": { + "type": "number", + "minimum": 0 + }, + "inventory": { + "type": "string" + }, + "scp-extra-args": { + "type": "string" + }, + "sftp-extra-args": { + "type": "string" + }, + "private-key": { + "type": "string" + }, + "connection": { + "type": "string" + }, + "module-name": { + "type": "string" + }, + "sleep": { + "type": "string" + }, + "tags": { + "type": "string" + }, + "skip-tags": { + "type": "string" + } + } + } + } + } + }, "galaxy": { "required": ["actions"], "type": "object", diff --git a/cloudinit/distros/__init__.py b/cloudinit/distros/__init__.py index 1d47c071..735a7832 100644 --- a/cloudinit/distros/__init__.py +++ b/cloudinit/distros/__init__.py @@ -962,6 +962,27 @@ class Distro(persistence.CloudInitPickleMixin, metaclass=abc.ABCMeta): return tmp_dir return os.path.join(self.usr_lib_exec, "cloud-init", "clouddir") + def do_as(self, command: list, user: str, cwd: str = "", **kwargs): + """ + Perform a command as the requested user. Behaves like subp() + + Note: We pass `PATH` to the user env by using `env`. This could be + probably simplified after bionic EOL by using + `su --whitelist-environment=PATH ...`, more info on: + https://lore.kernel.org/all/20180815110445.4qefy5zx5gfgbqly@ws.net.home/T/ + """ + directory = f"cd {cwd} && " if cwd else "" + return subp.subp( + [ + "su", + "-", + user, + "-c", + directory + "env PATH=$PATH " + " ".join(command), + ], + **kwargs, + ) + def _apply_hostname_transformations_to_url(url: str, transformations: list): """ diff --git a/doc/examples/cloud-config-ansible-controller.txt b/doc/examples/cloud-config-ansible-controller.txt new file mode 100644 index 00000000..f1c51ff8 --- /dev/null +++ b/doc/examples/cloud-config-ansible-controller.txt @@ -0,0 +1,140 @@ +#cloud-config +# +# Demonstrate setting up an ansible controller host on boot. +# This example installs a playbook repository from a remote private repository +# and then runs two of the plays. + +packages_update: true +packages_upgrade: true +packages: + - git + - python3-pip + +# Set up an ansible user +# ---------------------- +# In this case I give the local ansible user passwordless sudo so that ansible +# may write to a local root-only file. +users: +- name: ansible + gecos: Ansible User + shell: /bin/bash + groups: users,admin,wheel,lxd + sudo: ALL=(ALL) NOPASSWD:ALL + +# Initialize lxd using cloud-init. +# -------------------------------- +# In this example, a lxd container is +# started using ansible on boot, so having lxd initialized is required. +lxd: + init: + storage_backend: dir + +# Configure and run ansible on boot +# --------------------------------- +# Install ansible using pip, ensure that community.general collection is +# installed [1]. +# Use a deploy key to clone a remote private repository then run two playbooks. +# The first playbook starts a lxd container and creates a new inventory file. +# The second playbook connects to and configures the container using ansible. +# The public version of the playbooks can be inspected here [2] +# +# [1] community.general is likely already installed by pip +# [2] https://github.com/holmanb/ansible-lxd-public +# +ansible: + install-method: pip + package-name: ansible + run-user: ansible + galaxy: + actions: + - ["ansible-galaxy", "collection", "install", "community.general"] + + setup_controller: + repositories: + - path: /home/ansible/my-repo/ + source: git@github.com:holmanb/ansible-lxd-private.git + run_ansible: + - playbook-dir: /home/ansible/my-repo + playbook-name: start-lxd.yml + timeout: 120 + forks: 1 + private-key: /home/ansible/.ssh/id_rsa + - playbook-dir: /home/ansible/my-repo + playbook-name: configure-lxd.yml + become-user: ansible + timeout: 120 + forks: 1 + private-key: /home/ansible/.ssh/id_rsa + inventory: new_ansible_hosts + +# Write a deploy key to the filesystem for ansible. +# ------------------------------------------------- +# This deploy key is tied to a private github repository [1] +# This key exists to demonstrate deploy key usage in ansible +# a duplicate public copy of the repository exists here[2] +# +# [1] https://github.com/holmanb/ansible-lxd-private +# [2] https://github.com/holmanb/ansible-lxd-public +# +write_files: + - path: /home/ansible/.ssh/known_hosts + owner: ansible:ansible + permissions: 0o600 + defer: true + content: | + |1|YJEFAk6JjnXpUjUSLFiBQS55W9E=|OLNePOn3eBa1PWhBBmt5kXsbGM4= ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl + |1|PGGnpCpqi0aakERS4BWnYxMkMwM=|Td0piZoS4ZVC0OzeuRwKcH1MusM= ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ== + |1|OJ89KrsNcFTOvoCP/fPGKpyUYFo=|cu7mNzF+QB/5kR0spiYmUJL7DAI= ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg= + + - path: /home/ansible/.ssh/id_rsa + owner: ansible:ansible + permissions: 0o600 + defer: true + encoding: base64 + content: | + LS0tLS1CRUdJTiBPUEVOU1NIIFBSSVZBVEUgS0VZLS0tLS0KYjNCbGJuTnphQzFyWlhrdGRqRUFB + QUFBQkc1dmJtVUFBQUFFYm05dVpRQUFBQUFBQUFBQkFBQUJsd0FBQUFkemMyZ3RjbgpOaEFBQUFB + d0VBQVFBQUFZRUEwUWlRa05WQS9VTEpWZzBzT1Q4TEwyMnRGckg5YVR1SWFNT1FiVFdtWjlNUzJh + VTZ0cDZoClJDYklWSkhmOHdsaGV3MXNvWmphWVVQSFBsUHNISm5UVlhJTnFTTlpEOGF0Rldjd1gy + ZTNBOElZNEhpN0NMMDE3MVBoMVUKYmJGNGVIT1JaVkY2VVkzLzhmbXQ3NmhVYnpiRVhkUXhQdVdh + a0IyemxXNTdFclpOejJhYVdnY2pJUGdHV1RNZWVqbEpOcQpXUW9MNlFzSStpeUlzYXNMc1RTajha + aVgrT1VjanJEMUY4QXNKS3ZWQStKbnVZNUxFeno1TGQ2SGxGc05XVWtoZkJmOWVOClpxRnJCc1Vw + M2VUY1FtejFGaHFFWDJIQjNQT3VSTzlKemVGcTJaRE8wUlNQN09acjBMYm8vSFVTK3V5VkJNTDNi + eEF6dEIKQWM5dFJWZjRqcTJuRjNkcUpwVTFFaXZzR0sxaHJZc0VNQklLK0srVzRwc1F5c3ZTL0ZK + V2lXZmpqWVMwei9IbkV4MkpHbApOUXUrYkMxL1dXSGVXTGFvNGpSckRSZnNIVnVscTE2MElsbnNx + eGl1MmNHd081V29Fc1NHdThucXB5ZzQzWkhDYjBGd21CCml6UFFEQVNsbmlXanFjS21mblRycHpB + eTNlVldhd3dsTnBhUWtpZFRBQUFGZ0dLU2o4ZGlrby9IQUFBQUIzTnphQzF5YzIKRUFBQUdCQU5F + SWtKRFZRUDFDeVZZTkxEay9DeTl0clJheC9XazdpR2pEa0cwMXBtZlRFdG1sT3JhZW9VUW15RlNS + My9NSgpZWHNOYktHWTJtRkR4ejVUN0J5WjAxVnlEYWtqV1EvR3JSVm5NRjludHdQQ0dPQjR1d2k5 + TmU5VDRkVkcyeGVIaHprV1ZSCmVsR04vL0g1cmUrb1ZHODJ4RjNVTVQ3bG1wQWRzNVZ1ZXhLMlRj + OW1tbG9ISXlENEJsa3pIbm81U1RhbGtLQytrTENQb3MKaUxHckM3RTBvL0dZbC9qbEhJNnc5UmZB + TENTcjFRUGlaN21PU3hNOCtTM2VoNVJiRFZsSklYd1gvWGpXYWhhd2JGS2QzawozRUpzOVJZYWhG + OWh3ZHp6cmtUdlNjM2hhdG1RenRFVWorem1hOUMyNlB4MUV2cnNsUVRDOTI4UU03UVFIUGJVVlgr + STZ0CnB4ZDNhaWFWTlJJcjdCaXRZYTJMQkRBU0N2aXZsdUtiRU1yTDB2eFNWb2xuNDQyRXRNL3g1 + eE1kaVJwVFVMdm13dGYxbGgKM2xpMnFPSTBhdzBYN0IxYnBhdGV0Q0paN0tzWXJ0bkJzRHVWcUJM + RWhydko2cWNvT04yUndtOUJjSmdZc3owQXdFcFo0bApvNm5DcG41MDY2Y3dNdDNsVm1zTUpUYVdr + SkluVXdBQUFBTUJBQUVBQUFHQUV1ejc3SHU5RUVaeXVqTE9kVG5BVzlhZlJ2ClhET1pBNnBTN3lX + RXVmanc1Q1NsTUx3aXNSODN5d3cwOXQxUVd5dmhScUV5WW12T0JlY3NYZ2FTVXRuWWZmdFd6NDRh + cHkKL2dRWXZNVkVMR0thSkFDL3E3dmpNcEd5cnhVUGt5TE1oY2tBTFUyS1lnVisvcmovajZwQk1l + VmxjaG1rM3Bpa1lyZmZVWApKRFk5OTBXVk8xOTREbTBidUxSekp2Zk1LWUYyQmNmRjRUdmFyak9Y + V0F4U3VSOHd3dzA1MG9KOEhkS2FoVzdDbTVTMHBvCkZSbk5YRkdNbkxBNjJ2TjAwdkpXOFY3ajd2 + dWk5dWtCYmhqUldhSnVZNXJkRy9VWW16QWU0d3ZkSUVucGs5eEluNkpHQ3AKRlJZVFJuN2xUaDUr + L1FsUTZGWFJQOElyMXZYWkZuaEt6bDBLOFZxaDJzZjRNNzlNc0lVR0FxR3hnOXhkaGpJYTVkbWdw + OApOMThJRURvTkVWS1ViS3VLZS9aNXlmOFo5dG1leGZIMVl0dGptWE1Pb2pCdlVISWpSUzVoZEk5 + TnhuUEdSTFkya2pBemNtCmdWOVJ2M3Z0ZEYvK3phbGszZkFWTGVLOGhYSytkaS83WFR2WXBmSjJF + WkJXaU5yVGVhZ2ZOTkdpWXlkc1F5M3pqWkFBQUEKd0JOUmFrN1VycW5JSE1abjdwa0NUZ2NlYjFN + ZkJ5YUZ0bE56ZCtPYmFoNTRIWUlRajVXZFpUQkFJVFJlTVpOdDlTNU5BUgpNOHNRQjhVb1pQYVZT + QzNwcElMSU9mTGhzNktZajZSckdkaVl3eUloTVBKNWtSV0Y4eEdDTFVYNUNqd0gyRU9xN1hoSVd0 + Ck13RUZ0ZC9nRjJEdTdIVU5GUHNaR256SjNlN3BES0RuRTd3MmtoWjhDSXBURmdENzY5dUJZR0F0 + azQ1UVlURG81SnJvVk0KWlBEcTA4R2IvUmhJZ0pMbUlwTXd5cmVWcExMTGU4U3dvTUpKK3JpaG1u + Slp4TzhnQUFBTUVBMGxoaUtlemVUc2hodDR4dQpyV2MwTnh4RDg0YTI5Z1NHZlRwaERQT3JsS1NF + WWJrU1hoanFDc0FaSGQ4UzhrTXIzaUY2cG9PazNJV1N2Rko2bWJkM2llCnFkUlRnWEg5VGh3azRL + Z3BqVWhOc1F1WVJIQmJJNTlNbytCeFNJMUIxcXptSlNHZG1DQkw1NHd3elptRktEUVBRS1B4aUwK + bjBNbGM3R29vaURNalQxdGJ1Vy9PMUVMNUVxVFJxd2dXUFRLaEJBNnI0UG5HRjE1MGhaUklNb29a + a0Qyelg2YjFzR29qawpRcHZLa0V5a1R3bktDekY1VFhPOCt3SjNxYmNFbzlBQUFBd1FEK1owcjY4 + YzJZTU5wc215ajNaS3RaTlBTdkpOY0xteUQvCmxXb05KcTNkakpONHMySmJLOGw1QVJVZFczeFNG + RURJOXl4L3dwZnNYb2FxV255Z1AzUG9GdzJDTTRpMEVpSml5dnJMRlUKcjNKTGZEVUZSeTNFSjI0 + UnNxYmlnbUVzZ1FPelRsM3hmemVGUGZ4Rm9PaG9rU3ZURzg4UFFqaTFBWUh6NWtBN3A2WmZhegpP + azExckpZSWU3K2U5QjBsaGt1MEFGd0d5cWxXUW1TL01oSXBuakhJazV0UDRoZUhHU216S1FXSkRi + VHNrTldkNmFxMUc3CjZIV2ZEcFg0SGdvTThBQUFBTGFHOXNiV0Z1WWtCaGNtTT0KLS0tLS1FTkQg + T1BFTlNTSCBQUklWQVRFIEtFWS0tLS0tCg== diff --git a/doc/examples/cloud-config-ansible-managed.txt b/doc/examples/cloud-config-ansible-managed.txt new file mode 100644 index 00000000..b6508d21 --- /dev/null +++ b/doc/examples/cloud-config-ansible-managed.txt @@ -0,0 +1,64 @@ +#cloud-config +# +# A common use-case for cloud-init is to bootstrap user and ssh +# settings to be managed by a remote configuration management tool, +# such as ansible. +# +# This example assumes a default Ubuntu cloud image, which should contain +# the required software to be managed remotely by Ansible. +# +ssh_pwauth: false + +users: +- name: ansible + gecos: Ansible User + groups: users,admin,wheel + sudo: ALL=(ALL) NOPASSWD:ALL + shell: /bin/bash + lock_passwd: true + ssh_authorized_keys: + - "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDRCJCQ1UD9QslWDSw5Pwsvba0Wsf1pO4how5BtNaZn0xLZpTq2nqFEJshUkd/zCWF7DWyhmNphQ8c+U+wcmdNVcg2pI1kPxq0VZzBfZ7cDwhjgeLsIvTXvU+HVRtsXh4c5FlUXpRjf/x+a3vqFRvNsRd1DE+5ZqQHbOVbnsStk3PZppaByMg+AZZMx56OUk2pZCgvpCwj6LIixqwuxNKPxmJf45RyOsPUXwCwkq9UD4me5jksTPPkt3oeUWw1ZSSF8F/141moWsGxSnd5NxCbPUWGoRfYcHc865E70nN4WrZkM7RFI/s5mvQtuj8dRL67JUEwvdvEDO0EBz21FV/iOracXd2omlTUSK+wYrWGtiwQwEgr4r5bimxDKy9L8UlaJZ+ONhLTP8ecTHYkaU1C75sLX9ZYd5YtqjiNGsNF+wdW6WrXrQiWeyrGK7ZwbA7lagSxIa7yeqnKDjdkcJvQXCYGLM9AMBKWeJaOpwqZ+dOunMDLd5VZrDCU2lpCSJ1M=" + + +# use the following passwordless demonstration key for testing or +# replace with your own key pair +# +# -----BEGIN OPENSSH PRIVATE KEY----- +# b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn +# NhAAAAAwEAAQAAAYEA0QiQkNVA/ULJVg0sOT8LL22tFrH9aTuIaMOQbTWmZ9MS2aU6tp6h +# RCbIVJHf8wlhew1soZjaYUPHPlPsHJnTVXINqSNZD8atFWcwX2e3A8IY4Hi7CL0171Ph1U +# bbF4eHORZVF6UY3/8fmt76hUbzbEXdQxPuWakB2zlW57ErZNz2aaWgcjIPgGWTMeejlJNq +# WQoL6QsI+iyIsasLsTSj8ZiX+OUcjrD1F8AsJKvVA+JnuY5LEzz5Ld6HlFsNWUkhfBf9eN +# ZqFrBsUp3eTcQmz1FhqEX2HB3POuRO9JzeFq2ZDO0RSP7OZr0Lbo/HUS+uyVBML3bxAztB +# Ac9tRVf4jq2nF3dqJpU1EivsGK1hrYsEMBIK+K+W4psQysvS/FJWiWfjjYS0z/HnEx2JGl +# NQu+bC1/WWHeWLao4jRrDRfsHVulq160Ilnsqxiu2cGwO5WoEsSGu8nqpyg43ZHCb0FwmB +# izPQDASlniWjqcKmfnTrpzAy3eVWawwlNpaQkidTAAAFgGKSj8diko/HAAAAB3NzaC1yc2 +# EAAAGBANEIkJDVQP1CyVYNLDk/Cy9trRax/Wk7iGjDkG01pmfTEtmlOraeoUQmyFSR3/MJ +# YXsNbKGY2mFDxz5T7ByZ01VyDakjWQ/GrRVnMF9ntwPCGOB4uwi9Ne9T4dVG2xeHhzkWVR +# elGN//H5re+oVG82xF3UMT7lmpAds5VuexK2Tc9mmloHIyD4BlkzHno5STalkKC+kLCPos +# iLGrC7E0o/GYl/jlHI6w9RfALCSr1QPiZ7mOSxM8+S3eh5RbDVlJIXwX/XjWahawbFKd3k +# 3EJs9RYahF9hwdzzrkTvSc3hatmQztEUj+zma9C26Px1EvrslQTC928QM7QQHPbUVX+I6t +# pxd3aiaVNRIr7BitYa2LBDASCvivluKbEMrL0vxSVoln442EtM/x5xMdiRpTULvmwtf1lh +# 3li2qOI0aw0X7B1bpatetCJZ7KsYrtnBsDuVqBLEhrvJ6qcoON2Rwm9BcJgYsz0AwEpZ4l +# o6nCpn5066cwMt3lVmsMJTaWkJInUwAAAAMBAAEAAAGAEuz77Hu9EEZyujLOdTnAW9afRv +# XDOZA6pS7yWEufjw5CSlMLwisR83yww09t1QWyvhRqEyYmvOBecsXgaSUtnYfftWz44apy +# /gQYvMVELGKaJAC/q7vjMpGyrxUPkyLMhckALU2KYgV+/rj/j6pBMeVlchmk3pikYrffUX +# JDY990WVO194Dm0buLRzJvfMKYF2BcfF4TvarjOXWAxSuR8www050oJ8HdKahW7Cm5S0po +# FRnNXFGMnLA62vN00vJW8V7j7vui9ukBbhjRWaJuY5rdG/UYmzAe4wvdIEnpk9xIn6JGCp +# FRYTRn7lTh5+/QlQ6FXRP8Ir1vXZFnhKzl0K8Vqh2sf4M79MsIUGAqGxg9xdhjIa5dmgp8 +# N18IEDoNEVKUbKuKe/Z5yf8Z9tmexfH1YttjmXMOojBvUHIjRS5hdI9NxnPGRLY2kjAzcm +# gV9Rv3vtdF/+zalk3fAVLeK8hXK+di/7XTvYpfJ2EZBWiNrTeagfNNGiYydsQy3zjZAAAA +# wBNRak7UrqnIHMZn7pkCTgceb1MfByaFtlNzd+Obah54HYIQj5WdZTBAITReMZNt9S5NAR +# M8sQB8UoZPaVSC3ppILIOfLhs6KYj6RrGdiYwyIhMPJ5kRWF8xGCLUX5CjwH2EOq7XhIWt +# MwEFtd/gF2Du7HUNFPsZGnzJ3e7pDKDnE7w2khZ8CIpTFgD769uBYGAtk45QYTDo5JroVM +# ZPDq08Gb/RhIgJLmIpMwyreVpLLLe8SwoMJJ+rihmnJZxO8gAAAMEA0lhiKezeTshht4xu +# rWc0NxxD84a29gSGfTphDPOrlKSEYbkSXhjqCsAZHd8S8kMr3iF6poOk3IWSvFJ6mbd3ie +# qdRTgXH9Thwk4KgpjUhNsQuYRHBbI59Mo+BxSI1B1qzmJSGdmCBL54wwzZmFKDQPQKPxiL +# n0Mlc7GooiDMjT1tbuW/O1EL5EqTRqwgWPTKhBA6r4PnGF150hZRIMooZkD2zX6b1sGojk +# QpvKkEykTwnKCzF5TXO8+wJ3qbcEo9AAAAwQD+Z0r68c2YMNpsmyj3ZKtZNPSvJNcLmyD/ +# lWoNJq3djJN4s2JbK8l5ARUdW3xSFEDI9yx/wpfsXoaqWnygP3PoFw2CM4i0EiJiyvrLFU +# r3JLfDUFRy3EJ24RsqbigmEsgQOzTl3xfzeFPfxFoOhokSvTG88PQji1AYHz5kA7p6Zfaz +# Ok11rJYIe7+e9B0lhku0AFwGyqlWQmS/MhIpnjHIk5tP4heHGSmzKQWJDbTskNWd6aq1G7 +# 6HWfDpX4HgoM8AAAALaG9sbWFuYkBhcmM= +# -----END OPENSSH PRIVATE KEY----- +# diff --git a/doc/examples/cloud-config-ansible.txt b/doc/examples/cloud-config-ansible-pull.txt index a3e7c273..6c98a9e9 100644 --- a/doc/examples/cloud-config-ansible.txt +++ b/doc/examples/cloud-config-ansible-pull.txt @@ -1,5 +1,4 @@ #cloud-config -version: v1 packages_update: true packages_upgrade: true @@ -7,7 +6,6 @@ packages_upgrade: true # wish to manually install ansible to avoid multiple calls # to your package manager packages: - - ansible - git ansible: install-method: pip diff --git a/doc/rtd/topics/examples.rst b/doc/rtd/topics/examples.rst index 353e22d8..3f260947 100644 --- a/doc/rtd/topics/examples.rst +++ b/doc/rtd/topics/examples.rst @@ -41,10 +41,24 @@ Install and run `chef`_ recipes :language: yaml :linenos: -Install and run `ansible`_ -========================== +Install and run `ansible-pull` +=============================== + +.. literalinclude:: ../../examples/cloud-config-ansible-pull.txt + :language: yaml + :linenos: + +Configure Instance to be Managed by Ansible +=========================================== + +.. literalinclude:: ../../examples/cloud-config-ansible-managed.txt + :language: yaml + :linenos: + +Configure Instance to be An Ansible Controller +============================================== -.. literalinclude:: ../../examples/cloud-config-ansible.txt +.. literalinclude:: ../../examples/cloud-config-ansible-controller.txt :language: yaml :linenos: diff --git a/tests/integration_tests/modules/test_ansible.py b/tests/integration_tests/modules/test_ansible.py index 98772914..36fef2a7 100644 --- a/tests/integration_tests/modules/test_ansible.py +++ b/tests/integration_tests/modules/test_ansible.py @@ -34,7 +34,6 @@ write_files: WorkingDirectory=/root/playbooks/.git ExecStart=/usr/bin/env python3 -m http.server --bind 0.0.0.0 8000 - - path: /etc/systemd/system/repo_waiter.service content: | [Unit] @@ -79,6 +78,7 @@ write_files: - "{{ item }}" state: latest loop: "{{ packages }}" + runcmd: - [systemctl, enable, repo_server.service] - [systemctl, enable, repo_waiter.service] @@ -107,6 +107,155 @@ git add {REPO_D}/roles/apt/tasks/main.yml {REPO_D}/ubuntu.yml &&\ git commit -m auto &&\ (cd {REPO_D}/.git; git update-server-info)" +ANSIBLE_CONTROL = """\ +#cloud-config +# +# Demonstrate setting up an ansible controller host on boot. +# This example installs a playbook repository from a remote private repository +# and then runs two of the plays. + +packages_update: true +packages_upgrade: true +packages: + - git + - python3-pip + +# Set up an ansible user +# ---------------------- +# In this case I give the local ansible user passwordless sudo so that ansible +# may write to a local root-only file. +users: +- name: ansible + gecos: Ansible User + shell: /bin/bash + groups: users,admin,wheel,lxd + sudo: ALL=(ALL) NOPASSWD:ALL + +# Initialize lxd using cloud-init. +# -------------------------------- +# In this example, a lxd container is +# started using ansible on boot, so having lxd initialized is required. +lxd: + init: + storage_backend: dir + +# Configure and run ansible on boot +# --------------------------------- +# Install ansible using pip, ensure that community.general collection is +# installed [1]. +# Use a deploy key to clone a remote private repository then run two playbooks. +# The first playbook starts a lxd container and creates a new inventory file. +# The second playbook connects to and configures the container using ansible. +# The public version of the playbooks can be inspected here [2] +# +# [1] community.general is likely already installed by pip +# [2] https://github.com/holmanb/ansible-lxd-public +# +ansible: + install-method: pip + package-name: ansible + run-user: ansible + galaxy: + actions: + - ["ansible-galaxy", "collection", "install", "community.general"] + + setup_controller: + repositories: + - path: /home/ansible/my-repo/ + source: git@github.com:holmanb/ansible-lxd-private.git + run_ansible: + - playbook-dir: /home/ansible/my-repo + playbook-name: start-lxd.yml + timeout: 120 + forks: 1 + private-key: /home/ansible/.ssh/id_rsa + - playbook-dir: /home/ansible/my-repo + playbook-name: configure-lxd.yml + become-user: ansible + timeout: 120 + forks: 1 + private-key: /home/ansible/.ssh/id_rsa + inventory: new_ansible_hosts + +# Write a deploy key to the filesystem for ansible. +# ------------------------------------------------- +# This deploy key is tied to a private github repository [1] +# This key exists to demonstrate deploy key usage in ansible +# a duplicate public copy of the repository exists here[2] +# +# [1] https://github.com/holmanb/ansible-lxd-private +# [2] https://github.com/holmanb/ansible-lxd-public +# +write_files: + - path: /home/ansible/.ssh/known_hosts + owner: ansible:ansible + permissions: 0o600 + defer: true + content: | + |1|YJEFAk6JjnXpUjUSLFiBQS55W9E=|OLNePOn3eBa1PWhBBmt5kXsbGM4= ssh-ed2551\ +9 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl + + - path: /home/ansible/.ssh/id_rsa + owner: ansible:ansible + permissions: 0o600 + defer: true + encoding: base64 + content: | + LS0tLS1CRUdJTiBPUEVOU1NIIFBSSVZBVEUgS0VZLS0tLS0KYjNCbGJuTnphQzFyWlhrdGRqRUFB + QUFBQkc1dmJtVUFBQUFFYm05dVpRQUFBQUFBQUFBQkFBQUJsd0FBQUFkemMyZ3RjbgpOaEFBQUFB + d0VBQVFBQUFZRUEwUWlRa05WQS9VTEpWZzBzT1Q4TEwyMnRGckg5YVR1SWFNT1FiVFdtWjlNUzJh + VTZ0cDZoClJDYklWSkhmOHdsaGV3MXNvWmphWVVQSFBsUHNISm5UVlhJTnFTTlpEOGF0Rldjd1gy + ZTNBOElZNEhpN0NMMDE3MVBoMVUKYmJGNGVIT1JaVkY2VVkzLzhmbXQ3NmhVYnpiRVhkUXhQdVdh + a0IyemxXNTdFclpOejJhYVdnY2pJUGdHV1RNZWVqbEpOcQpXUW9MNlFzSStpeUlzYXNMc1RTajha + aVgrT1VjanJEMUY4QXNKS3ZWQStKbnVZNUxFeno1TGQ2SGxGc05XVWtoZkJmOWVOClpxRnJCc1Vw + M2VUY1FtejFGaHFFWDJIQjNQT3VSTzlKemVGcTJaRE8wUlNQN09acjBMYm8vSFVTK3V5VkJNTDNi + eEF6dEIKQWM5dFJWZjRqcTJuRjNkcUpwVTFFaXZzR0sxaHJZc0VNQklLK0srVzRwc1F5c3ZTL0ZK + V2lXZmpqWVMwei9IbkV4MkpHbApOUXUrYkMxL1dXSGVXTGFvNGpSckRSZnNIVnVscTE2MElsbnNx + eGl1MmNHd081V29Fc1NHdThucXB5ZzQzWkhDYjBGd21CCml6UFFEQVNsbmlXanFjS21mblRycHpB + eTNlVldhd3dsTnBhUWtpZFRBQUFGZ0dLU2o4ZGlrby9IQUFBQUIzTnphQzF5YzIKRUFBQUdCQU5F + SWtKRFZRUDFDeVZZTkxEay9DeTl0clJheC9XazdpR2pEa0cwMXBtZlRFdG1sT3JhZW9VUW15RlNS + My9NSgpZWHNOYktHWTJtRkR4ejVUN0J5WjAxVnlEYWtqV1EvR3JSVm5NRjludHdQQ0dPQjR1d2k5 + TmU5VDRkVkcyeGVIaHprV1ZSCmVsR04vL0g1cmUrb1ZHODJ4RjNVTVQ3bG1wQWRzNVZ1ZXhLMlRj + OW1tbG9ISXlENEJsa3pIbm81U1RhbGtLQytrTENQb3MKaUxHckM3RTBvL0dZbC9qbEhJNnc5UmZB + TENTcjFRUGlaN21PU3hNOCtTM2VoNVJiRFZsSklYd1gvWGpXYWhhd2JGS2QzawozRUpzOVJZYWhG + OWh3ZHp6cmtUdlNjM2hhdG1RenRFVWorem1hOUMyNlB4MUV2cnNsUVRDOTI4UU03UVFIUGJVVlgr + STZ0CnB4ZDNhaWFWTlJJcjdCaXRZYTJMQkRBU0N2aXZsdUtiRU1yTDB2eFNWb2xuNDQyRXRNL3g1 + eE1kaVJwVFVMdm13dGYxbGgKM2xpMnFPSTBhdzBYN0IxYnBhdGV0Q0paN0tzWXJ0bkJzRHVWcUJM + RWhydko2cWNvT04yUndtOUJjSmdZc3owQXdFcFo0bApvNm5DcG41MDY2Y3dNdDNsVm1zTUpUYVdr + SkluVXdBQUFBTUJBQUVBQUFHQUV1ejc3SHU5RUVaeXVqTE9kVG5BVzlhZlJ2ClhET1pBNnBTN3lX + RXVmanc1Q1NsTUx3aXNSODN5d3cwOXQxUVd5dmhScUV5WW12T0JlY3NYZ2FTVXRuWWZmdFd6NDRh + cHkKL2dRWXZNVkVMR0thSkFDL3E3dmpNcEd5cnhVUGt5TE1oY2tBTFUyS1lnVisvcmovajZwQk1l + VmxjaG1rM3Bpa1lyZmZVWApKRFk5OTBXVk8xOTREbTBidUxSekp2Zk1LWUYyQmNmRjRUdmFyak9Y + V0F4U3VSOHd3dzA1MG9KOEhkS2FoVzdDbTVTMHBvCkZSbk5YRkdNbkxBNjJ2TjAwdkpXOFY3ajd2 + dWk5dWtCYmhqUldhSnVZNXJkRy9VWW16QWU0d3ZkSUVucGs5eEluNkpHQ3AKRlJZVFJuN2xUaDUr + L1FsUTZGWFJQOElyMXZYWkZuaEt6bDBLOFZxaDJzZjRNNzlNc0lVR0FxR3hnOXhkaGpJYTVkbWdw + OApOMThJRURvTkVWS1ViS3VLZS9aNXlmOFo5dG1leGZIMVl0dGptWE1Pb2pCdlVISWpSUzVoZEk5 + TnhuUEdSTFkya2pBemNtCmdWOVJ2M3Z0ZEYvK3phbGszZkFWTGVLOGhYSytkaS83WFR2WXBmSjJF + WkJXaU5yVGVhZ2ZOTkdpWXlkc1F5M3pqWkFBQUEKd0JOUmFrN1VycW5JSE1abjdwa0NUZ2NlYjFN + ZkJ5YUZ0bE56ZCtPYmFoNTRIWUlRajVXZFpUQkFJVFJlTVpOdDlTNU5BUgpNOHNRQjhVb1pQYVZT + QzNwcElMSU9mTGhzNktZajZSckdkaVl3eUloTVBKNWtSV0Y4eEdDTFVYNUNqd0gyRU9xN1hoSVd0 + Ck13RUZ0ZC9nRjJEdTdIVU5GUHNaR256SjNlN3BES0RuRTd3MmtoWjhDSXBURmdENzY5dUJZR0F0 + azQ1UVlURG81SnJvVk0KWlBEcTA4R2IvUmhJZ0pMbUlwTXd5cmVWcExMTGU4U3dvTUpKK3JpaG1u + Slp4TzhnQUFBTUVBMGxoaUtlemVUc2hodDR4dQpyV2MwTnh4RDg0YTI5Z1NHZlRwaERQT3JsS1NF + WWJrU1hoanFDc0FaSGQ4UzhrTXIzaUY2cG9PazNJV1N2Rko2bWJkM2llCnFkUlRnWEg5VGh3azRL + Z3BqVWhOc1F1WVJIQmJJNTlNbytCeFNJMUIxcXptSlNHZG1DQkw1NHd3elptRktEUVBRS1B4aUwK + bjBNbGM3R29vaURNalQxdGJ1Vy9PMUVMNUVxVFJxd2dXUFRLaEJBNnI0UG5HRjE1MGhaUklNb29a + a0Qyelg2YjFzR29qawpRcHZLa0V5a1R3bktDekY1VFhPOCt3SjNxYmNFbzlBQUFBd1FEK1owcjY4 + YzJZTU5wc215ajNaS3RaTlBTdkpOY0xteUQvCmxXb05KcTNkakpONHMySmJLOGw1QVJVZFczeFNG + RURJOXl4L3dwZnNYb2FxV255Z1AzUG9GdzJDTTRpMEVpSml5dnJMRlUKcjNKTGZEVUZSeTNFSjI0 + UnNxYmlnbUVzZ1FPelRsM3hmemVGUGZ4Rm9PaG9rU3ZURzg4UFFqaTFBWUh6NWtBN3A2WmZhegpP + azExckpZSWU3K2U5QjBsaGt1MEFGd0d5cWxXUW1TL01oSXBuakhJazV0UDRoZUhHU216S1FXSkRi + VHNrTldkNmFxMUc3CjZIV2ZEcFg0SGdvTThBQUFBTGFHOXNiV0Z1WWtCaGNtTT0KLS0tLS1FTkQg + T1BFTlNTSCBQUklWQVRFIEtFWS0tLS0tCg== + + +# Work around this bug [1] by dropping the second interface after it is no +# longer required +# [1] https://github.com/canonical/pycloudlib/issues/220 +runcmd: + - [ip, link, delete, lxdbr0] +""" + def _test_ansible_pull_from_local_server(my_client): setup = my_client.execute(SETUP_REPO) @@ -133,16 +282,33 @@ def _test_ansible_pull_from_local_server(my_client): @pytest.mark.user_data( USER_DATA + INSTALL_METHOD.format(package="ansible-core", method="pip") ) -class TestAnsiblePullPip: - def test_ansible_pull_pip(self, class_client): - _test_ansible_pull_from_local_server(class_client) +def test_ansible_pull_pip(client): + _test_ansible_pull_from_local_server(client) # temporarily disable this test on jenkins until firewall rules are in place @pytest.mark.adhoc +# Ansible packaged in bionic is 2.5.1. This test relies on ansible collections, +# which requires Ansible 2.9+, so no bionic. The functionality is covered +# in `test_ansible_pull_pip` using pip rather than the bionic package. +@pytest.mark.not_bionic @pytest.mark.user_data( USER_DATA + INSTALL_METHOD.format(package="ansible", method="distro") ) -class TestAnsiblePullDistro: - def test_ansible_pull_distro(self, class_client): - _test_ansible_pull_from_local_server(class_client) +def test_ansible_pull_distro(client): + _test_ansible_pull_from_local_server(client) + + +@pytest.mark.user_data(ANSIBLE_CONTROL) +@pytest.mark.lxd_vm +def test_ansible_controller(client): + log = client.read_from_file("/var/log/cloud-init.log") + verify_clean_log(log) + content_ansible = client.execute( + "lxc exec lxd-container-00 -- cat /home/ansible/ansible.txt" + ) + content_root = client.execute( + "lxc exec lxd-container-00 -- cat /root/root.txt" + ) + assert content_ansible == "hello as ansible" + assert content_root == "hello as root" diff --git a/tests/unittests/config/test_cc_ansible.py b/tests/unittests/config/test_cc_ansible.py index fc97305a..2b66f283 100644 --- a/tests/unittests/config/test_cc_ansible.py +++ b/tests/unittests/config/test_cc_ansible.py @@ -1,8 +1,9 @@ import re from copy import deepcopy +from os import environ from textwrap import dedent from unittest import mock -from unittest.mock import call +from unittest.mock import MagicMock from pytest import mark, param, raises @@ -42,7 +43,56 @@ pip_version = dedent( libyaml = True """ ) -CFG_FULL = { +CFG_CTRL = { + "ansible": { + "install-method": "distro", + "package-name": "ansible-core", + "ansible_config": "/etc/ansible/ansible.cfg", + "galaxy": { + "actions": [["ansible-galaxy", "install", "debops.apt"]], + }, + "setup_controller": { + "repositories": [ + { + "path": "/home/ansible/public/", + "source": "git@github.com:holmanb/ansible-lxd-public.git", + }, + { + "path": "/home/ansible/private/", + "source": "git@github.com:holmanb/ansible-lxd-private.git", + }, + { + "path": "/home/ansible/vmboot", + "source": "git@github.com:holmanb/vmboot.git", + }, + ], + "run_ansible": [ + { + "playbook-dir": "/home/ansible/my-repo", + "playbook-name": "start-lxd.yml", + "timeout": 120, + "forks": 1, + "private-key": "/home/ansible/.ssh/id_rsa", + }, + { + "playbook-name": "configure-lxd.yml", + "become-user": "ansible", + "timeout": 120, + "forks": 1, + "private-key": "/home/ansible/.ssh/id_rsa", + "become-password-file": "/path/less/traveled", + "connection-password-file": "/path/more/traveled", + "module-path": "/path/head/traveled", + "vault-password-file": "/path/tail/traveled", + "playbook-dir": "/path/to/nowhere", + "inventory": "/a/file/as/well", + }, + ], + }, + }, +} + +CFG_FULL_PULL = { "ansible": { "install-method": "distro", "package-name": "ansible-core", @@ -75,10 +125,12 @@ CFG_FULL = { }, } } + CFG_MINIMAL = { "ansible": { "install-method": "pip", "package-name": "ansible", + "run-user": "ansible", "pull": { "url": "https://github/holmanb/vmboot", "playbook-name": "ubuntu.yml", @@ -111,9 +163,14 @@ class TestSchema: id="additional-properties", ), param( - CFG_FULL, + CFG_FULL_PULL, None, - id="all-keys", + id="all-pull-keys", + ), + param( + CFG_CTRL, + None, + id="ctrl-keys", ), param( { @@ -167,7 +224,7 @@ class TestAnsible: def test_filter_args(self): """only diff should be removed""" out = cc_ansible.filter_args( - CFG_FULL.get("ansible", {}).get("pull", {}) + CFG_FULL_PULL.get("ansible", {}).get("pull", {}) ) assert out == { "url": "https://github/holmanb/vmboot", @@ -192,6 +249,64 @@ class TestAnsible: "private-key": "{nope}", } + @mark.parametrize( + ("cfg", "exception"), + ( + (CFG_FULL_PULL, None), + (CFG_MINIMAL, None), + ( + { + "ansible": { + "package-name": "ansible-core", + "install-method": "distro", + "pull": { + "playbook-name": "ubuntu.yml", + }, + } + }, + ValueError, + ), + ( + { + "ansible": { + "install-method": "pip", + "pull": { + "url": "https://github/holmanb/vmboot", + }, + } + }, + ValueError, + ), + ), + ) + def test_required_keys(self, cfg, exception, mocker): + mocker.patch(M_PATH + "subp", return_value=("", "")) + mocker.patch(M_PATH + "which", return_value=True) + mocker.patch(M_PATH + "AnsiblePull.check_deps") + mocker.patch( + M_PATH + "AnsiblePull.get_version", + return_value=cc_ansible.Version(2, 7, 1), + ) + mocker.patch( + M_PATH + "AnsiblePullDistro.is_installed", + return_value=False, + ) + mocker.patch.dict(M_PATH + "os.environ", clear=True) + if exception: + with raises(exception): + cc_ansible.handle("", cfg, get_cloud(), None, None) + else: + cloud = get_cloud(mocked_distro=True) + install = cfg["ansible"]["install-method"] + cc_ansible.handle("", cfg, cloud, None, None) + if install == "distro": + cloud.distro.install_packages.assert_called_once() + cloud.distro.install_packages.assert_called_with( + "ansible-core" + ) + elif install == "pip": + assert 0 == cloud.distro.install_packages.call_count + @mock.patch(M_PATH + "which", return_value=False) def test_deps_not_installed(self, m_which): """assert exception raised if package not installed""" @@ -203,23 +318,22 @@ class TestAnsible: """assert exception not raised if package installed""" cc_ansible.AnsiblePullDistro(get_cloud().distro).check_deps() - @mock.patch(M_PATH + "which", return_value=False) @mock.patch(M_PATH + "subp", return_value=("stdout", "stderr")) + @mock.patch(M_PATH + "which", return_value=False) def test_pip_bootstrap(self, m_which, m_subp): - distro = get_cloud(mocked_distro=True).distro - ansible = cc_ansible.AnsiblePullPip(distro) with mock.patch("builtins.__import__", side_effect=ImportError): - ansible.install("") + cc_ansible.AnsiblePullPip(distro, "ansible").install("") distro.install_packages.assert_called_once() @mock.patch(M_PATH + "which", return_value=True) @mock.patch(M_PATH + "subp", return_value=("stdout", "stderr")) + @mock.patch("cloudinit.distros.subp", return_value=("stdout", "stderr")) @mark.parametrize( ("cfg", "expected"), ( ( - CFG_FULL, + CFG_FULL_PULL, [ "ansible-pull", "--url=https://github/holmanb/vmboot", @@ -254,13 +368,13 @@ class TestAnsible: ), ), ) - def test_ansible_pull(self, m_subp, m_which, cfg, expected): + def test_ansible_pull(self, m_subp1, m_subp2, m_which, cfg, expected): """verify expected ansible invocation from userdata config""" pull_type = cfg["ansible"]["install-method"] distro = get_cloud().distro with mock.patch.dict(M_PATH + "os.environ", clear=True): ansible_pull = ( - cc_ansible.AnsiblePullPip(distro) + cc_ansible.AnsiblePullPip(distro, "ansible") if pull_type == "pip" else cc_ansible.AnsiblePullDistro(distro) ) @@ -268,12 +382,11 @@ class TestAnsible: ansible_pull, deepcopy(cfg["ansible"]["pull"]) ) - if pull_type == "pip": - assert m_subp.call_args == call( - args=expected, env={"PATH": "/root/.local/bin/"} + if pull_type != "pip": + assert m_subp2.call_args[0][0] == expected + assert m_subp2.call_args[1]["env"].get("HOME") == environ.get( + "HOME" ) - else: - assert m_subp.call_args == call(expected, env={}) @mock.patch(M_PATH + "validate_config") def test_do_not_run(self, m_validate): @@ -282,37 +395,28 @@ class TestAnsible: assert not m_validate.called @mock.patch( - M_PATH + "subp", - side_effect=[ - (distro_version, ""), - (pip_version, ""), - (" ansible 2.1.0", ""), - (" ansible 2.1.0", ""), - ], + "cloudinit.config.cc_ansible.subp", side_effect=[(distro_version, "")] ) - def test_parse_version(self, m_subp): + def test_parse_version_distro(self, m_subp): """Verify that the expected version is returned""" - distro = get_cloud().distro assert cc_ansible.AnsiblePullDistro( - distro - ).get_version() == cc_ansible.Version(2, 10, 8) - assert cc_ansible.AnsiblePullPip( - distro - ).get_version() == cc_ansible.Version(2, 13, 2) + get_cloud().distro + ).get_version() == util.Version(2, 10, 8) - assert ( - util.Version(2, 1, 0, -1) - == cc_ansible.AnsiblePullPip(distro).get_version() - ) - assert ( - util.Version(2, 1, 0, -1) - == cc_ansible.AnsiblePullDistro(distro).get_version() - ) + @mock.patch("cloudinit.subp.subp", side_effect=[(pip_version, "")]) + def test_parse_version_pip(self, m_subp): + """Verify that the expected version is returned""" + distro = get_cloud().distro + distro.do_as = MagicMock(return_value=(pip_version, "")) + pip = cc_ansible.AnsiblePullPip(distro, "root") + received = pip.get_version() + expected = util.Version(2, 13, 2) + assert received == expected @mock.patch(M_PATH + "subp", return_value=("stdout", "stderr")) @mock.patch(M_PATH + "which", return_value=True) def test_ansible_env_var(self, m_which, m_subp): - cc_ansible.handle("", CFG_FULL, get_cloud(), mock.Mock(), []) + cc_ansible.handle("", CFG_FULL_PULL, get_cloud(), mock.Mock(), []) # python 3.8 required for Mock.call_args.kwargs dict attribute if isinstance(m_subp.call_args.kwargs, dict): diff --git a/tests/unittests/util.py b/tests/unittests/util.py index 4635ca3f..e7094ec5 100644 --- a/tests/unittests/util.py +++ b/tests/unittests/util.py @@ -23,7 +23,7 @@ def get_cloud( cls = distros.fetch(distro) if distro else MockDistro mydist = cls(distro, sys_cfg, paths) if mocked_distro: - mydist = mock.Mock(wraps=mydist) + mydist = mock.MagicMock(wraps=mydist) myds = DataSourceTesting(sys_cfg, mydist, paths) if metadata: myds.metadata.update(metadata) @@ -148,6 +148,9 @@ class MockDistro(distros.Distro): def update_package_sources(self): return (True, "yay") + def do_as(self, command, args=None, **kwargs): + return ("stdout", "stderr") + TEST_INSTANCE_ID = "i-testing" |