Repository: canonical/cloud-init
Pull request #6010: feat: Change ansible pull module type from dict to list
Author: login: shaerpour
Labels: name: documentation
description: This Pull Request changes documentation
Created: 2025-02-06T16:49:06Z | Merged: 2025-05-10T01:28:35Z

## Proposed Commit Message
```
feat: Support both list and dict type for ansible pull

Use both dict and list types for using ansible pull module
like setup_controller.
```

## Additional Context
<!-- If relevant -->

## Test Steps
## Merge type

- [x] Squash merge using "Proposed Commit Message"
- [ ] Rebase and merge unique commits. Requires commit messages per-commit each referencing the pull request number (#<PR_NUM>)

Fixes GH-4566


Diff:
diff --git a/cloudinit/config/cc_ansible.py b/cloudinit/config/cc_ansible.py
index 1fc539ae476..ec8ba28ed60 100644
--- a/cloudinit/config/cc_ansible.py
+++ b/cloudinit/config/cc_ansible.py
@@ -7,7 +7,7 @@
 import sys
 import sysconfig
 from copy import deepcopy
-from typing import Optional
+from typing import List, Optional
 
 from cloudinit import lifecycle, subp
 from cloudinit.cloud import Cloud
@@ -183,7 +183,10 @@ def handle(name: str, cfg: Config, cloud: Cloud, args: list) -> None:
             ansible_galaxy(galaxy_cfg, ansible)
 
         if pull_cfg:
-            run_ansible_pull(ansible, deepcopy(pull_cfg))
+            if isinstance(pull_cfg, dict):
+                pull_cfg = [pull_cfg]
+            for cfg in pull_cfg:
+                run_ansible_pull(ansible, deepcopy(cfg))
 
         if setup_controller:
             ansible_controller(setup_controller, ansible)
@@ -197,10 +200,35 @@ def validate_config(cfg: dict):
     for key in required_keys:
         if not get_cfg_by_path(cfg, 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}")
+    pull_cfg = cfg.get("pull")
+    if pull_cfg:
+        if isinstance(pull_cfg, dict):
+            pull_cfg = [pull_cfg]
+        elif not isinstance(pull_cfg, list):
+            raise ValueError(
+                "Invalid value ansible.pull. Expected either dict of list of"
+                f" dicts but found {pull_cfg}"
+            )
+        for p_cfg in pull_cfg:
+            if not isinstance(p_cfg, dict):
+                raise ValueError(
+                    "Invalid value of ansible.pull. Expected dict but found"
+                    f" {p_cfg}"
+                )
+            if not get_cfg_by_path(p_cfg, "url"):
+                raise ValueError(f"Missing required key 'url' from {p_cfg}")
+            has_playbook = get_cfg_by_path(p_cfg, "playbook_name")
+            has_playbooks = get_cfg_by_path(p_cfg, "playbook_names")
+            if not any([has_playbook, has_playbooks]):
+                raise ValueError(
+                    f"Missing required key 'playbook_names' from {p_cfg}"
+                )
+            elif all([has_playbooks, has_playbook]):
+                raise ValueError(
+                    "Key 'ansible.pull.playbook_name' and"
+                    " 'ansible.pull.playbook_names' are mutually exclusive."
+                    f" Please use 'playbook_names' in {p_cfg}"
+                )
 
     controller_cfg = cfg.get("setup_controller")
     if controller_cfg:
@@ -227,8 +255,10 @@ def filter_args(cfg: dict) -> dict:
 
 
 def run_ansible_pull(pull: AnsiblePull, cfg: dict):
-    playbook_name: str = cfg.pop("playbook_name")
-
+    playbook_name: str = cfg.pop("playbook_name", None)
+    playbook_names: List[str] = cfg.pop("playbook_names", None)
+    if playbook_name:
+        playbook_names = [playbook_name]
     v = pull.get_version()
     if not v:
         LOG.warning("Cannot parse ansible version")
@@ -244,7 +274,7 @@ def run_ansible_pull(pull: AnsiblePull, cfg: dict):
             f"--{key}={value}" if value is not True else f"--{key}"
             for key, value in filter_args(cfg).items()
         ],
-        playbook_name,
+        *playbook_names,
     )
     if stdout:
         sys.stdout.write(f"{stdout}")
diff --git a/cloudinit/config/schemas/schema-cloud-config-v1.json b/cloudinit/config/schemas/schema-cloud-config-v1.json
index c09e8fdd10d..a910c5dbc4b 100644
--- a/cloudinit/config/schemas/schema-cloud-config-v1.json
+++ b/cloudinit/config/schemas/schema-cloud-config-v1.json
@@ -493,6 +493,108 @@
         }
       }
     },
