summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBrett Holman <brett.holman@canonical.com>2022-11-10 12:15:20 -0700
committerGitHub <noreply@github.com>2022-11-10 13:15:20 -0600
commite6c51ba62daf623859653f73a685923531c406d7 (patch)
treed360d4ed16f77154463dc1eb7506dcca75b022f4
parent892ad9e573177b9c7b6f06c2dca12b1224803be6 (diff)
downloadcloud-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.py87
-rw-r--r--cloudinit/config/schemas/schema-cloud-config-v1.json119
-rw-r--r--cloudinit/distros/__init__.py21
-rw-r--r--doc/examples/cloud-config-ansible-controller.txt140
-rw-r--r--doc/examples/cloud-config-ansible-managed.txt64
-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.rst20
-rw-r--r--tests/integration_tests/modules/test_ansible.py180
-rw-r--r--tests/unittests/config/test_cc_ansible.py184
-rw-r--r--tests/unittests/util.py5
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"