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:
Sergey Antropoff
2026-05-05 08:56:54 +03:00
parent b2d3b6b803
commit 9727ff6402
9 changed files with 532 additions and 81 deletions

View File

@@ -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

View File

@@ -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):

View File

@@ -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"]},

View 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 }}"

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>