+    "ansible.pull": {
+      "oneOf": [
+        {
+          "required": [
+            "url",
+            "playbook_name"
+          ]
+        },
+        {
+          "required": [
+            "url",
+            "playbook_names"
+          ]
+        }
+      ],
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "accept_host_key": {
+          "type": "boolean",
+          "default": false
+        },
+        "clean": {
+          "type": "boolean",
+          "default": false
+        },
+        "full": {
+          "type": "boolean",
+          "default": false
+        },
+        "diff": {
+          "type": "boolean",
+          "default": false
+        },
+        "ssh_common_args": {
+          "type": "string"
+        },
+        "scp_extra_args": {
+          "type": "string"
+        },
+        "sftp_extra_args": {
+          "type": "string"
+        },
+        "private_key": {
+          "type": "string"
+        },
+        "checkout": {
+          "type": "string"
+        },
+        "module_path": {
+          "type": "string"
+        },
+        "timeout": {
+          "type": "string"
+        },
+        "url": {
+          "type": "string"
+        },
+        "connection": {
+          "type": "string"
+        },
+        "vault_id": {
+          "type": "string"
+        },
+        "vault_password_file": {
+          "type": "string"
+        },
+        "verify_commit": {
+          "type": "boolean",
+          "default": false
+        },
+        "inventory": {
+          "type": "string"
+        },
+        "module_name": {
+          "type": "string"
+        },
+        "sleep": {
+          "type": "string"
+        },
+        "tags": {
+          "type": "string"
+        },
+        "skip_tags": {
+          "type": "string"
+        },
+        "playbook_name": {
+          "deprecated": true,
+          "deprecated_version": "25.2",
+          "deprecated_description": "Use **playbook_names** key instead.",
+          "description": "Single playbook_name to run with ansible-pull",
+          "type": "string"
+        },
+        "playbook_names": {
+          "type": "array",
+          "description": "List of playbook_names to run with ansible-pull",
+          "items": {
+            "type": "string"
+          }
+        }
+      }
+    },
     "apt_configure.mirror": {
       "type": "array",
       "items": {
@@ -866,85 +968,21 @@
               "default": "ansible"
             },
             "pull": {
-              "required": [
-                "url",
-                "playbook_name"
-              ],
-              "type": "object",
-              "additionalProperties": false,
-              "properties": {
-                "accept_host_key": {
-                  "type": "boolean",
-                  "default": false
-                },
-                "clean": {
-                  "type": "boolean",
-                  "default": false
-                },
-                "full": {
-                  "type": "boolean",
-                  "default": false
-                },
-                "diff": {
-                  "type": "boolean",
-                  "default": false
-                },
-                "ssh_common_args": {
-                  "type": "string"
-                },
-                "scp_extra_args": {
-                  "type": "string"
-                },
-                "sftp_extra_args": {
-                  "type": "string"
-                },
-                "private_key": {
-                  "type": "string"
-                },
-                "checkout": {
-                  "type": "string"
-                },
-                "module_path": {
-                  "type": "string"
-                },
-                "timeout": {
-                  "type": "string"
-                },
-                "url": {
-                  "type": "string"
-                },
-                "connection": {
-                  "type": "string"
-                },
-                "vault_id": {
-                  "type": "string"
-                },
-                "vault_password_file": {
-                  "type": "string"
-                },
-                "verify_commit": {
-                  "type": "boolean",
-                  "default": false
-                },
-                "inventory": {
-                  "type": "string"
-                },
-                "module_name": {
-                  "type": "string"
-                },
-                "sleep": {
-                  "type": "string"
-                },
-                "tags": {
-                  "type": "string"
-                },
-                "skip_tags": {
-                  "type": "string"
+              "description": "pull playbooks from a VCS repo and run them on the host",
+              "oneOf": [
+                {
+                  "type": "array",
+                  "items": {
+                    "$ref": "#/$defs/ansible.pull"
+                  }
                 },
-                "playbook_name": {
-                  "type": "string"
+                {
+                  "$ref": "#/$defs/ansible.pull",
+                  "deprecated": true,
+                  "deprecated_version": "25.2",
+                  "deprecated_description": "Expect **ansible.pull** as list of objects instead of a single object."
                 }
-              }
+              ]
             }
           }
         }
diff --git a/doc/examples/cloud-config-ansible-pull.txt b/doc/examples/cloud-config-ansible-pull.txt
index 73985772014..61677fea7d9 100644
--- a/doc/examples/cloud-config-ansible-pull.txt
+++ b/doc/examples/cloud-config-ansible-pull.txt
@@ -10,5 +10,5 @@ packages:
 ansible:
   install_method: pip
   pull:
-    url: "https://github.com/holmanb/vmboot.git"
-    playbook_name: ubuntu.yml
+    - url: "https://github.com/holmanb/vmboot.git"
+      playbook_names: [ubuntu.yml]
diff --git a/doc/module-docs/cc_ansible/data.yaml b/doc/module-docs/cc_ansible/data.yaml
index ca5ac3bfbaf..b296f8b6b50 100644
--- a/doc/module-docs/cc_ansible/data.yaml
+++ b/doc/module-docs/cc_ansible/data.yaml
@@ -9,8 +9,10 @@ cc_ansible:
   - comment: |
       Example 1:
     file: cc_ansible/example1.yaml
-  - comment: |
-      Example 2:
+  - comment: >
+      Example 2: Multiple ansible-pull URLs can be provided by providing a list
+      of pull objects. Additionally, multiple playbooks can be provided as a
+      space-separated playbook_name value.
     file: cc_ansible/example2.yaml
   name: Ansible
   title: Configure Ansible for instance
diff --git a/doc/module-docs/cc_ansible/example1.yaml b/doc/module-docs/cc_ansible/example1.yaml
index 9fd5e5204b1..85161021b99 100644
--- a/doc/module-docs/cc_ansible/example1.yaml
+++ b/doc/module-docs/cc_ansible/example1.yaml
@@ -3,5 +3,5 @@ ansible:
   package_name: ansible-core
   install_method: distro
   pull:
-    url: https://github.com/holmanb/vmboot.git
-    playbook_name: ubuntu.yml
+    - url: https://github.com/holmanb/vmboot.git
+      playbook_names: [ubuntu.yml]
diff --git a/doc/module-docs/cc_ansible/example2.yaml b/doc/module-docs/cc_ansible/example2.yaml
index 2adb491d03f..d54ded7afea 100644
--- a/doc/module-docs/cc_ansible/example2.yaml
+++ b/doc/module-docs/cc_ansible/example2.yaml
@@ -3,5 +3,5 @@ ansible:
   package_name: ansible-core
   install_method: pip
   pull:
-    url: https://github.com/holmanb/vmboot.git
-    playbook_name: ubuntu.yml
+    - url: https://github.com/holmanb/vmboot.git
+      playbook_names: [ubuntu.yml, watermark.yml]
diff --git a/doc/rtd/conf.py b/doc/rtd/conf.py
index 53b4b8c1fbf..212ca832383 100644
--- a/doc/rtd/conf.py
+++ b/doc/rtd/conf.py
@@ -1,6 +1,5 @@
 import datetime
 import glob
-import itertools
 import os
 import sys
 
@@ -240,12 +239,12 @@ def render_property_template(prop_name, prop_cfg, prefix=""):
         description = f" {prop_cfg['description']}"
     else:
         description = ""
-    description += get_deprecated_str(prop_name, prop_cfg)
-    description += get_changed_str(prop_name, prop_cfg)
     jinja_vars = {
         "prefix": prefix,
         "name": prop_name,
         "description": description,
+        "deprecated": get_deprecated_str(prop_name, prop_cfg),
+        "changed": get_changed_str(prop_name, prop_cfg),
         "types": get_types_str(prop_cfg),
         "prop_cfg": prop_cfg,
     }
@@ -270,14 +269,13 @@ def flatten_schema_refs(src_cfg: dict, defs: dict):
                 if "$ref" in sub_schema:
                     reference = sub_schema.pop("$ref").replace("#/$defs/", "")
                     sub_schema.update(defs[reference])
-    for sub_schema in itertools.chain(
-        src_cfg.get("oneOf", []),
-        src_cfg.get("anyOf", []),
-        src_cfg.get("allOf", []),
-    ):
-        if "$ref" in sub_schema:
-            reference = sub_schema.pop("$ref").replace("#/$defs/", "")
-            sub_schema.update(defs[reference])
+    for key in ("anyOf", "oneOf", "allOf"):
+        if key in src_cfg:
+            for sub_schema in src_cfg[key]:
+                flatten_schema_refs(sub_schema, defs)
+    #   if "$ref" in sub_schema:
+    #       reference = sub_schema.pop("$ref").replace("#/$defs/", "")
+    #       sub_schema.update(defs[reference])
 
 
 def flatten_schema_all_of(src_cfg: dict):
@@ -295,6 +293,9 @@ def flatten_schema_all_of(src_cfg: dict):
 
 def render_nested_properties(prop_cfg, defs, prefix):
     prop_str = ""
+    if "oneOf" in prop_cfg:
+        for alt_schema in prop_cfg["oneOf"]:
+            prop_str += render_nested_properties(alt_schema, defs, prefix)
     prop_types = set(["properties", "patternProperties"])
     flatten_schema_refs(prop_cfg, defs)
     if "items" in prop_cfg:
diff --git a/doc/rtd/templates/module_property.tmpl b/doc/rtd/templates/module_property.tmpl
index 895a8956581..50875a968f9 100644
--- a/doc/rtd/templates/module_property.tmpl
+++ b/doc/rtd/templates/module_property.tmpl
@@ -1,12 +1,23 @@
-{% macro print_prop(name, types, description, prefix) -%}
+{% macro print_prop(name, types, description, deprecated, changed, prefix) -%}
 {% set descr_suffix = description.splitlines()[0]|d('') -%}
 {% set descr_lines = description.splitlines()[1:]|d([]) -%}
+{% set deprecated_lines = deprecated.splitlines()|d([]) -%}
+{% set changed_lines = changed.splitlines()|d([]) -%}
 {{prefix}}* **{{name}}:** ({{types}}){{ descr_suffix }}
 {% for line in descr_lines -%}
 {{prefix ~ '  '}}{{ line }}
 {% endfor -%}
+{% for line in deprecated_lines -%}
+{{prefix ~ '  '}}{{ line }}
+{% endfor -%}
+{% for line in changed_lines -%}
+{{prefix ~ '  '}}{{ line }}
+{% endfor -%}
 {%- endmacro -%}
 {% set ns = namespace(is_obj_type=false) -%}
+{% for alt_schema in prop_cfg.get("oneOf", []) -%}
+  {% if ('properties' in alt_schema or 'patternProperties' in alt_schema) %}{% set ns.is_obj_type = true -%}{% endif -%}
+{% endfor -%}
 {% if ('properties' in prop_cfg or 'patternProperties' in prop_cfg) %}{% set ns.is_obj_type = true -%}{% endif -%}
 {% for key, val in prop_cfg.get('items', {}).items() -%}
   {% if key in ('properties', 'patternProperties') -%}{% set ns.is_obj_type = true -%}{% endif -%}
@@ -19,4 +30,4 @@
 {% if ns.is_obj_type -%}
 {% set description = description ~ " Each object in **" ~ name ~ "** list supports the following keys:" -%}
 {% endif -%}
-{{ print_prop(name, types, description, prefix ) -}}
+{{ print_prop(name, types, description, deprecated, changed, prefix ) -}}
diff --git a/tests/integration_tests/modules/test_ansible.py b/tests/integration_tests/modules/test_ansible.py
index bf74cba2105..ca603abdc48 100644
--- a/tests/integration_tests/modules/test_ansible.py
+++ b/tests/integration_tests/modules/test_ansible.py
@@ -37,6 +37,18 @@
              - python3-pip
          roles:
            - apt
+  - path: /root/playbooks/watermark.yml
+    content: |
+       ---
+       - hosts: 127.0.0.1
+         connection: local
+         become: true
+         tasks:
+           - name: create a watermark file to assert multiple playbooks run
+             copy:
+               content: |
+                 cloud-init was here
+               dest: /MULTIPLAYBOOK_RUN
   - path: /root/playbooks/roles/apt/tasks/main.yml
     content: |
        ---
@@ -94,20 +106,21 @@
   package_name: {package}
   galaxy:
     actions:
-     - ["ansible-galaxy", "collection", "install", "community.grafana"]
+     - ["ansible-galaxy", "collection", "install", "community.elastic"]
   pull:
-    url: "http://0.0.0.0:8000/"
-    playbook_name: ubuntu.yml
-    full: true
+    - url: "http://0.0.0.0:8000/"
+      playbook_names: [ubuntu.yml]
+      full: true
 """
 
-SETUP_REPO = f"cd {REPO_D}                                    &&\
-git config --global user.name auto                            &&\
-git config --global user.email autom@tic.io                   &&\
-git config --global init.defaultBranch main                   &&\
-git init {REPO_D}                                             &&\
-git add {REPO_D}/roles/apt/tasks/main.yml {REPO_D}/ubuntu.yml &&\
-git commit -m auto                                            &&\
+SETUP_REPO = f"cd {REPO_D}                                    && \
+git config --global user.name auto                            && \
+git config --global user.email autom@tic.io                   && \
+git config --global init.defaultBranch main                   && \
+git init {REPO_D}                                             && \
+git add {REPO_D}/roles/apt/tasks/main.yml {REPO_D}/ubuntu.yml && \
+git add {REPO_D}/watermark.yml                                && \
+git commit -m auto                                            && \
 (cd {REPO_D}/.git; git update-server-info)"
 
 ANSIBLE_CONTROL = """\
@@ -270,15 +283,17 @@ def _test_ansible_pull_from_local_server(my_client):
     verify_clean_log(log)
     verify_clean_boot(my_client)
     output_log = my_client.read_from_file("/var/log/cloud-init-output.log")
-    assert "ok=3" in output_log
+    assert "ok=5" in output_log
     assert "SUCCESS: config-ansible ran successfully" in log
 
     # binary location is dependent on install-type, check the filepath
     # to ensure that the installed collection directory exists
     output = my_client.execute(
-        "ls /root/.ansible/collections/ansible_collections/community/grafana"
+        "ls /root/.ansible/collections/ansible_collections/community/elastic"
     )
     assert not output.stderr.strip() and output.ok
+    playbook2_content = my_client.read_from_file("/MULTIPLAYBOOK_RUN")
+    assert "cloud-init was here" == playbook2_content
 
 
 # temporarily disable this test on jenkins until firewall rules are in place
@@ -304,7 +319,6 @@ def test_ansible_pull_pip(client: IntegrationInstance):
 @pytest.mark.user_data(
     USER_DATA + INSTALL_METHOD.format(package="ansible", method="distro")
 )
-@pytest.mark.skip("This test is currently broken and needs to be fixed")
 def test_ansible_pull_distro(client):
     push_and_enable_systemd_unit(client, "repo_server.service", REPO_SERVER)
     push_and_enable_systemd_unit(client, "repo_waiter.service", REPO_WAITER)
diff --git a/tests/unittests/config/test_cc_ansible.py b/tests/unittests/config/test_cc_ansible.py
index bd0e04eefb7..2d0c359f300 100644
--- a/tests/unittests/config/test_cc_ansible.py
+++ b/tests/unittests/config/test_cc_ansible.py
@@ -15,7 +15,10 @@
     get_schema,
     validate_cloudconfig_schema,
 )
