Molecule и Docker-тесты: vendored create playbook и явная платформа образа
- Добавлен molecule docker create playbook (create.yml + tasks/create_network.yml) с правкой tmpfs: словарь из molecule-plugins приводится к списку строк для community.docker.docker_container; сценарии копируют playbook и задают provisioner.playbooks.create. - Для systemd-платформ tmpfs задаётся списком строк вместо mounts. - В опциях ОС — run_platform (каноническая архитектура после build); в TestHostSpec и hosts теста передаётся platform в molecule/docker_container, чтобы на ARM не падал /sbin/init из-за amd64 без --platform. - Страницы роли (просмотр и создание): одна dashboard-карточка на всю ширину, вкладки Role details / Role file catalog в
This commit is contained in:
@@ -73,6 +73,19 @@ DOCKERFILES_ROOT = Path(__file__).resolve().parents[2] / "dockerfiles"
|
||||
BUILDX_BUILDER_NAME = "roleforge-builder"
|
||||
|
||||
|
||||
def _run_platform_for_os_image(platform: str, build_platforms: str) -> str:
|
||||
"""Architectural platform for `docker run --platform` / molecule docker_container.platform.
|
||||
|
||||
Matches the canonical single-arch tag produced by build-all (first matrix entry when build allows multi-arch).
|
||||
Without this on Apple Silicon, an amd64-only layer triggers exec format error on /sbin/init.
|
||||
"""
|
||||
p = str(platform or "").strip()
|
||||
if p:
|
||||
return p
|
||||
parts = [x.strip() for x in str(build_platforms or "").split(",") if x.strip()]
|
||||
return parts[0] if parts else "linux/amd64"
|
||||
|
||||
|
||||
def _scan_os_options() -> list[dict[str, str]]:
|
||||
items: list[dict[str, str]] = []
|
||||
root = DOCKERFILES_ROOT
|
||||
@@ -94,6 +107,7 @@ def _scan_os_options() -> list[dict[str, str]]:
|
||||
else:
|
||||
platform = ""
|
||||
build_platforms = "linux/amd64,linux/arm64"
|
||||
run_platform = _run_platform_for_os_image(platform, build_platforms)
|
||||
items.append(
|
||||
{
|
||||
"id": f"{os_key}:{variant or 'default'}",
|
||||
@@ -104,6 +118,7 @@ def _scan_os_options() -> list[dict[str, str]]:
|
||||
"systemd": "true",
|
||||
"platform": platform,
|
||||
"build_platforms": build_platforms,
|
||||
"run_platform": run_platform,
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
@@ -150,6 +150,8 @@ class TestHostSpec(BaseModel):
|
||||
command: str = "tail -f /dev/null"
|
||||
privileged: bool = True
|
||||
systemd: bool = False
|
||||
# OCI platform for molecule/docker_container (e.g. linux/amd64 on ARM hosts for amd64-only images).
|
||||
platform: str = ""
|
||||
|
||||
|
||||
class TestLaunchRequest(BaseModel):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from collections.abc import Generator
|
||||
@@ -10,6 +11,7 @@ from pathlib import Path
|
||||
_MOLECULE_DEPENDENCY = {"name": "galaxy", "enabled": False}
|
||||
# Docker targets often lack a usable ~/.ansible; use RAM-backed tmp in target containers.
|
||||
_MOLECULE_REMOTE_TMP = "/dev/shm/.ansible-remote-tmp"
|
||||
_MOLECULE_DOCKER_PLAYBOOK_SRC = Path(__file__).resolve().parent / "molecule_docker_playbook"
|
||||
|
||||
|
||||
def _molecule_runtime_path() -> str:
|
||||
@@ -46,8 +48,8 @@ def _platform_command(host: dict) -> str | list[str]:
|
||||
def _platform_systemd_overrides(host: dict) -> dict:
|
||||
if not bool(host.get("systemd")):
|
||||
return {}
|
||||
# Required runtime knobs for systemd-based containers in Docker.
|
||||
# community.docker.docker_container expects tmpfs as list[str] (CLI-style path:opts), not a dict.
|
||||
# Systemd-in-Docker needs cgroup rw + writable /run. molecule-plugins may merge Podman-style dict tmpfs;
|
||||
# vendored provisioner create.yml converts that mapping to docker_container's list-of-strings format.
|
||||
return {
|
||||
"tmpfs": [
|
||||
"/run:rw,noexec,nosuid,size=65536k",
|
||||
@@ -58,45 +60,6 @@ def _platform_systemd_overrides(host: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _normalize_platform_tmpfs(platform: dict) -> None:
|
||||
"""Ensure tmpfs matches community.docker.docker_container (list of path:opts strings).
|
||||
|
||||
Molecule's docker driver passes platform.tmpfs straight through; dict-shaped tmpfs (Docker-API
|
||||
style) fails on recent ansible-core with: unable to convert to list.
|
||||
"""
|
||||
if "tmpfs" not in platform:
|
||||
return
|
||||
raw = platform["tmpfs"]
|
||||
if raw in (None, False):
|
||||
platform.pop("tmpfs", None)
|
||||
return
|
||||
if isinstance(raw, list):
|
||||
norm = [str(x).strip() for x in raw if str(x).strip()]
|
||||
if norm:
|
||||
platform["tmpfs"] = norm
|
||||
else:
|
||||
platform.pop("tmpfs", None)
|
||||
return
|
||||
if isinstance(raw, dict):
|
||||
out: list[str] = []
|
||||
for path_key, opts in raw.items():
|
||||
path = str(path_key).strip()
|
||||
if not path:
|
||||
continue
|
||||
if isinstance(opts, bool):
|
||||
if opts:
|
||||
out.append(path)
|
||||
continue
|
||||
opt_s = str(opts).strip()
|
||||
out.append(f"{path}:{opt_s}" if opt_s else path)
|
||||
if out:
|
||||
platform["tmpfs"] = out
|
||||
else:
|
||||
platform.pop("tmpfs", None)
|
||||
return
|
||||
platform.pop("tmpfs", None)
|
||||
|
||||
|
||||
def run_ansible_playbook(
|
||||
playbook_yaml: str,
|
||||
inventory_text: str,
|
||||
@@ -158,6 +121,18 @@ def _write_molecule_ansible_cfgs(project_root: Path, scenario_dir: Path) -> None
|
||||
(scenario_dir / "ansible.cfg").write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
def _install_molecule_docker_playbook(scenario_dir: Path) -> None:
|
||||
"""Vendor molecule-plugins docker create playbook (+tasks/) into the scenario; tmpfs line patched."""
|
||||
src_root = _MOLECULE_DOCKER_PLAYBOOK_SRC
|
||||
for path in src_root.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
rel = path.relative_to(src_root)
|
||||
dest = scenario_dir / rel
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(path, dest)
|
||||
|
||||
|
||||
def _run_streaming(command: list[str], cwd: str, env: dict[str, str]) -> Generator[str, None, int]:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
@@ -293,6 +268,7 @@ def run_molecule_test_for_playbook(
|
||||
scenario_dir = root / "molecule" / "default"
|
||||
scenario_dir.mkdir(parents=True, exist_ok=True)
|
||||
_write_molecule_ansible_cfgs(root, scenario_dir)
|
||||
_install_molecule_docker_playbook(scenario_dir)
|
||||
platforms = []
|
||||
for host in hosts:
|
||||
item = {
|
||||
@@ -304,7 +280,9 @@ def run_molecule_test_for_playbook(
|
||||
"pre_build_image": True,
|
||||
}
|
||||
item.update(_platform_systemd_overrides(host))
|
||||
_normalize_platform_tmpfs(item)
|
||||
plat = str(host.get("platform") or "").strip()
|
||||
if plat:
|
||||
item["platform"] = plat
|
||||
platforms.append(item)
|
||||
|
||||
molecule_config = {
|
||||
@@ -314,7 +292,7 @@ def run_molecule_test_for_playbook(
|
||||
"provisioner": {
|
||||
"name": "ansible",
|
||||
"env": _molecule_provisioner_env(),
|
||||
"playbooks": {"converge": "converge.yml"},
|
||||
"playbooks": {"create": "create.yml", "converge": "converge.yml"},
|
||||
"inventory": {"group_vars": {"all": _molecule_group_vars(extra_vars)}},
|
||||
},
|
||||
"scenario": {"name": "default", "converge_sequence": ["converge"]},
|
||||
@@ -357,6 +335,7 @@ def run_molecule_test_for_role(
|
||||
scenario_dir = root / "molecule" / "default"
|
||||
scenario_dir.mkdir(parents=True, exist_ok=True)
|
||||
_write_molecule_ansible_cfgs(root, scenario_dir)
|
||||
_install_molecule_docker_playbook(scenario_dir)
|
||||
converge = (
|
||||
"- hosts: all\n"
|
||||
" gather_facts: true\n"
|
||||
@@ -378,7 +357,9 @@ def run_molecule_test_for_role(
|
||||
"pre_build_image": True,
|
||||
}
|
||||
item.update(_platform_systemd_overrides(host))
|
||||
_normalize_platform_tmpfs(item)
|
||||
plat = str(host.get("platform") or "").strip()
|
||||
if plat:
|
||||
item["platform"] = plat
|
||||
platforms.append(item)
|
||||
molecule_config = {
|
||||
"dependency": _MOLECULE_DEPENDENCY,
|
||||
@@ -387,7 +368,7 @@ def run_molecule_test_for_role(
|
||||
"provisioner": {
|
||||
"name": "ansible",
|
||||
"env": _molecule_provisioner_env(),
|
||||
"playbooks": {"converge": "converge.yml"},
|
||||
"playbooks": {"create": "create.yml", "converge": "converge.yml"},
|
||||
"inventory": {"group_vars": {"all": _molecule_group_vars(extra_vars)}},
|
||||
},
|
||||
"scenario": {"name": "default", "converge_sequence": ["converge"]},
|
||||
|
||||
209
app/services/molecule_docker_playbook/create.yml
Normal file
209
app/services/molecule_docker_playbook/create.yml
Normal file
@@ -0,0 +1,209 @@
|
||||
---
|
||||
# Vendored from ansible-community/molecule-plugins (docker playbooks/create.yml).
|
||||
# Patched tmpfs: molecule-plugins 23.5.3+ may merge Podman-style mappings; docker_container expects a list of strings.
|
||||
- name: Create
|
||||
hosts: localhost
|
||||
connection: local
|
||||
gather_facts: false
|
||||
no_log: "{{ molecule_no_log }}"
|
||||
vars:
|
||||
molecule_labels:
|
||||
owner: molecule
|
||||
tags:
|
||||
- always
|
||||
tasks:
|
||||
- name: Set async_dir for HOME env
|
||||
ansible.builtin.set_fact:
|
||||
ansible_async_dir: "{{ lookup('env', 'HOME') }}/.ansible_async/"
|
||||
when: lookup('env', 'HOME') is truthy
|
||||
|
||||
- name: Log into a Docker registry
|
||||
community.docker.docker_login:
|
||||
username: "{{ item.registry.credentials.username }}"
|
||||
password: "{{ item.registry.credentials.password }}"
|
||||
email: "{{ item.registry.credentials.email | default(omit) }}"
|
||||
registry: "{{ item.registry.url }}"
|
||||
docker_host: "{{ item.docker_host | default(lookup('env', 'DOCKER_HOST') or 'unix://var/run/docker.sock') }}"
|
||||
cacert_path: "{{ item.cacert_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/ca.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}"
|
||||
cert_path: "{{ item.cert_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/cert.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}"
|
||||
key_path: "{{ item.key_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/key.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}"
|
||||
tls_verify: "{{ item.tls_verify | default(lookup('env', 'DOCKER_TLS_VERIFY')) or false }}"
|
||||
with_items: "{{ molecule_yml.platforms }}"
|
||||
loop_control:
|
||||
label: "{{ item.registry.url | default(item.name) }}"
|
||||
when:
|
||||
- item.registry is defined
|
||||
- item.registry.url is truthy
|
||||
- item.registry.credentials is defined
|
||||
- item.registry.credentials.username is defined
|
||||
no_log: true
|
||||
|
||||
- name: Check presence of custom Dockerfiles
|
||||
ansible.builtin.stat:
|
||||
path: "{{ molecule_scenario_directory + '/' + (item.dockerfile | default('Dockerfile.j2')) }}"
|
||||
loop: "{{ molecule_yml.platforms }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
register: dockerfile_stats
|
||||
|
||||
- name: Create Dockerfiles from image names
|
||||
ansible.builtin.template:
|
||||
# when using embedded playbooks the dockerfile is alongside them
|
||||
src: "{%- if dockerfile_stats.results[i].stat.exists -%}{{ molecule_scenario_directory + '/' + (item.dockerfile | default('Dockerfile.j2')) }}{%- else -%}{{
|
||||
playbook_dir + '/Dockerfile.j2' }}{%- endif -%}"
|
||||
dest: "{{ molecule_ephemeral_directory }}/Dockerfile_{{ item.image | regex_replace('[^a-zA-Z0-9_]', '_') }}"
|
||||
mode: "0600"
|
||||
loop: "{{ molecule_yml.platforms }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
index_var: i
|
||||
when: not item.pre_build_image | default(false)
|
||||
register: platforms
|
||||
|
||||
- name: Synchronization the context
|
||||
ansible.posix.synchronize:
|
||||
src: "{%- if dockerfile_stats.results[i].stat.exists -%}{{ molecule_scenario_directory + '/' }}{%- else -%}{{ playbook_dir + '/' }}{%- endif -%}"
|
||||
dest: "{{ molecule_ephemeral_directory }}"
|
||||
rsync_opts:
|
||||
- "--exclude=molecule.yml"
|
||||
loop: "{{ molecule_yml.platforms }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
index_var: i
|
||||
when: not item.pre_build_image | default(false)
|
||||
delegate_to: localhost
|
||||
|
||||
- name: Discover local Docker images
|
||||
community.docker.docker_image_info:
|
||||
name: "molecule_local/{{ item.item.name }}"
|
||||
docker_host: "{{ item.item.docker_host | default(lookup('env', 'DOCKER_HOST') or 'unix://var/run/docker.sock') }}"
|
||||
cacert_path: "{{ item.cacert_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/ca.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}"
|
||||
cert_path: "{{ item.cert_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/cert.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}"
|
||||
key_path: "{{ item.key_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/key.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}"
|
||||
tls_verify: "{{ item.tls_verify | default(lookup('env', 'DOCKER_TLS_VERIFY')) or false }}"
|
||||
with_items: "{{ platforms.results }}"
|
||||
loop_control:
|
||||
label: "{{ item.item.docker_host | default(lookup('env', 'DOCKER_HOST') or 'unix://var/run/docker.sock') }}"
|
||||
when:
|
||||
- not item.pre_build_image | default(false)
|
||||
register: docker_images
|
||||
|
||||
- name: Create docker network(s)
|
||||
ansible.builtin.include_tasks: tasks/create_network.yml
|
||||
with_items: "{{ molecule_yml.platforms | molecule_get_docker_networks(molecule_labels) }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
no_log: false
|
||||
|
||||
- name: Build an Ansible compatible image (new) # noqa: no-handler
|
||||
when:
|
||||
- platforms.changed or docker_images.results | map(attribute='images') | select('equalto', []) | list | count >= 0
|
||||
- not item.item.pre_build_image | default(false)
|
||||
community.docker.docker_image:
|
||||
build:
|
||||
path: "{{ molecule_ephemeral_directory }}"
|
||||
dockerfile: "{{ item.invocation.module_args.dest }}"
|
||||
pull: "{{ item.item.pull | default(true) }}"
|
||||
network: "{{ item.item.network_mode | default(omit) }}"
|
||||
args: "{{ item.item.buildargs | default(omit) }}"
|
||||
platform: "{{ item.item.platform | default(omit) }}"
|
||||
cache_from: "{{ item.item.cache_from | default(omit) }}"
|
||||
name: "molecule_local/{{ item.item.image }}"
|
||||
docker_host: "{{ item.item.docker_host | default(lookup('env', 'DOCKER_HOST') or 'unix://var/run/docker.sock') }}"
|
||||
cacert_path: "{{ item.cacert_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/ca.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}"
|
||||
cert_path: "{{ item.cert_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/cert.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}"
|
||||
key_path: "{{ item.key_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/key.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}"
|
||||
tls_verify: "{{ item.tls_verify | default(lookup('env', 'DOCKER_TLS_VERIFY')) or false }}"
|
||||
force_source: "{{ item.item.force | default(true) }}"
|
||||
source: build
|
||||
with_items: "{{ platforms.results }}"
|
||||
loop_control:
|
||||
label: "molecule_local/{{ item.item.image }}"
|
||||
no_log: false
|
||||
register: result
|
||||
until: result is not failed
|
||||
retries: 3
|
||||
delay: 30
|
||||
|
||||
- name: Determine the CMD directives
|
||||
ansible.builtin.set_fact:
|
||||
command_directives_dict: >-
|
||||
{{ command_directives_dict | default({}) |
|
||||
combine({item.name: item.command | default('bash -c "while true; do sleep 10000; done"')})
|
||||
}}
|
||||
with_items: "{{ molecule_yml.platforms }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
when: item.override_command | default(true)
|
||||
|
||||
- name: Create molecule instance(s)
|
||||
community.docker.docker_container:
|
||||
name: "{{ item.name }}"
|
||||
docker_host: "{{ item.docker_host | default(lookup('env', 'DOCKER_HOST') or 'unix://var/run/docker.sock') }}"
|
||||
cacert_path: "{{ item.cacert_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/ca.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}"
|
||||
cert_path: "{{ item.cert_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/cert.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}"
|
||||
key_path: "{{ item.key_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/key.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}"
|
||||
tls_verify: "{{ item.tls_verify | default(lookup('env', 'DOCKER_TLS_VERIFY')) or false }}"
|
||||
hostname: "{{ item.hostname | default(item.name) }}"
|
||||
image: "{{ item.pre_build_image | default(false) | ternary('', 'molecule_local/') }}{{ item.image }}"
|
||||
pull: "{{ item.pull | default(omit) }}"
|
||||
memory: "{{ item.memory | default(omit) }}"
|
||||
memory_swap: "{{ item.memory_swap | default(omit) }}"
|
||||
state: started
|
||||
recreate: false
|
||||
log_driver: json-file
|
||||
command: "{{ (command_directives_dict | default({}))[item.name] | default(omit) }}"
|
||||
command_handling: "{{ item.command_handling | default('compatibility') }}"
|
||||
user: "{{ item.user | default(omit) }}"
|
||||
pid_mode: "{{ item.pid_mode | default(omit) }}"
|
||||
runtime: "{{ item.runtime | default(omit) }}"
|
||||
privileged: "{{ item.privileged | default(omit) }}"
|
||||
security_opts: "{{ item.security_opts | default(omit) }}"
|
||||
devices: "{{ item.devices | default(omit) }}"
|
||||
links: "{{ item.links | default(omit) }}"
|
||||
volumes: "{{ item.volumes | default(omit) }}"
|
||||
mounts: "{{ item.mounts | default(omit) }}"
|
||||
tmpfs: "{{ omit if item.tmpfs is not defined else (item.tmpfs if item.tmpfs is not mapping else (item.tmpfs | dict2items | map(attribute='key') | zip(item.tmpfs | dict2items | map(attribute='value')) | map('join', ':') | list)) }}"
|
||||
capabilities: "{{ item.capabilities | default(omit) }}"
|
||||
sysctls: "{{ item.sysctls | default(omit) }}"
|
||||
exposed_ports: "{{ item.exposed_ports | default(omit) }}"
|
||||
published_ports: "{{ item.published_ports | default(omit) }}"
|
||||
ulimits: "{{ item.ulimits | default(omit) }}"
|
||||
networks: "{{ item.networks | default(omit) }}"
|
||||
network_mode: "{{ item.network_mode | default(omit) }}"
|
||||
networks_cli_compatible: "{{ item.networks_cli_compatible | default(true) }}"
|
||||
purge_networks: "{{ item.purge_networks | default(omit) }}"
|
||||
dns_servers: "{{ item.dns_servers | default(omit) }}"
|
||||
etc_hosts: "{{ item.etc_hosts | default(omit) }}"
|
||||
env: "{{ item.env | default(omit) }}"
|
||||
restart_policy: "{{ item.restart_policy | default(omit) }}"
|
||||
restart_retries: "{{ item.restart_retries | default(omit) }}"
|
||||
tty: "{{ item.tty | default(omit) }}"
|
||||
labels: "{{ molecule_labels | combine(item.labels | default({})) }}"
|
||||
container_default_behavior: >-
|
||||
{{ item.container_default_behavior
|
||||
| default('compatibility' if ansible_version.full is version_compare('2.10', '>=') else omit) }}
|
||||
stop_signal: "{{ item.stop_signal | default(omit) }}"
|
||||
kill_signal: "{{ item.kill_signal | default(omit) }}"
|
||||
cgroupns_mode: "{{ item.cgroupns_mode | default(omit) }}"
|
||||
shm_size: "{{ item.shm_size | default(omit) }}"
|
||||
platform: "{{ item.platform | default(omit) }}"
|
||||
comparisons:
|
||||
platform: ignore
|
||||
register: server
|
||||
with_items: "{{ molecule_yml.platforms }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
no_log: false
|
||||
async: 7200
|
||||
poll: 0
|
||||
|
||||
- name: Wait for instance(s) creation to complete
|
||||
ansible.builtin.async_status:
|
||||
jid: "{{ item.ansible_job_id }}"
|
||||
register: docker_jobs
|
||||
until: docker_jobs is finished
|
||||
retries: 300
|
||||
with_items: "{{ server.results }}"
|
||||
loop_control:
|
||||
label: "{{ item.item.name }}"
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
- name: Check if network exist
|
||||
community.docker.docker_network_info:
|
||||
name: "{{ item.name }}"
|
||||
register: docker_netname
|
||||
|
||||
- name: Create docker network(s)
|
||||
community.docker.docker_network:
|
||||
api_version: "{{ item.api_version | default(omit) }}"
|
||||
appends: "{{ item.appends | default(omit) }}"
|
||||
attachable: "{{ item.attachable | default(omit) }}"
|
||||
ca_cert: "{{ item.ca_cert | default(omit) }}"
|
||||
client_cert: "{{ item.client_cert | default(omit) }}"
|
||||
client_key: "{{ item.client_key | default(omit) }}"
|
||||
connected: "{{ item.connected | default(omit) }}"
|
||||
debug: "{{ item.debug | default(omit) }}"
|
||||
docker_host: "{{ item.docker_host | default(omit) }}"
|
||||
driver: "{{ item.driver | default(omit) }}"
|
||||
driver_options: "{{ item.driver_options | default(omit) }}"
|
||||
enable_ipv6: "{{ item.enable_ipv6 | default(omit) }}"
|
||||
force: "{{ item.force | default(omit) }}"
|
||||
internal: "{{ item.internal | default(omit) }}"
|
||||
ipam_config: "{{ item.ipam_config | default(omit) }}"
|
||||
ipam_driver: "{{ item.ipam_driver | default(omit) }}"
|
||||
ipam_driver_options: "{{ item.ipam_driver_options | default(omit) }}"
|
||||
key_path: "{{ item.key_path | default(omit) }}"
|
||||
labels: "{{ item.labels }}"
|
||||
name: "{{ item.name }}"
|
||||
scope: "{{ item.scope | default(omit) }}"
|
||||
ssl_version: "{{ item.ssl_version | default(omit) }}"
|
||||
state: "present"
|
||||
timeout: "{{ item.timeout | default(omit) }}"
|
||||
tls: "{{ item.tls | default(omit) }}"
|
||||
tls_hostname: "{{ item.tls_hostname | default(omit) }}"
|
||||
validate_certs: "{{ item.validate_certs | default(omit) }}"
|
||||
when: not docker_netname.exists
|
||||
@@ -2385,6 +2385,84 @@ body[data-theme="light"] .schema-field-tip-block--sep {
|
||||
border-top: 1px solid var(--card-border);
|
||||
}
|
||||
|
||||
/* Role view/create: one dashboard card; tabs span full width (segment strip, not pill buttons). */
|
||||
.roles-page .role-view-tabbed-shell {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.roles-page .role-view-tabbed-shell.dashboard-panel--primary {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.roles-page .role-view-tabs {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: color-mix(in srgb, var(--bg-panel) 55%, var(--card));
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
}
|
||||
|
||||
.roles-page .role-view-tab {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
padding: 14px 12px 13px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
border-bottom: 3px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
font-size: 0.94rem;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
letter-spacing: -0.015em;
|
||||
line-height: 1.25;
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.roles-page .role-view-tab + .role-view-tab {
|
||||
box-shadow: -1px 0 0 var(--card-border);
|
||||
}
|
||||
|
||||
.roles-page .role-view-tab:hover {
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--card) 72%, transparent);
|
||||
}
|
||||
|
||||
.roles-page .role-view-tab:focus-visible {
|
||||
outline: none;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent) 50%, transparent);
|
||||
}
|
||||
|
||||
.roles-page .role-view-tab[aria-selected="true"] {
|
||||
color: var(--text);
|
||||
background: var(--card);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.roles-page .role-view-tabbed-panels {
|
||||
padding: 22px 24px 24px;
|
||||
}
|
||||
|
||||
.roles-page .role-view-tabbed-shell.dashboard-panel--primary .role-view-tabbed-panels {
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
|
||||
.roles-page .role-view-tab-panel[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.roles-catalog {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
@@ -2790,7 +2868,7 @@ body[data-theme="light"] .admin-category-role-badge--has-roles {
|
||||
color: #e0f2fe;
|
||||
}
|
||||
|
||||
/* Target OS families: list of rows + switch toggles (role form) */
|
||||
/* Target OS families: two-column grid + switch toggles (role form) */
|
||||
.role-os-family-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2798,6 +2876,9 @@ body[data-theme="light"] .admin-category-role-badge--has-roles {
|
||||
}
|
||||
|
||||
.role-os-family-grid--toggles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
@@ -2805,6 +2886,37 @@ body[data-theme="light"] .admin-category-role-badge--has-roles {
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.role-os-family-grid--toggles .role-os-family-row {
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--card-border) 70%, transparent);
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.role-os-family-grid--toggles .role-os-family-row:nth-child(odd) {
|
||||
border-right: 1px solid color-mix(in srgb, var(--card-border) 70%, transparent);
|
||||
}
|
||||
|
||||
.role-os-family-grid--toggles .role-os-family-row:nth-last-child(-n + 2) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
@media (max-width: 540px) {
|
||||
.role-os-family-grid--toggles {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.role-os-family-grid--toggles .role-os-family-row:nth-child(odd) {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.role-os-family-grid--toggles .role-os-family-row:nth-last-child(-n + 2) {
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--card-border) 70%, transparent);
|
||||
}
|
||||
|
||||
.role-os-family-grid--toggles .role-os-family-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.role-os-family-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2819,7 +2931,11 @@ body[data-theme="light"] .admin-category-role-badge--has-roles {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.role-os-family-row:last-child {
|
||||
.role-os-family-row--compact {
|
||||
padding-block: 10px;
|
||||
}
|
||||
|
||||
.role-os-family-grid:not(.role-os-family-grid--toggles) .role-os-family-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -2950,13 +3066,37 @@ body[data-theme="light"] .role-os-switch__track {
|
||||
background: color-mix(in srgb, #64748b 28%, #e2e8f0);
|
||||
}
|
||||
|
||||
.role-team-picker-row {
|
||||
/* Role form: team picker — only visible when visibility = Team (JS toggles [hidden]) */
|
||||
#role-team-picker-wrap[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.role-team-picker-field:not([hidden]) {
|
||||
margin-top: 6px;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid color-mix(in srgb, var(--card-border) 88%, var(--accent, #f97316));
|
||||
background: color-mix(in srgb, var(--card) 94%, var(--bg-panel));
|
||||
}
|
||||
|
||||
.role-team-picker-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.role-team-display--summary {
|
||||
flex: 1;
|
||||
min-width: 12rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.role-team-picker-open-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.role-team-display {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -4188,6 +4188,32 @@ dashboard.initRoleViewPage = function initRoleViewPage() {
|
||||
const saveSubmitBtn = root.querySelector(".roles-page-bottom-actions button[type='submit']");
|
||||
if (!root || !form || !testBtn || !addFileBtn || !addFolderBtn || !filesUploadBtn || !catalogRoot || !categorySelect)
|
||||
return;
|
||||
|
||||
(function initRolePageTabs() {
|
||||
const tablist = root.querySelector(".role-view-tabs[role=\"tablist\"]");
|
||||
if (!tablist) return;
|
||||
const tabs = Array.from(tablist.querySelectorAll(".role-view-tab[role=\"tab\"]"));
|
||||
const panels = tabs.map((trigger) => {
|
||||
const panelId = trigger.getAttribute("aria-controls");
|
||||
return panelId ? document.getElementById(panelId) : null;
|
||||
});
|
||||
if (!tabs.length || panels.some((p) => !p)) return;
|
||||
|
||||
function selectTab(index) {
|
||||
tabs.forEach((tab, i) => {
|
||||
const active = i === index;
|
||||
tab.setAttribute("aria-selected", active ? "true" : "false");
|
||||
tab.tabIndex = active ? 0 : -1;
|
||||
const panel = panels[i];
|
||||
if (panel) panel.hidden = !active;
|
||||
});
|
||||
}
|
||||
|
||||
tabs.forEach((tab, i) => {
|
||||
tab.addEventListener("click", () => selectTab(i));
|
||||
});
|
||||
})();
|
||||
|
||||
let isCreateFlow = root.hasAttribute("data-role-create");
|
||||
let activeRoleId = root.getAttribute("data-role-id") || "";
|
||||
if (!isCreateFlow && !activeRoleId) return;
|
||||
@@ -4217,44 +4243,45 @@ dashboard.initRoleViewPage = function initRoleViewPage() {
|
||||
const osGrid = document.getElementById("role-os-family-grid");
|
||||
|
||||
const OS_FAMILY_CHOICES = [
|
||||
["universal", "Universal", "Any common Linux/Unix target"],
|
||||
["rhel", "RHEL / RPM-based", "RHEL, Rocky, Alma, Fedora, …"],
|
||||
["debian", "Debian / apt", "Debian, Ubuntu, and derivatives"],
|
||||
["alpine", "Alpine", "Alpine (apk)"],
|
||||
["suse", "SUSE", "SLES, openSUSE (zypper)"],
|
||||
["arch", "Arch", "Arch and similar"],
|
||||
["bsd", "BSD", "FreeBSD, OpenBSD, …"],
|
||||
["windows", "Windows", "WinRM / Windows hosts"],
|
||||
["universal", "Universal"],
|
||||
["rhel", "RHEL / RPM-based"],
|
||||
["debian", "Debian / apt"],
|
||||
["alpine", "Alpine"],
|
||||
["bsd", "BSD"],
|
||||
["windows", "Windows"],
|
||||
];
|
||||
const OS_FAMILY_GRID_IDS = new Set(OS_FAMILY_CHOICES.map((row) => row[0]));
|
||||
/** Families stored on the role but not shown as toggles (e.g. legacy suse/arch); kept across saves. */
|
||||
let osFamiliesOffGrid = [];
|
||||
|
||||
function mountOsFamilyGrid() {
|
||||
if (!osGrid || osGrid.dataset.mounted === "1") return;
|
||||
osGrid.dataset.mounted = "1";
|
||||
osGrid.classList.add("role-os-family-grid--toggles");
|
||||
osGrid.innerHTML = OS_FAMILY_CHOICES.map(
|
||||
([id, label, hint]) => `
|
||||
<label class="role-os-family-row">
|
||||
osGrid.innerHTML = OS_FAMILY_CHOICES.map(([id, label]) => `
|
||||
<label class="role-os-family-row role-os-family-row--compact">
|
||||
<span class="role-os-family-row__copy">
|
||||
<span class="role-os-family-row__title">${esc(label)}</span>
|
||||
<span class="role-os-family-row__hint">${esc(hint)}</span>
|
||||
</span>
|
||||
<span class="role-os-switch">
|
||||
<input type="checkbox" class="role-os-switch__input" name="role-os-families" value="${esc(id)}" data-os-family="${esc(id)}" role="switch" />
|
||||
<span class="role-os-switch__track" aria-hidden="true"><span class="role-os-switch__thumb"></span></span>
|
||||
</span>
|
||||
</label>`,
|
||||
).join("");
|
||||
</label>`).join("");
|
||||
}
|
||||
mountOsFamilyGrid();
|
||||
|
||||
function getSelectedOsFamilies() {
|
||||
if (!osGrid) return [];
|
||||
return Array.from(osGrid.querySelectorAll('input[name="role-os-families"]:checked')).map((el) => el.value);
|
||||
if (!osGrid) return [...osFamiliesOffGrid];
|
||||
const fromGrid = Array.from(osGrid.querySelectorAll('input[name="role-os-families"]:checked')).map((el) => el.value);
|
||||
return [...new Set([...fromGrid, ...osFamiliesOffGrid])];
|
||||
}
|
||||
|
||||
function setSelectedOsFamilies(arr) {
|
||||
if (!osGrid) return;
|
||||
const set = new Set((arr || []).map((x) => String(x).toLowerCase()));
|
||||
const norm = (arr || []).map((x) => String(x).toLowerCase());
|
||||
osFamiliesOffGrid = norm.filter((id) => !OS_FAMILY_GRID_IDS.has(id));
|
||||
const set = new Set(norm);
|
||||
osGrid.querySelectorAll('input[name="role-os-families"]').forEach((inp) => {
|
||||
if (inp instanceof HTMLInputElement) inp.checked = set.has(inp.value);
|
||||
});
|
||||
@@ -4369,7 +4396,7 @@ dashboard.initRoleViewPage = function initRoleViewPage() {
|
||||
el.textContent = selectedTeamId;
|
||||
el.classList.add("muted");
|
||||
} else {
|
||||
el.textContent = "—";
|
||||
el.textContent = "No team selected — use the button to pick one.";
|
||||
el.classList.add("muted");
|
||||
}
|
||||
}
|
||||
@@ -4389,6 +4416,9 @@ dashboard.initRoleViewPage = function initRoleViewPage() {
|
||||
if (chooseBtn) {
|
||||
chooseBtn.disabled = !canPick || fixedTeamFromUrl;
|
||||
chooseBtn.hidden = !canPick || fixedTeamFromUrl;
|
||||
if (showTeamPicker && canPick && !fixedTeamFromUrl) {
|
||||
chooseBtn.textContent = String(selectedTeamId || "").trim() ? "Change team…" : "Choose team…";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6580,6 +6610,7 @@ dashboard.initRoleViewPage = function initRoleViewPage() {
|
||||
command: String(selected.command || "/sbin/init"),
|
||||
privileged: true,
|
||||
systemd: String(selected.systemd || "").toLowerCase() === "true",
|
||||
platform: String(selected.run_platform || selected.platform || "").trim(),
|
||||
},
|
||||
],
|
||||
extra_vars: {},
|
||||
@@ -6617,6 +6648,23 @@ dashboard.initRoleViewPage = function initRoleViewPage() {
|
||||
}
|
||||
updateTeamDisplay();
|
||||
syncTeamPickerUi();
|
||||
|
||||
const canPickTeam =
|
||||
roleIsEditable && (currentIsOwner || currentIsAdmin) && !(isCreateFlow && createTeamId);
|
||||
if (
|
||||
el.value === "team" &&
|
||||
canPickTeam &&
|
||||
!String(selectedTeamId || "").trim()
|
||||
) {
|
||||
const picked = await openTeamPickerModal();
|
||||
if (picked && picked.id) {
|
||||
selectedTeamId = picked.id;
|
||||
selectedTeamName = picked.name || "";
|
||||
}
|
||||
updateTeamDisplay();
|
||||
syncTeamPickerUi();
|
||||
}
|
||||
|
||||
if (el.value !== "public") return;
|
||||
if (isCreateFlow && !activeRoleId) return;
|
||||
if (!roleIsEditable) return;
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
{% block content %}
|
||||
<div class="dashboard-page roles-page" data-role-create="true">
|
||||
{{ page_hero("Library", "Create Role", "Fill in details and build the file catalog; Create saves the role and opens Role details.") }}
|
||||
<section class="dashboard-panel dashboard-panel--primary">
|
||||
<section class="dashboard-panel dashboard-panel--primary role-view-tabbed-shell">
|
||||
<div class="role-view-tabs" role="tablist" aria-label="Role sections">
|
||||
<button type="button" class="role-view-tab" role="tab" id="role-tab-trigger-details" aria-selected="true" aria-controls="role-tab-panel-details">Role details</button>
|
||||
<button type="button" class="role-view-tab" role="tab" id="role-tab-trigger-catalog" aria-selected="false" aria-controls="role-tab-panel-catalog" tabindex="-1">Role file catalog</button>
|
||||
</div>
|
||||
<div class="role-view-tabbed-panels">
|
||||
<section id="role-tab-panel-details" class="role-view-tab-panel" role="tabpanel" aria-labelledby="role-tab-trigger-details">
|
||||
<div class="dashboard-panel-h">
|
||||
<div>
|
||||
<h2 class="dashboard-panel-title">Role details</h2>
|
||||
@@ -82,18 +88,20 @@
|
||||
</div>
|
||||
<p class="muted small-note role-visibility-footnote">New roles are personal. After editing, open the role and use Publish to add it to the public library.</p>
|
||||
</div>
|
||||
<div class="schema-field schema-field--wide" id="role-team-picker-wrap" hidden="hidden">
|
||||
<div class="schema-field schema-field--wide role-team-picker-field" id="role-team-picker-wrap" hidden="hidden">
|
||||
<span class="schema-field__label">Team</span>
|
||||
<div class="role-team-picker-row">
|
||||
<span id="role-team-display" class="role-team-display muted">—</span>
|
||||
<button type="button" class="btn-muted" id="role-team-choose-btn">Choose team…</button>
|
||||
<div class="role-team-picker-actions">
|
||||
<span id="role-team-display" class="role-team-display role-team-display--summary muted">—</span>
|
||||
<button type="button" class="cta-button role-team-picker-open-btn" id="role-team-choose-btn">
|
||||
Choose team…
|
||||
</button>
|
||||
</div>
|
||||
<p class="muted small-note" style="margin:6px 0 0">Only teams you belong to (active membership) are listed.</p>
|
||||
<p class="muted small-note" style="margin: 10px 0 0">Only teams you belong to (active membership) are listed.</p>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="dashboard-panel">
|
||||
<section id="role-tab-panel-catalog" class="dashboard-panel role-view-tab-panel" role="tabpanel" aria-labelledby="role-tab-trigger-catalog" hidden="hidden">
|
||||
<div class="dashboard-panel-h">
|
||||
<div>
|
||||
<h2 class="dashboard-panel-title">Role file catalog</h2>
|
||||
@@ -113,6 +121,8 @@
|
||||
</div>
|
||||
<div id="role-view-catalog" class="role-file-catalog" aria-label="Role files tree"></div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="roles-page-bottom-actions">
|
||||
<button type="submit" form="role-view-form" class="cta-button">Create</button>
|
||||
|
||||
@@ -11,7 +11,13 @@
|
||||
</button>
|
||||
</div>
|
||||
{% endcall %}
|
||||
<section class="dashboard-panel dashboard-panel--primary">
|
||||
<section class="dashboard-panel dashboard-panel--primary role-view-tabbed-shell">
|
||||
<div class="role-view-tabs" role="tablist" aria-label="Role sections">
|
||||
<button type="button" class="role-view-tab" role="tab" id="role-tab-trigger-details" aria-selected="true" aria-controls="role-tab-panel-details">Role details</button>
|
||||
<button type="button" class="role-view-tab" role="tab" id="role-tab-trigger-catalog" aria-selected="false" aria-controls="role-tab-panel-catalog" tabindex="-1">Role file catalog</button>
|
||||
</div>
|
||||
<div class="role-view-tabbed-panels">
|
||||
<section id="role-tab-panel-details" class="role-view-tab-panel" role="tabpanel" aria-labelledby="role-tab-trigger-details">
|
||||
<div class="dashboard-panel-h">
|
||||
<div>
|
||||
<h2 class="dashboard-panel-title">Role details</h2>
|
||||
@@ -90,18 +96,20 @@
|
||||
</div>
|
||||
<p class="muted small-note role-visibility-footnote">Personal roles can become Public via “Who can see this role”. Public roles can only be deleted by an administrator; personal roles can be deleted by you or an administrator.</p>
|
||||
</div>
|
||||
<div class="schema-field schema-field--wide" id="role-team-picker-wrap" hidden="hidden">
|
||||
<div class="schema-field schema-field--wide role-team-picker-field" id="role-team-picker-wrap" hidden="hidden">
|
||||
<span class="schema-field__label">Team</span>
|
||||
<div class="role-team-picker-row">
|
||||
<span id="role-team-display" class="role-team-display muted">—</span>
|
||||
<button type="button" class="btn-muted" id="role-team-choose-btn">Choose team…</button>
|
||||
<div class="role-team-picker-actions">
|
||||
<span id="role-team-display" class="role-team-display role-team-display--summary muted">—</span>
|
||||
<button type="button" class="cta-button role-team-picker-open-btn" id="role-team-choose-btn">
|
||||
Choose team…
|
||||
</button>
|
||||
</div>
|
||||
<p class="muted small-note" style="margin:6px 0 0">Only teams you belong to (active membership) are listed.</p>
|
||||
<p class="muted small-note" style="margin: 10px 0 0">Only teams you belong to (active membership) are listed.</p>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="dashboard-panel">
|
||||
<section id="role-tab-panel-catalog" class="role-view-tab-panel" role="tabpanel" aria-labelledby="role-tab-trigger-catalog" hidden="hidden">
|
||||
<div class="dashboard-panel-h">
|
||||
<div>
|
||||
<h2 class="dashboard-panel-title">Role file catalog</h2>
|
||||
@@ -121,6 +129,8 @@
|
||||
</div>
|
||||
<div id="role-view-catalog" class="role-file-catalog" aria-label="Role files tree"></div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="roles-page-bottom-actions">
|
||||
<button type="submit" form="role-view-form" class="cta-button">Save</button>
|
||||
|
||||
Reference in New Issue
Block a user