diff --git a/app/routers/domain.py b/app/routers/domain.py index d8dad15..aa01da6 100644 --- a/app/routers/domain.py +++ b/app/routers/domain.py @@ -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 diff --git a/app/schemas/domain.py b/app/schemas/domain.py index 7b33c97..cb06bc2 100644 --- a/app/schemas/domain.py +++ b/app/schemas/domain.py @@ -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): diff --git a/app/services/executors.py b/app/services/executors.py index a75e95f..6928d82 100644 --- a/app/services/executors.py +++ b/app/services/executors.py @@ -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"]}, diff --git a/app/services/molecule_docker_playbook/create.yml b/app/services/molecule_docker_playbook/create.yml new file mode 100644 index 0000000..a811385 --- /dev/null +++ b/app/services/molecule_docker_playbook/create.yml @@ -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 }}" diff --git a/app/services/molecule_docker_playbook/tasks/create_network.yml b/app/services/molecule_docker_playbook/tasks/create_network.yml new file mode 100644 index 0000000..0ec7fef --- /dev/null +++ b/app/services/molecule_docker_playbook/tasks/create_network.yml @@ -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 diff --git a/app/static/css/styles.css b/app/static/css/styles.css index b1ea7ee..ca63206 100644 --- a/app/static/css/styles.css +++ b/app/static/css/styles.css @@ -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; } diff --git a/app/static/js/pages-main.js b/app/static/js/pages-main.js index 1b7de12..6976b84 100644 --- a/app/static/js/pages-main.js +++ b/app/static/js/pages-main.js @@ -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]) => ` - `).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; diff --git a/app/templates/role-create.xhtml b/app/templates/role-create.xhtml index e4f2dd2..8fc6072 100644 --- a/app/templates/role-create.xhtml +++ b/app/templates/role-create.xhtml @@ -3,7 +3,13 @@ {% block content %}
{{ page_hero("Library", "Create Role", "Fill in details and build the file catalog; Create saves the role and opens Role details.") }} -
+
+
+ + +
+
+

Role details

@@ -82,18 +88,20 @@

New roles are personal. After editing, open the role and use Publish to add it to the public library.

-
-
+ +
+
diff --git a/app/templates/role-view.xhtml b/app/templates/role-view.xhtml index a91d3aa..a1821b9 100644 --- a/app/templates/role-view.xhtml +++ b/app/templates/role-view.xhtml @@ -11,7 +11,13 @@
{% endcall %} -
+
+
+ + +
+
+

Role details

@@ -90,18 +96,20 @@

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.

-
-
+ +
+