-from tests.unittests.helpers import skipUnlessJsonSchema
+from tests.unittests.helpers import (
+    skipUnlessJsonSchema,
+    skipUnlessJsonSchemaVersionGreaterThan,
+)
 from tests.unittests.util import get_cloud
 
 try:
@@ -100,7 +103,7 @@
     },
 }
 
-CFG_FULL_PULL: Dict[str, Any] = {
+CFG_FULL_PULL_DICT: Dict[str, Any] = {
     "ansible": {
         "install_method": "distro",
         "package_name": "ansible-core",
@@ -110,7 +113,7 @@
         },
         "pull": {
             "url": "https://github/holmanb/vmboot",
-            "playbook_name": "arch.yml",
+            "playbook_names": ["arch.yml"],
             "accept_host_key": True,
             "clean": True,
             "full": True,
@@ -134,46 +137,107 @@
     }
 }
 
-CFG_MINIMAL = {
+CFG_FULL_PULL_LIST: Dict[str, Any] = {
+    "ansible": {
+        "install_method": "distro",
+        "package_name": "ansible-core",
+        "ansible_config": "/etc/ansible/ansible.cfg",
+        "galaxy": {
+            "actions": [["ansible-galaxy", "install", "debops.apt"]],
+        },
+        "pull": [
+            {
+                "url": "https://github/holmanb/vmboot",
+                "playbook_names": ["arch.yml"],
+                "accept_host_key": True,
+                "clean": True,
+                "full": True,
+                "diff": False,
+                "ssh_common_args": "-y",
+                "scp_extra_args": "-l",
+                "sftp_extra_args": "-f",
+                "checkout": "tree",
+                "module_path": "~/.ansible/plugins/modules:"
+                "/usr/share/ansible/plugins/modules",
+                "timeout": "10",
+                "vault_id": "me",
+                "connection": "smart",
+                "vault_password_file": "/path/to/file",
+                "module_name": "git",
+                "sleep": "1",
+                "tags": "cumulus",
+                "skip_tags": "cisco",
+                "private_key": "{nope}",
+            }
+        ],
+    }
+}
+
+CFG_MINIMAL_DICT = {
     "ansible": {
         "install_method": "pip",
         "package_name": "ansible",
         "run_user": "ansible",
         "pull": {
             "url": "https://github/holmanb/vmboot",
-            "playbook_name": "ubuntu.yml",
+            "playbook_names": ["ubuntu.yml"],
         },
     }
 }
 
+CFG_MINIMAL_LIST = {
+    "ansible": {
+        "install_method": "pip",
+        "package_name": "ansible",
+        "run_user": "ansible",
+        "pull": [
+            {
+                "url": "https://github/holmanb/vmboot",
+                "playbook_names": ["ubuntu.yml"],
+            }
+        ],
+    }
+}
+
 
 class TestSchema:
+
     @mark.parametrize(
         ("config", "error_msg"),
         (
             param(
-                CFG_MINIMAL,
-                None,
-                id="essentials",
+                CFG_MINIMAL_DICT,
+                "Expect **ansible.pull** as list of objects",
+                id="essentials_dict",
             ),
+            param(
+                CFG_FULL_PULL_DICT,
+                "Expect **ansible.pull** as list of objects",
+                id="all-pull-keys-dict",
+            ),
+        ),
+    )
+    @skipUnlessJsonSchemaVersionGreaterThan(version=(3, 2, 0))
+    def test_schema_validation_deprecations(self, config, error_msg):
+        with raises(SchemaValidationError, match=re.escape(error_msg)):
+            validate_cloudconfig_schema(config, get_schema(), strict=True)
+
+    @mark.parametrize(
+        ("config", "error_msg"),
+        (
             param(
                 {
                     "ansible": {
                         "install_method": "distro",
                         "pull": {
                             "url": "https://github/holmanb/vmboot",
-                            "playbook_name": "centos.yml",
+                            "playbook_names": ["centos.yml"],
                             "dance": "bossa nova",
                         },
                     }
                 },
                 "Additional properties are not allowed ",
-                id="additional-properties",
-            ),
-            param(
-                CFG_FULL_PULL,
-                None,
-                id="all-pull-keys",
+                id="additional-properties-dict",
             ),
             param(
                 CFG_CTRL,
@@ -186,24 +250,24 @@ class TestSchema:
                         "install_method": "true",
                         "pull": {
                             "url": "https://github/holmanb/vmboot",
-                            "playbook_name": "debian.yml",
+                            "playbook_names": ["debian.yml"],
                         },
                     }
                 },
                 "'true' is not one of ['distro', 'pip']",
-                id="install-type",
+                id="install-type-dict",
             ),
             param(
                 {
                     "ansible": {
                         "install_method": "pip",
                         "pull": {
-                            "playbook_name": "fedora.yml",
+                            "playbook_names": ["fedora.yml"],
                         },
                     }
                 },
                 "'url' is a required property",
-                id="require-url",
+                id="require-url-dict",
             ),
             param(
                 {
@@ -215,7 +279,81 @@ class TestSchema:
                     }
                 },
                 "'playbook_name' is a required property",
-                id="require-url",
+                id="require-url-dict",
+            ),
+            param(
+                CFG_MINIMAL_LIST,
+                None,
+                id="essentials_list",
+            ),
+            param(
+                {
+                    "ansible": {
+                        "install_method": "distro",
+                        "pull": [
+                            {
+                                "url": "https://github/holmanb/vmboot",
+                                "playbook_names": ["centos.yml"],
+                                "dance": "bossa nova",
+                            }
+                        ],
+                    }
+                },
+                "Additional properties are not allowed ",
+                id="additional-properties-list",
+            ),
+            param(
+                CFG_FULL_PULL_LIST,
+                None,
+                id="all-pull-keys-list",
+            ),
+            param(
+                CFG_CTRL,
+                None,
+                id="ctrl-keys",
+            ),
+            param(
+                {
+                    "ansible": {
+                        "install_method": "true",
+                        "pull": [
+                            {
+                                "url": "https://github/holmanb/vmboot",
+                                "playbook_names": ["debian.yml"],
+                            }
+                        ],
+                    }
+                },
+                "'true' is not one of ['distro', 'pip']",
+                id="install-type-list",
+            ),
+            param(
+                {
+                    "ansible": {
+                        "install_method": "pip",
+                        "pull": [
+                            {
+                                "playbook_names": ["fedora.yml"],
+                            }
+                        ],
+                    }
+                },
+                "'url' is a required property",
+                id="require-url-list",
+            ),
+            param(
+                {
+                    "ansible": {
+                        "install_method": "pip",
+                        "pull": [
+                            {
+                                "url": "gophers://encrypted-gophers/",
+                            }
+                        ],
+                    }
+                },
+                "'playbook_name' is a required property",
+                id="require-url-list",
             ),
         ),
     )
@@ -231,12 +369,13 @@ def test_schema_validation(self, config, error_msg):
 class TestAnsible:
     def test_filter_args(self):
         """only diff should be removed"""
-        out = cc_ansible.filter_args(
-            CFG_FULL_PULL.get("ansible", {}).get("pull", {}),
+        src_cfg = deepcopy(
+            CFG_FULL_PULL_DICT.get("ansible", {}).get("pull", {})
         )
-        assert out == {
+        src_cfg.pop("playbook_names")
+        out_dict = cc_ansible.filter_args(src_cfg)
+        assert out_dict == {
             "url": "https://github/holmanb/vmboot",
-            "playbook-name": "arch.yml",
             "accept-host-key": True,
             "clean": True,
             "full": True,
@@ -258,17 +397,17 @@ def test_filter_args(self):
         }
 
     @mark.parametrize(
-        ("cfg", "exception"),
+        ("cfg_dict", "exception"),
         (
-            (CFG_FULL_PULL, None),
-            (CFG_MINIMAL, None),
+            (CFG_FULL_PULL_DICT, None),
+            (CFG_MINIMAL_DICT, None),
             (
                 {
                     "ansible": {
                         "package_name": "ansible-core",
                         "install_method": "distro",
                         "pull": {
-                            "playbook_name": "ubuntu.yml",
+                            "playbook_names": ["ubuntu.yml"],
                         },
                     }
                 },
@@ -287,7 +426,74 @@ def test_filter_args(self):
             ),
         ),
     )
-    def test_required_keys(self, cfg, exception, mocker):
+    def test_required_keys_dict(self, cfg_dict, exception, mocker):
+        mocker.patch(M_PATH + "subp.subp", return_value=("", ""))
+        mocker.patch(M_PATH + "subp.which", return_value=True)
+        mocker.patch(M_PATH + "AnsiblePull.check_deps")
+        mocker.patch(
+            M_PATH + "AnsiblePull.get_version",
+            return_value=cc_ansible.lifecycle.Version(2, 7, 1),
+        )
+        mocker.patch(
+            M_PATH + "AnsiblePullDistro.is_installed",
+            return_value=False,
+        )
+        if exception:
+            with raises(exception):
+                cc_ansible.handle("", cfg_dict, get_cloud(), [])
+        else:
+            cloud = get_cloud(mocked_distro=True)
+            cloud.distro.pip_package_name = "python3-pip"
+            install = cfg_dict["ansible"]["install_method"]
+            cc_ansible.handle("", cfg_dict, cloud, [])
+            if install == "distro":
+                cloud.distro.install_packages.assert_called_once()
+                cloud.distro.install_packages.assert_called_with(
+                    ["ansible-core"]
+                )
+            elif install == "pip":
+                if HAS_PIP:
+                    assert 0 == cloud.distro.install_packages.call_count
+                else:
+                    cloud.distro.install_packages.assert_called_with(
+                        ["python3-pip"]
+                    )
+
+    @mark.parametrize(
+        ("cfg_list", "exception"),
+        (
+            (CFG_FULL_PULL_LIST, None),
+            (CFG_MINIMAL_LIST, None),
+            (
+                {
+                    "ansible": {
+                        "package_name": "ansible-core",
+                        "install_method": "distro",
+                        "pull": [
+                            {
+                                "playbook_names": ["ubuntu.yml"],
+                            }
+                        ],
+                    }
+                },
+                ValueError,
+            ),
+            (
+                {
+                    "ansible": {
+                        "install_method": "pip",
+                        "pull": [
+                            {
+                                "url": "https://github/holmanb/vmboot",
+                            }
+                        ],
+                    }
+                },
+                ValueError,
+            ),
+        ),
+    )
+    def test_required_keys_list(self, cfg_list, exception, mocker):
         mocker.patch(M_PATH + "subp.subp", return_value=("", ""))
         mocker.patch(M_PATH + "subp.which", return_value=True)
         mocker.patch(M_PATH + "AnsiblePull.check_deps")
@@ -301,12 +507,12 @@ def test_required_keys(self, cfg, exception, mocker):
         )
         if exception:
             with raises(exception):
-                cc_ansible.handle("", cfg, get_cloud(), [])
+                cc_ansible.handle("", cfg_list, get_cloud(), [])
         else:
             cloud = get_cloud(mocked_distro=True)
             cloud.distro.pip_package_name = "python3-pip"
-            install = cfg["ansible"]["install_method"]
-            cc_ansible.handle("", cfg, cloud, [])
+            install = cfg_list["ansible"]["install_method"]
+            cc_ansible.handle("", cfg_list, cloud, [])
             if install == "distro":
                 cloud.distro.install_packages.assert_called_once()
                 cloud.distro.install_packages.assert_called_with(
@@ -350,10 +556,10 @@ def test_pip_bootstrap(self, m_which, m_subp):
         "cloudinit.distros.subp.subp", return_value=("stdout", "stderr")
     )
     @mark.parametrize(
-        ("cfg", "expected"),
+        ("cfg_dict", "expected"),
         (
             (
-                CFG_FULL_PULL,
+                CFG_FULL_PULL_DICT,
                 [
                     "ansible-pull",
                     "--url=https://github/holmanb/vmboot",
@@ -379,7 +585,7 @@ def test_pip_bootstrap(self, m_which, m_subp):
                 ],
             ),
             (
-                CFG_MINIMAL,
+                CFG_MINIMAL_DICT,
                 [
                     "ansible-pull",
                     "--url=https://github/holmanb/vmboot",
@@ -388,9 +594,11 @@ def test_pip_bootstrap(self, m_which, m_subp):
             ),
         ),
     )
-    def test_ansible_pull(self, m_subp1, m_subp2, m_which, cfg, expected):
+    def test_ansible_pull_dict(
+        self, m_subp1, m_subp2, m_which, cfg_dict, expected
+    ):
         """verify expected ansible invocation from userdata config"""
-        pull_type = cfg["ansible"]["install_method"]
+        pull_type = cfg_dict["ansible"]["install_method"]
         distro = get_cloud().distro
         ansible_pull = (
             cc_ansible.AnsiblePullPip(distro, "ansible")
@@ -398,8 +606,72 @@ def test_ansible_pull(self, m_subp1, m_subp2, m_which, cfg, expected):
             else cc_ansible.AnsiblePullDistro(distro, "")
         )
         cc_ansible.run_ansible_pull(
-            ansible_pull, deepcopy(cfg["ansible"]["pull"])
+            ansible_pull, deepcopy(cfg_dict["ansible"]["pull"])
+        )
+
+        if pull_type != "pip":
+            assert m_subp2.call_args[0][0] == expected
+            assert m_subp2.call_args[1]["update_env"].get(
+                "HOME"
+            ) == os.environ.get("HOME", "/root")
+
+    @mock.patch(M_PATH + "subp.which", return_value=True)
+    @mock.patch(M_PATH + "subp.subp", return_value=("stdout", "stderr"))
+    @mock.patch(
+        "cloudinit.distros.subp.subp", return_value=("stdout", "stderr")
+    )
+    @mark.parametrize(
+        ("cfg_list", "expected"),
+        (
+            (
+                CFG_FULL_PULL_LIST,
+                [
+                    "ansible-pull",
+                    "--url=https://github/holmanb/vmboot",
+                    "--accept-host-key",
+                    "--clean",
+                    "--full",
+                    "--ssh-common-args=-y",
+                    "--scp-extra-args=-l",
+                    "--sftp-extra-args=-f",
+                    "--checkout=tree",
+                    "--module-path=~/.ansible/plugins/modules"
+                    ":/usr/share/ansible/plugins/modules",
+                    "--timeout=10",
+                    "--vault-id=me",
+                    "--connection=smart",
+                    "--vault-password-file=/path/to/file",
+                    "--module-name=git",
+                    "--sleep=1",
+                    "--tags=cumulus",
+                    "--skip-tags=cisco",
+                    "--private-key={nope}",
+                    "arch.yml",
+                ],
+            ),
+            (
+                CFG_MINIMAL_LIST,
+                [
+                    "ansible-pull",
+                    "--url=https://github/holmanb/vmboot",
+                    "ubuntu.yml",
+                ],
+            ),
+        ),
+    )
+    def test_ansible_pull_list(
+        self, m_subp1, m_subp2, m_which, cfg_list, expected
+    ):
+        """verify expected ansible invocation from userdata config"""
+        pull_type = cfg_list["ansible"]["install_method"]
+        distro = get_cloud().distro
+        ansible_pull = (
+            cc_ansible.AnsiblePullPip(distro, "ansible")
+            if pull_type == "pip"
+            else cc_ansible.AnsiblePullDistro(distro, "")
         )
+        for cfg in cfg_list["ansible"]["pull"]:
+            cc_ansible.run_ansible_pull(ansible_pull, deepcopy(cfg))
 
         if pull_type != "pip":
             assert m_subp2.call_args[0][0] == expected
@@ -436,7 +708,7 @@ def test_parse_version_pip(self, m_subp):
     @mock.patch(M_PATH + "subp.subp", return_value=("stdout", "stderr"))
     @mock.patch(M_PATH + "subp.which", return_value=True)
     def test_ansible_env_var(self, m_which, m_subp):
-        cc_ansible.handle("", CFG_FULL_PULL, get_cloud(), [])
+        cc_ansible.handle("", CFG_FULL_PULL_DICT, get_cloud(), [])
 
         # python 3.8 required for Mock.call_args.kwargs dict attribute
         if isinstance(m_subp.call_args.kwargs, dict):


Reviews:
user: login: blackboxsw
body: Thank you for putting together this PR @shaerpour and making cloud-init more flexible w.r.t. ansible use-cases.

Can you also adapt the documented examples at doc/module-docs/cc_ansible to ensure we document that lists of dicts are allowed. doc generation can be tested with `tox -e doc; xdg-open doc/rtd_html/reference/modules.html`

Below is a combined patch of my inline suggestions above, with the exception of module-docs.
```diff
commit ebc67dba301b7d0cce59b780d6ebebd2bac69466 (HEAD -> ansible-pull-list)
Author: Chad Smith <chad.smith@canonical.com>
Date:   Thu May 1 15:45:01 2025 -0600

    chore:refactor cc_ansible logic to avoid code duplication
    
    - consolidate logic to process ansible.pull list of dicts vs single dict
    - refactor JSON schema to use $defs instead of duplication
    - refactor unittests to use existing schema parametrize

diff --git a/cloudinit/config/cc_ansible.py b/cloudinit/config/cc_ansible.py
index 2ee3eb19c..e1c814c34 100644
--- a/cloudinit/config/cc_ansible.py
+++ b/cloudinit/config/cc_ansible.py
@@ -184,14 +184,10 @@ def handle(name: str, cfg: Config, cloud: Cloud, args: list) -> None:
             ansible_galaxy(galaxy_cfg, ansible)
 
         if pull_cfg:
-            pull_cfg_type = type(pull_cfg)
-            if pull_cfg_type is dict:
-                run_ansible_pull(ansible, deepcopy(pull_cfg))
-            elif pull_cfg_type is list:
-                for cfg in pull_cfg:
-                    run_ansible_pull(ansible, deepcopy(cfg))
-            else:
-                raise ValueError(f"Unknown type for pull: {pull_cfg_type}")
+            if isinstance(pull_cfg, dict):
+                pull_cfg = [pull_cfg]
+            for cfg in pull_cfg:
+                run_ansible_pull(ansible, deepcopy(cfg))
 
         if setup_controller:
             ansible_controller(setup_controller, ansible)
@@ -207,20 +203,24 @@ def validate_config(cfg: dict):
             raise ValueError(f"Missing required key '{key}' from {cfg}")
     pull_cfg = cfg.get("pull")
     if pull_cfg:
-        pull_cfg_type = type(pull_cfg)
-        if pull_cfg_type is dict:
-            for key in "url", "playbook_name":
-                if not get_cfg_by_path(pull_cfg, key):
+        if isinstance(pull_cfg, dict):
+            pull_cfg = [pull_cfg]
+        elif not isinstance(pull_cfg, list):
+            raise ValueError(
+                "Invalid value ansible.pull. Expected either dict of list of"
+                f" dicts but found {pull_cfg}"
+            )
+        for p_cfg in pull_cfg:
+            if not isinstance(p_cfg, dict):
+                raise ValueError(
+                    "Invalid value of ansible.pull. Expected dict but found"
+                    f" {p_cfg}"
+                )
+            for key in ("url", "playbook_name"):
+                if not get_cfg_by_path(p_cfg, key):
                     raise ValueError(
-                        f"Missing required key '{key}' from {pull_cfg}"
+                        f"Missing required key '{key}' from {p_cfg}"
                     )
-        elif pull_cfg_type is list:
-            for c in pull_cfg:
-                for key in "url", "playbook_name":
-                    if not get_cfg_by_path(c, key):
-                        raise ValueError(
-                            f"Missing required key '{key}' from {c}"
-                        )
 
     controller_cfg = cfg.get("setup_controller")
     if controller_cfg:
@@ -239,23 +239,11 @@ def validate_config(cfg: dict):
 
 def filter_args(cfg):
     """remove boolean false values"""
-    if type(cfg) is dict:
-        return {
-            key.replace("_", "-"): value
-            for (key, value) in cfg.items()
-            if value is not False
-        }
-    elif type(cfg) is list:
-        list_of_playbooks = []
-        for c in cfg:
-            list_of_playbooks.append(
-                {
-                    key.replace("_", "-"): value
-                    for (key, value) in c.items()
-                    if value is not False
-                }
-            )
-        return list_of_playbooks
+    return {
+        key.replace("_", "-"): value
+        for (key, value) in cfg.items()
+        if value is not False
+    }
 
 
 def run_ansible_pull(pull: AnsiblePull, cfg: dict):
diff --git a/cloudinit/config/schemas/schema-cloud-config-v1.json b/cloudinit/config/schemas/schema-cloud-config-v1.json
index 761ad6133..596a0f722 100644
--- a/cloudinit/config/schemas/schema-cloud-config-v1.json
+++ b/cloudinit/config/schemas/schema-cloud-config-v1.json
@@ -493,6 +493,87 @@
         }
       }
     },
+    "ansible.pull": {
+      "required": [
+        "url",
+        "playbook_name"
+      ],
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "accept_host_key": {
+          "type": "boolean",
+          "default": false
+        },
+        "clean": {
+          "type": "boolean",
+          "default": false
+        },
+        "full": {
+          "type": "boolean",
+          "default": false
+        },
+        "diff": {
+          "type": "boolean",
+          "default": false
+        },
+        "ssh_common_args": {
+          "type": "string"
+        },
+        "scp_extra_args": {
+          "type": "string"
+        },
+        "sftp_extra_args": {
+          "type": "string"
+        },
+        "private_key": {
+          "type": "string"
+        },
+        "checkout": {
+          "type": "string"
+        },
+        "module_path": {
+          "type": "string"
+        },
+        "timeout": {
+          "type": "string"
+        },
+        "url": {
+          "type": "string"
+        },
+        "connection": {
+          "type": "string"
+        },
+        "vault_id": {
+          "type": "string"
+        },
+        "vault_password_file": {
+          "type": "string"
+        },
+        "verify_commit": {
+          "type": "boolean",
+          "default": false
+        },
+        "inventory": {
+          "type": "string"
+        },
+        "module_name": {
+          "type": "string"
+        },
+        "sleep": {
+          "type": "string"
+        },
+        "tags": {
+          "type": "string"
+        },
+        "skip_tags": {
+          "type": "string"
+        },
+        "playbook_name": {
+          "type": "string"
+        }
+      }
+    },
     "apt_configure.mirror": {
       "type": "array",
       "items": {
@@ -867,168 +948,11 @@
             },
             "pull": {
               "oneOf": [
-                {
-                  "required": [
-                    "url",
-                    "playbook_name"
-                  ],
-                  "type": "object",
-                  "additionalProperties": false,
-                  "properties": {
-                    "accept_host_key": {
-                      "type": "boolean",
-                      "default": false
-                    },
-                    "clean": {
-                      "type": "boolean",
-                      "default": false
-                    },
-                    "full": {
-                      "type": "boolean",
-                      "default": false
-                    },
-                    "diff": {
-                      "type": "boolean",
-                      "default": false
-                    },
-                    "ssh_common_args": {
-                      "type": "string"
-                    },
-                    "scp_extra_args": {
-                      "type": "string"
-                    },
-                    "sftp_extra_args": {
-                      "type": "string"
-                    },
-                    "private_key": {
-                      "type": "string"
-                    },
-                    "checkout": {
-                      "type": "string"
-                    },
-                    "module_path": {
-                      "type": "string"
-                    },
-                    "timeout": {
-                      "type": "string"
-                    },
-                    "url": {
-                      "type": "string"
-                    },
-                    "connection": {
-                      "type": "string"
-                    },
-                    "vault_id": {
-                      "type": "string"
-                    },
-                    "vault_password_file": {
-                      "type": "string"
-                    },
-                    "verify_commit": {
-                      "type": "boolean",
-                      "default": false
-                    },
-                    "inventory": {
-                      "type": "string"
-                    },
-                    "module_name": {
-                      "type": "string"
-                    },
-                    "sleep": {
-                      "type": "string"
-                    },
-                    "tags": {
-                      "type": "string"
-                    },
-                    "skip_tags": {
-                      "type": "string"
-                    },
-                    "playbook_name": {
-                      "type": "string"
-                    }
-                  }
-                },
+               {"$ref": "#/$defs/ansible.pull"},
                 {
                   "type": "array",
                   "items": {
-                    "required": [
-                      "url",
-                      "playbook_name"
-                    ],
-                    "additionalProperties": false,
-                    "properties": {
-                      "accept_host_key": {
-                        "type": "boolean",
-                        "default": false
-                      },
-                      "clean": {
-                        "type": "boolean",
-                        "default": false
-                      },
-                      "full": {
-                        "type": "boolean",
-                        "default": false
-                      },
-                      "diff": {
-                        "type": "boolean",
-                        "default": false
-                      },
-                      "ssh_common_args": {
-                        "type": "string"
-                      },
-                      "scp_extra_args": {
-                        "type": "string"
-                      },
-                      "sftp_extra_args": {
-                        "type": "string"
-                      },
-                      "private_key": {
-                        "type": "string"
-                      },
-                      "checkout": {
-                        "type": "string"
-                      },
-                      "module_path": {
-                        "type": "string"
-                      },
-                      "timeout": {
-                        "type": "string"
-                      },
-                      "url": {
-                        "type": "string"
-                      },
-                      "connection": {
-                        "type": "string"
-                      },
-                      "vault_id": {
-                        "type": "string"
-                      },
-                      "vault_password_file": {
-                        "type": "string"
-                      },
-                      "verify_commit": {
-                        "type": "boolean",
-                        "default": false
-                      },
-                      "inventory": {
-                        "type": "string"
-                      },
-                      "module_name": {
-                        "type": "string"
-                      },
-                      "sleep": {
-                        "type": "string"
-                      },
-                      "tags": {
-                        "type": "string"
-                      },
-                      "skip_tags": {
-                        "type": "string"
-                      },
-                      "playbook_name": {
-                        "type": "string"
-                      }
-                    }
+                    "$ref": "#/$defs/ansible.pull"
                   }
                 }
               ]
diff --git a/doc/module-docs/cc_ansible/example2.yaml b/doc/module-docs/cc_ansible/example2.yaml
index 2adb491d0..f247b2267 100644
--- a/doc/module-docs/cc_ansible/example2.yaml
+++ b/doc/module-docs/cc_ansible/example2.yaml
@@ -4,4 +4,4 @@ ansible:
   install_method: pip
   pull:
     url: https://github.com/holmanb/vmboot.git
-    playbook_name: ubuntu.yml
+    playbook_name: ubuntu.yml watermark.yml
diff --git a/tests/unittests/config/test_cc_ansible.py b/tests/unittests/config/test_cc_ansible.py
index db7384d3c..0be2415ae 100644
--- a/tests/unittests/config/test_cc_ansible.py
+++ b/tests/unittests/config/test_cc_ansible.py
@@ -199,7 +199,7 @@ CFG_MINIMAL_LIST = {
 
 class TestSchema:
     @mark.parametrize(
-        ("config_dict", "error_msg"),
+        ("config", "error_msg"),
         (
             param(
                 CFG_MINIMAL_DICT,
@@ -267,21 +267,6 @@ class TestSchema:
                 "'playbook_name' is a required property",
                 id="require-url-dict",
             ),
-        ),
-    )
-    @skipUnlessJsonSchema()
-    def test_schema_validationt_dict(self, config_dict, error_msg):
-        if error_msg is None:
-            validate_cloudconfig_schema(config_dict, get_schema(), strict=True)
-        else:
-            with raises(SchemaValidationError, match=re.escape(error_msg)):
-                validate_cloudconfig_schema(
-                    config_dict, get_schema(), strict=True
-                )
-
-    @mark.parametrize(
-        ("config_list", "error_msg"),
-        (
             param(
                 CFG_MINIMAL_LIST,
                 None,
@@ -359,13 +344,13 @@ class TestSchema:
         ),
     )
     @skipUnlessJsonSchema()
-    def test_schema_validationt_list(self, config_list, error_msg):
+    def test_schema_validation(self, config, error_msg):
         if error_msg is None:
-            validate_cloudconfig_schema(config_list, get_schema(), strict=True)
+            validate_cloudconfig_schema(config, get_schema(), strict=True)
         else:
             with raises(SchemaValidationError, match=re.escape(error_msg)):
                 validate_cloudconfig_schema(
-                    config_list, get_schema(), strict=True
+                    config, get_schema(), strict=True
                 )
 
 
@@ -397,33 +382,6 @@ class TestAnsible:
             "skip-tags": "cisco",
             "private-key": "{nope}",
         }
-        out_list = cc_ansible.filter_args(
-            CFG_FULL_PULL_LIST.get("ansible", {}).get("pull", []),
-        )
-        assert out_list == [
-            {
-                "url": "https://github/holmanb/vmboot",
-                "playbook-name": "arch.yml",
-                "accept-host-key": True,
-                "clean": True,
-                "full": True,
-                "ssh-common-args": "-y",
-                "scp-extra-args": "-l",
-                "sftp-extra-args": "-f",
-                "checkout": "tree",
-                "module-path": "~/.ansible/plugins/modules:"
-                "/usr/share/ansible/plugins/modules",
-                "timeout": "10",
-                "vault-id": "me",
-                "connection": "smart",
-                "vault-password-file": "/path/to/file",
-                "module-name": "git",
-                "sleep": "1",
-                "tags": "cumulus",
-                "skip-tags": "cisco",
-                "private-key": "{nope}",
-            }
-        ]
 
     @mark.parametrize(
         ("cfg_dict", "exception"),
```
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-05-01T22:21:06Z
user: login: blackboxsw
body: Thank you for the followup. here.

In testing further, I discovered that multiple playbook names can be supported by cloud-init via `ansible-pull` CLI. We can document in schema and module doc examples to allow `playbook_name` to accept a space-delimited value for multiple playbooks.

I've provided integration tests changes which enable and fix the existing ansible distro  test and also exercise the multiple-playbooks provided in `playbook_name`.
Below is the provided combined diff fixing docs and integration test, 

To fix the integration test I adapted ansible collection to community.elastic, because the distro package in ubuntu already contains community.grafana in the deb package which broke the integration test assertion on file presence for grafana
```diff
diff --git a/cloudinit/config/cc_ansible.py b/cloudinit/config/cc_ansible.py
index e1c814c34..6097affae 100644
--- a/cloudinit/config/cc_ansible.py
+++ b/cloudinit/config/cc_ansible.py
@@ -237,7 +237,7 @@ def validate_config(cfg: dict):
         raise ValueError("Invalid install method {install}")
 
 
-def filter_args(cfg):
+def filter_args(cfg: dict) -> dict:
     """remove boolean false values"""
     return {
         key.replace("_", "-"): value
@@ -264,7 +264,7 @@ def run_ansible_pull(pull: AnsiblePull, cfg: dict):
             f"--{key}={value}" if value is not True else f"--{key}"
             for key, value in filter_args(cfg).items()
         ],
-        playbook_name,
+        *playbook_name.split(),
     )
     if stdout:
         sys.stdout.write(f"{stdout}")
diff --git a/cloudinit/config/schemas/schema-cloud-config-v1.json b/cloudinit/config/schemas/schema-cloud-config-v1.json
index 596a0f722..e5a728325 100644
--- a/cloudinit/config/schemas/schema-cloud-config-v1.json
+++ b/cloudinit/config/schemas/schema-cloud-config-v1.json
@@ -570,6 +570,7 @@
           "type": "string"
         },
         "playbook_name": {
+          "description": "Space separated list of playbooks to provide to ansible-pull",
           "type": "string"
         }
       }
@@ -948,7 +949,9 @@
             },
             "pull": {
               "oneOf": [
-               {"$ref": "#/$defs/ansible.pull"},
+                {
+                  "$ref": "#/$defs/ansible.pull"
+                },
                 {
                   "type": "array",
                   "items": {
diff --git a/doc/module-docs/cc_ansible/data.yaml b/doc/module-docs/cc_ansible/data.yaml
index ca5ac3bfb..b296f8b6b 100644
--- a/doc/module-docs/cc_ansible/data.yaml
+++ b/doc/module-docs/cc_ansible/data.yaml
@@ -9,8 +9,10 @@ cc_ansible:
   - comment: |
       Example 1:
     file: cc_ansible/example1.yaml
-  - comment: |
-      Example 2:
+  - comment: >
+      Example 2: Multiple ansible-pull URLs can be provided by providing a list
+      of pull objects. Additionally, multiple playbooks can be provided as a
+      space-separated playbook_name value.
     file: cc_ansible/example2.yaml
   name: Ansible
   title: Configure Ansible for instance
diff --git a/doc/module-docs/cc_ansible/example1.yaml b/doc/module-docs/cc_ansible/example1.yaml
index 9fd5e5204..c5799d48d 100644
--- a/doc/module-docs/cc_ansible/example1.yaml
+++ b/doc/module-docs/cc_ansible/example1.yaml
@@ -4,4 +4,4 @@ ansible:
   install_method: distro
   pull:
     url: https://github.com/holmanb/vmboot.git
-    playbook_name: ubuntu.yml
+    playbook_name: ubuntu.yml watermark.yml
diff --git a/doc/module-docs/cc_ansible/example2.yaml b/doc/module-docs/cc_ansible/example2.yaml
index f247b2267..3ab819ab4 100644
--- a/doc/module-docs/cc_ansible/example2.yaml
+++ b/doc/module-docs/cc_ansible/example2.yaml
@@ -3,5 +3,5 @@ ansible:
   package_name: ansible-core
   install_method: pip
   pull:
-    url: https://github.com/holmanb/vmboot.git
-    playbook_name: ubuntu.yml watermark.yml
+    - url: https://github.com/holmanb/vmboot.git
+      playbook_name: ubuntu.yml watermark.yml
diff --git a/tests/integration_tests/modules/test_ansible.py b/tests/integration_tests/modules/test_ansible.py
index bf74cba21..c48125767 100644
--- a/tests/integration_tests/modules/test_ansible.py
+++ b/tests/integration_tests/modules/test_ansible.py
@@ -37,6 +37,18 @@ write_files:
              - python3-pip
          roles:
            - apt
+  - path: /root/playbooks/watermark.yml
+    content: |
+       ---
+       - hosts: 127.0.0.1
+         connection: local
+         become: true
+         tasks:
+           - name: create a watermark file to assert multiple playbooks run
+             copy:
+               content: |
+                 cloud-init was here
+               dest: /MULTIPLAYBOOK_RUN
   - path: /root/playbooks/roles/apt/tasks/main.yml
     content: |
        ---
@@ -94,20 +106,21 @@ ansible:
   package_name: {package}
   galaxy:
     actions:
-     - ["ansible-galaxy", "collection", "install", "community.grafana"]
+     - ["ansible-galaxy", "collection", "install", "community.elastic"]
   pull:
-    url: "http://0.0.0.0:8000/"
-    playbook_name: ubuntu.yml
-    full: true
+    - url: "http://0.0.0.0:8000/"
+      playbook_name: ubuntu.yml watermark.yml
+      full: true
 """
 
-SETUP_REPO = f"cd {REPO_D}                                    &&\
-git config --global user.name auto                            &&\
-git config --global user.email autom@tic.io                   &&\
-git config --global init.defaultBranch main                   &&\
-git init {REPO_D}                                             &&\
-git add {REPO_D}/roles/apt/tasks/main.yml {REPO_D}/ubuntu.yml &&\
-git commit -m auto                                            &&\
+SETUP_REPO = f"cd {REPO_D}                                    && \
+git config --global user.name auto                            && \
+git config --global user.email autom@tic.io                   && \
+git config --global init.defaultBranch main                   && \
+git init {REPO_D}                                             && \
+git add {REPO_D}/roles/apt/tasks/main.yml {REPO_D}/ubuntu.yml && \
+git add {REPO_D}/watermark.yml                                && \
+git commit -m auto                                            && \
 (cd {REPO_D}/.git; git update-server-info)"
 
 ANSIBLE_CONTROL = """\
@@ -270,15 +283,17 @@ def _test_ansible_pull_from_local_server(my_client):
     verify_clean_log(log)
     verify_clean_boot(my_client)
     output_log = my_client.read_from_file("/var/log/cloud-init-output.log")
-    assert "ok=3" in output_log
+    assert "ok=5" in output_log
     assert "SUCCESS: config-ansible ran successfully" in log
 
     # binary location is dependent on install-type, check the filepath
     # to ensure that the installed collection directory exists
     output = my_client.execute(
-        "ls /root/.ansible/collections/ansible_collections/community/grafana"
+        "ls /root/.ansible/collections/ansible_collections/community/elastic"
     )
     assert not output.stderr.strip() and output.ok
+    playbook2_content = my_client.read_from_file("/MULTIPLAYBOOK_RUN")
+    assert "cloud-init was here" == playbook2_content
 
 
 # temporarily disable this test on jenkins until firewall rules are in place
@@ -304,7 +319,6 @@ def test_ansible_pull_pip(client: IntegrationInstance):
 @pytest.mark.user_data(
     USER_DATA + INSTALL_METHOD.format(package="ansible", method="distro")
 )
-@pytest.mark.skip("This test is currently broken and needs to be fixed")
 def test_ansible_pull_distro(client):
     push_and_enable_systemd_unit(client, "repo_server.service", REPO_SERVER)
     push_and_enable_systemd_unit(client, "repo_waiter.service", REPO_WAITER)
diff --git a/tests/unittests/config/test_cc_ansible.py b/tests/unittests/config/test_cc_ansible.py
index 0be2415ae..5e3456825 100644
--- a/tests/unittests/config/test_cc_ansible.py
+++ b/tests/unittests/config/test_cc_ansible.py
@@ -349,9 +349,7 @@ class TestSchema:
             validate_cloudconfig_schema(config, get_schema(), strict=True)
         else:
             with raises(SchemaValidationError, match=re.escape(error_msg)):
-                validate_cloudconfig_schema(
-                    config, get_schema(), strict=True
-                )
+                validate_cloudconfig_schema(config, get_schema(), strict=True)
 
 
 class TestAnsible:
```
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-05-06T16:54:38Z
user: login: blackboxsw
body: Thank you again @shaerpour we had an internal discussion about possibly deprecating a couple of cloud-config user-data keys or values:
1. deprecate single-dictionary of ansible.pull and suggest use of a list of dicts instead
2. deprecate `playbook_name` string in favor of `playbook_names` list of strings.

In recommending this changeset I've pushed another commit on top of your branch for review which:
- fixes doc generation for ansible.pull in config schema docs `tox -e doc; xdg-open doc/rtd_html/reference/modules.html`. 
- fixed unittests which now raise deprecation warnings for non-list ansible.pull values

Leaving this suggestion up for your review before we merge it.
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-09T01:11:47Z

Review comments:
diff_hunk: @@ -184,7 +184,14 @@ def handle(name: str, cfg: Config, cloud: Cloud, args: list) -> None:
             ansible_galaxy(galaxy_cfg, ansible)
 
         if pull_cfg:
-            run_ansible_pull(ansible, deepcopy(pull_cfg))
+            pull_cfg_type = type(pull_cfg)
+            if pull_cfg_type is dict:
+                run_ansible_pull(ansible, deepcopy(pull_cfg))
+            elif pull_cfg_type is list:
+                for cfg in pull_cfg:
+                    run_ansible_pull(ansible, deepcopy(cfg))
path: cloudinit/config/cc_ansible.py
user: login: blackboxsw
body: 1. Let's be a bit more pythonic and cope with pymorphism and subclasses with something like the following instead of direct type checking.
2. We can also setup a common list of dicts to ensure we reuse the same run_ansible_pull logic. 
3. We already have a validate_config function in cc_ansible, let's move that ValueError type checking into that function instead of performing that validation here.
```suggestion
            if isinstance(pull_cfg, dict):
                pull_cfg = [pull_cfg]
            for cfg in pull_cfg:
                run_ansible_pull(ansible, deepcopy(cfg))
```

I also think it's worth trying to type check the individual list items if ansible.pull is a list, but we can add that to validate_config function instead of here.
created_at: 2025-05-01T20:02:02Z
author_association: COLLABORATOR
diff_hunk: @@ -184,7 +184,14 @@ def handle(name: str, cfg: Config, cloud: Cloud, args: list) -> None:
             ansible_galaxy(galaxy_cfg, ansible)
 
         if pull_cfg:
-            run_ansible_pull(ansible, deepcopy(pull_cfg))
+            pull_cfg_type = type(pull_cfg)
+            if pull_cfg_type is dict:
+                run_ansible_pull(ansible, deepcopy(pull_cfg))
+            elif pull_cfg_type is list:
+                for cfg in pull_cfg:
+                    run_ansible_pull(ansible, deepcopy(cfg))
+            else:
+                raise ValueError(f"Unknown type for pull: {pull_cfg_type}")
path: cloudinit/config/cc_ansible.py
user: login: blackboxsw
body: Let's move this into validate_config function
created_at: 2025-05-01T20:09:34Z
author_association: COLLABORATOR
diff_hunk: @@ -198,10 +205,22 @@ def validate_config(cfg: dict):
     for key in required_keys:
         if not get_cfg_by_path(cfg, 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}")
+    pull_cfg = cfg.get("pull")
+    if pull_cfg:
+        pull_cfg_type = type(pull_cfg)
+        if pull_cfg_type is dict:
+            for key in "url", "playbook_name":
+                if not get_cfg_by_path(pull_cfg, key):
+                    raise ValueError(
+                        f"Missing required key '{key}' from {pull_cfg}"
+                    )
+        elif pull_cfg_type is list:
+            for c in pull_cfg:
+                for key in "url", "playbook_name":
path: cloudinit/config/cc_ansible.py
user: login: blackboxsw
body: It's more pythonic and readable to use a tuple instead of list of comma-separated strings.
```suggestion
                for key in ("url", "playbook_name"):
```
created_at: 2025-05-01T20:15:48Z
author_association: COLLABORATOR
diff_hunk: @@ -198,10 +205,22 @@ def validate_config(cfg: dict):
     for key in required_keys:
         if not get_cfg_by_path(cfg, 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}")
+    pull_cfg = cfg.get("pull")
+    if pull_cfg:
+        pull_cfg_type = type(pull_cfg)
+        if pull_cfg_type is dict:
+            for key in "url", "playbook_name":
+                if not get_cfg_by_path(pull_cfg, key):
+                    raise ValueError(
+                        f"Missing required key '{key}' from {pull_cfg}"
+                    )
+        elif pull_cfg_type is list:
+            for c in pull_cfg:
+                for key in "url", "playbook_name":
+                    if not get_cfg_by_path(c, key):
+                        raise ValueError(
+                            f"Missing required key '{key}' from {c}"
+                        )
path: cloudinit/config/cc_ansible.py
user: login: blackboxsw
body: Let's use `isinstance()` and avoid copy and paste logic by setting up a 1 item list if pull_cfg is a dict.
Additionally, raise ValueError  if pull_cfg is neither dict not list of dicts
```suggestion
        if isinstance(pull_cfg, dict):
            pull_cfg = [pull_cfg]
        elif not isinstance(pull_cfg, list):
            raise ValueError(
                "Invalid value ansible.pull. Expected either dict of list of" 
                f" dicts but found {pull_cfg}"
            )
        for cfg in pull_cfg:
            if not isinstance(cfg, dict):
                raise ValueError(
                    "Invalid value of ansible.pull. Expected dict but found" 
                    f" {cfg}"
                )
            for key in ("url", "playbook_name"):
                if not get_cfg_by_path(cfg, key):
                    raise ValueError(
                        f"Missing required key '{key}' from {cfg}"
                    )
```
created_at: 2025-05-01T20:17:05Z
author_association: COLLABORATOR
diff_hunk: @@ -218,13 +237,25 @@ def validate_config(cfg: dict):
         raise ValueError("Invalid install method {install}")
 
 
-def filter_args(cfg: dict) -> dict:
+def filter_args(cfg):
     """remove boolean false values"""
-    return {
-        key.replace("_", "-"): value
-        for (key, value) in cfg.items()
-        if value is not False
-    }
+    if type(cfg) is dict:
+        return {
+            key.replace("_", "-"): value
+            for (key, value) in cfg.items()
+            if value is not False
+        }
+    elif type(cfg) is list:
+        list_of_playbooks = []
+        for c in cfg:
+            list_of_playbooks.append(
+                {
+                    key.replace("_", "-"): value
+                    for (key, value) in c.items()
+                    if value is not False
+                }
+            )
+        return list_of_playbooks
path: cloudinit/config/cc_ansible.py
user: login: blackboxsw
body: I'm not certain why we need this changeset in the current PR given that run_ansible_pull should only be called on an individual pull dictionary not a list of dicts by your other logic.
created_at: 2025-05-01T20:28:56Z
author_association: COLLABORATOR
diff_hunk: @@ -256,12 +397,39 @@ def test_filter_args(self):
             "skip-tags": "cisco",
             "private-key": "{nope}",
         }
+        out_list = cc_ansible.filter_args(
+            CFG_FULL_PULL_LIST.get("ansible", {}).get("pull", []),
+        )
+        assert out_list == [
+            {
+                "url": "https://github/holmanb/vmboot",
+                "playbook-name": "arch.yml",
+                "accept-host-key": True,
+                "clean": True,
+                "full": True,
+                "ssh-common-args": "-y",
+                "scp-extra-args": "-l",
+                "sftp-extra-args": "-f",
+                "checkout": "tree",
+                "module-path": "~/.ansible/plugins/modules:"
+                "/usr/share/ansible/plugins/modules",
+                "timeout": "10",
+                "vault-id": "me",
+                "connection": "smart",
+                "vault-password-file": "/path/to/file",
+                "module-name": "git",
+                "sleep": "1",
+                "tags": "cumulus",
+                "skip-tags": "cisco",
+                "private-key": "{nope}",
+            }
+        ]
path: tests/unittests/config/test_cc_ansible.py
user: login: blackboxsw
body: This duplicated test assertion is unnecessary the `ansible.pull` lists shouldn't be passed directly to `cc_ansible.filter_args` in code paths should they? Let's redact this case.
```suggestion
```
created_at: 2025-05-01T20:40:29Z
author_association: COLLABORATOR
diff_hunk: @@ -215,26 +265,117 @@ class TestSchema:
                     }
                 },
                 "'playbook_name' is a required property",
-                id="require-url",
+                id="require-url-dict",
             ),
         ),
     )
     @skipUnlessJsonSchema()
-    def test_schema_validation(self, config, error_msg):
+    def test_schema_validationt_dict(self, config_dict, error_msg):
path: tests/unittests/config/test_cc_ansible.py
user: login: blackboxsw
body: No need to carve out a separate class and test case for this, just provide more parametrized options to the existing test and and leave the pytest param named 'config'
created_at: 2025-05-01T20:52:20Z
author_association: COLLABORATOR
diff_hunk: @@ -866,85 +866,172 @@
               "default": "ansible"
             },
             "pull": {
-              "required": [
-                "url",
-                "playbook_name"
-              ],
-              "type": "object",
-              "additionalProperties": false,
-              "properties": {
-                "accept_host_key": {
-                  "type": "boolean",
-                  "default": false
-                },
-                "clean": {
-                  "type": "boolean",
-                  "default": false
-                },
-                "full": {
-                  "type": "boolean",
-                  "default": false
-                },
-                "diff": {
-                  "type": "boolean",
-                  "default": false
-                },
-                "ssh_common_args": {
-                  "type": "string"
-                },
-                "scp_extra_args": {
-                  "type": "string"
-                },
-                "sftp_extra_args": {
-                  "type": "string"
-                },
-                "private_key": {
-                  "type": "string"
-                },
-                "checkout": {
-                  "type": "string"
-                },
-                "module_path": {
-                  "type": "string"
-                },
-                "timeout": {
-                  "type": "string"
-                },
-                "url": {
-                  "type": "string"
-                },
-                "connection": {
-                  "type": "string"
-                },
-                "vault_id": {
-                  "type": "string"
-                },
-                "vault_password_file": {
-                  "type": "string"
-                },
-                "verify_commit": {
-                  "type": "boolean",
-                  "default": false
-                },
-                "inventory": {
-                  "type": "string"
-                },
-                "module_name": {
-                  "type": "string"
-                },
-                "sleep": {
-                  "type": "string"
-                },
-                "tags": {
-                  "type": "string"
-                },
-                "skip_tags": {
-                  "type": "string"
+              "oneOf": [
+                {
+                  "required": [
path: cloudinit/config/schemas/schema-cloud-config-v1.json
user: login: blackboxsw
body: Let's avoid duplicating this entire definition and using it twice in the array and object items of this oneOf. The way we do that is by defining a new $defs/ansible.pull object which can be used in both the array case and the object case.
created_at: 2025-05-01T22:04:55Z
author_association: COLLABORATOR
diff_hunk: @@ -217,7 +236,7 @@ def validate_config(cfg: dict):
         raise ValueError("Invalid install method {install}")
 
 
-def filter_args(cfg: dict) -> dict:
path: cloudinit/config/cc_ansible.py
user: login: blackboxsw
body: Let's avoid this delta which drops typing hints.
created_at: 2025-05-05T16:36:23Z
author_association: COLLABORATOR
diff_hunk: @@ -4,4 +4,4 @@ ansible:
   install_method: pip
   pull:
     url: https://github.com/holmanb/vmboot.git
-    playbook_name: ubuntu.yml
path: doc/module-docs/cc_ansible/example2.yaml
user: login: blackboxsw
body: I've realized during testing that while ansible-pull CLI supports providing multiple playbooks on the command-line. Providing multiple playbooks as `playbook_name` value in cloud-config results in issues passing these values to `subp' as a single quoted argument, instead of separate arguments for each playbook.

We can add the following to allow supporting multiple playbooks from a single pull url
```diff
--- a/cloudinit/config/cc_ansible.py
+++ b/cloudinit/config/cc_ansible.py
@@ -264,7 +264,7 @@ def run_ansible_pull(pull: AnsiblePull, cfg: dict):
             f"--{key}={value}" if value is not True else f"--{key}"
             for key, value in filter_args(cfg).items()
         ],
-        playbook_name,
+        *playbook_name.split(),
     )
     if stdout:
         sys.stdout.write(f"{stdout}")
```
created_at: 2025-05-06T16:00:10Z
author_association: COLLABORATOR
diff_hunk: @@ -4,4 +4,4 @@ ansible:
   install_method: pip
   pull:
     url: https://github.com/holmanb/vmboot.git
-    playbook_name: ubuntu.yml
path: doc/module-docs/cc_ansible/example2.yaml
user: login: blackboxsw
body: Let's also change the example module-doc to ensure we represent pull as a list.
```diff
diff --git a/doc/module-docs/cc_ansible/example1.yaml b/doc/module-docs/cc_ansible/example1.yaml
index 9fd5e5204..e275dd11e 100644
--- a/doc/module-docs/cc_ansible/example1.yaml
+++ b/doc/module-docs/cc_ansible/example1.yaml
@@ -3,5 +3,5 @@ ansible:
   package_name: ansible-core
   install_method: distro
   pull:
-    url: https://github.com/holmanb/vmboot.git
-    playbook_name: ubuntu.yml
+    - url: https://github.com/holmanb/vmboot.git
+      playbook_name: ubuntu.yml watermark.yml
```
created_at: 2025-05-06T16:08:44Z
author_association: COLLABORATOR

PR comments:
user: login: holmanb
created_at: 2025-02-20T02:46:07Z
body: Hi @shaerpour, thanks for this proposal! It looks pretty good, but there's one big thing we need to address. If we shipped a new version of cloud-init with this feature it would break everybody that is currently using the old non-array version. Can you please modify this PR to support the old format as well?
author_association: MEMBER
user: login: shaerpour
created_at: 2025-02-20T17:36:27Z
body: Dear @holmanb.
Absolutely! I will work on backward compatibility of this PR and update ASAP. Thank you for mentioning.
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-03-07T00:25:31Z
body: Hello! Thank you for this proposed change to cloud-init. This pull request is now marked as stale as it has not seen any activity in 14 days. If no activity occurs within the next 7 days, this pull request will automatically close.

If you are waiting for code review and you are seeing this message, apologies!  Please reply, tagging TheRealFalcon, and he will ensure that someone takes a look soon.

(If the pull request is closed and you would like to continue working on it, please do tag TheRealFalcon to reopen it.)
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-30T00:28:46Z
body: Hello! Thank you for this proposed change to cloud-init. This pull request is now marked as stale as it has not seen any activity in 14 days. If no activity occurs within the next 7 days, this pull request will automatically close.

If you are waiting for code review and you are seeing this message, apologies!  Please reply, tagging TheRealFalcon, and he will ensure that someone takes a look soon.

(If the pull request is closed and you would like to continue working on it, please do tag TheRealFalcon to reopen it.)
author_association: NONE
user: login: shaerpour
created_at: 2025-04-02T18:37:51Z
body: Commenting for not closing PR
author_association: CONTRIBUTOR
user: login: shaerpour
created_at: 2025-04-12T09:58:34Z
body: Dear @holmanb @TheRealFalcon 
I rewrote ```ansible.pull``` module so both dict and  list forms are supported.
For CLA Check, I signed before and resigned today after pipeline failed. Should I do anything else for that?
author_association: CONTRIBUTOR
user: login: shaerpour
created_at: 2025-05-03T13:05:15Z
body: Dear @blackboxsw 
Thank you for your recommendations!
I didn't know about isisntance() function and schema reference creation. cloud-init's unittests are a little bit confusing for me.
I appreciate your suggestions and I will surely use them in future PR requests. I'm currently working on your suggestions and will apply them soon.
author_association: CONTRIBUTOR
user: login: blackboxsw
created_at: 2025-05-06T17:24:36Z
body: Note ansible-pull looks like it's had support for some time for specifying multiple playboooks on the CLI https://github.com/ansible/ansible/pull/73172
author_association: COLLABORATOR
user: login: shaerpour
created_at: 2025-05-10T05:45:48Z
body: Dear @blackboxsw 

Sorry for late response I was busy for a couple of days. Thank you for your updates.
author_association: CONTRIBUTOR