LLM-Server: ausführbares Projekt in die Hauptverzeichnisse host/, llm/ und encoding/ aufgeteilt.

Grundkonfiguration des Servers liegt nun unter llm-server/host/ansible/
(vorher llm-server/ansible/), der LLM-Stack-Platzhalter unter llm-server/llm/
und der Encoding-Workload unter llm-server/encoding/. Dokumentation und
Pfadverweise (ansible.cfg roles_path, .venv-Tiefe, Markdown-Links) angepasst;
Linkvalidator meldet broken_local_links=0.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
2026-10-19 14:42:14 +02:00
parent 82af4e9235
commit 92c846f527
31 changed files with 114 additions and 61 deletions
+10
View File
@@ -0,0 +1,10 @@
# Host baseline `ghostnet-ki`
Shared platform configuration of the physical LLM server at `10.0.0.90`:
host identity, Podman runtime, locked workload accounts and AMD GPU device
access. This area is common to all workloads on the server.
The executable Ansible control project lives under
[`ansible/`](ansible/README.md). The operating documentation for this area is
under
[`../../docs/platform/llm-server/host/`](../../docs/platform/llm-server/host/).
+241
View File
@@ -0,0 +1,241 @@
# LLM server host Ansible control project
This directory contains the reproducible control project for the shared host
baseline of `ghostnet-ki`. The LLM-Server host root is split into one main
directory per task area: `host/` (this project), `llm/` and `encoding/`; see
[../../README.md](../../README.md).
The read-only `playbooks/preflight.yml` verifies the documented host identity
and hardware. The shared `playbooks/host.yml` applies the host baseline,
Podman runtime, workload identities and narrowly scoped AMD device access
through the dedicated deployment account.
## Control-node setup
Use Python 3.12 through 3.14 and create a virtual environment at the repository
root:
```bash
python3 -m venv .venv
.venv/bin/python -m pip install --upgrade pip
.venv/bin/python -m pip install -r shared/ansible/requirements-control.txt
```
Load the private key for the read-only `ghost-agent` account into an SSH agent.
Do not store private keys or passphrases in this repository:
```bash
ssh-add /path/to/ghost-agent-private-key
```
The workstation must already trust the verified SSH host key for `10.0.0.90`.
An unexpected host-key change must be checked through a trusted console before
continuing.
## Read-only preflight
Run the commands from the host project directory:
```bash
cd llm-server/host/ansible
../../../.venv/bin/ansible-inventory \
--inventory inventories/testing/hosts.yml \
--graph
../../../.venv/bin/ansible-playbook \
--inventory inventories/testing/hosts.yml \
playbooks/preflight.yml \
--syntax-check
../../../.venv/bin/ansible-playbook \
--inventory inventories/testing/hosts.yml \
playbooks/preflight.yml
```
The final recap must report `changed=0` and `failed=0`. The playbook verifies:
- hostname `ghostnet-ki`;
- Debian major version 13;
- default IPv4 address `10.0.0.90`;
- remote Python availability; and
- two AMD Navi 21 PCI devices with ID `1002:73bf`.
The inventory deliberately contains no private-key path. Each operator supplies
their authorized key through `ssh-agent` or a local Ansible override.
## One-time deployment-account bootstrap
Status 2026-09-19: The initial bootstrap has been applied to `ghostnet-ki`, and
the separate verification playbook confirmed SSH access and non-interactive
effective UID 0 with `changed=0` and `failed=0`. Run the bootstrap again only
for an intentional repair, rotation or idempotence check.
The bootstrap creates the dedicated `ansible-deploy` account. Its password is
locked, its committed public key is restricted to the LAN and WireGuard source
networks, and `/etc/sudoers.d/90-ansible-deploy` is validated with `visudo`
before installation. The account receives `NOPASSWD` root escalation because
the planned host, package, systemd and container management cannot be expressed
as a small stable command allowlist.
The matching private key stays outside this repository. On the workstation on
which the initial key was created, it is stored as:
```text
~/.ssh/id_ed25519_ghostnet_ai_ansible_deploy
```
The automation key has no interactive passphrase so a future runner can use it
non-interactively. It is protected by filesystem mode `0600`, SSH source-network
restrictions and the dedicated account. Compromise of this private key would
nevertheless grant root-equivalent deployment access; rotate it when moving the
deployment identity to the runner or after any suspected disclosure.
Load the existing personal administrator key into an SSH agent, then run the
bootstrap interactively from `llm-server/host/ansible/`. Ansible will ask for
the personal administrator's existing sudo password:
```bash
ssh-add /path/to/personal-ghost-private-key
../../../.venv/bin/ansible-playbook \
--inventory inventories/bootstrap/hosts.yml \
playbooks/bootstrap-deploy-account.yml \
--ask-become-pass
```
Do not send the password through chat, store it in Git, or place it in a shell
history entry. The bootstrap is idempotent; a second run should report no
changes.
After a successful bootstrap, verify the new account independently:
```bash
../../../.venv/bin/ansible-playbook \
--inventory inventories/host/hosts.yml \
playbooks/verify-deploy-access.yml \
--extra-vars ansible_user=ansible-deploy \
--private-key ~/.ssh/id_ed25519_ghostnet_ai_ansible_deploy
```
The verification must report `changed=0` and `failed=0`. It confirms both the
unprivileged SSH identity and non-interactive escalation to effective UID 0.
## Shared host baseline
The host baseline is common to all workloads. It verifies the target identity
and at least 100 GiB free on the root filesystem, then manages these root-owned
parent and shared-data roots:
```text
/etc/ghostnet-ai
/var/lib/ghostnet-ai
/var/lib/ghostnet-ai/shared
```
It does not install software or start services. Use the dedicated host
inventory and supply the private deployment key outside the repository:
```bash
../../../.venv/bin/ansible-playbook \
--inventory inventories/host/hosts.yml \
playbooks/host.yml \
--syntax-check
../../../.venv/bin/ansible-playbook \
--inventory inventories/host/hosts.yml \
playbooks/host.yml \
--check --diff \
--private-key ~/.ssh/id_ed25519_ghostnet_ai_ansible_deploy
../../../.venv/bin/ansible-playbook \
--inventory inventories/host/hosts.yml \
playbooks/host.yml \
--private-key ~/.ssh/id_ed25519_ghostnet_ai_ansible_deploy
```
Run the final command a second time. The second recap must report `changed=0`
and `failed=0`; otherwise the role has not demonstrated convergence.
## Podman runtime
The shared host play also installs the Podman runtime from Debian 13. The role
requires systemd and cgroup v2, prepares `/etc/containers/systemd`, verifies the
overlay storage driver and exercises the installed Quadlet generator without
creating a workload.
The role explicitly keeps the Podman API socket and automatic image-update
timer disabled. It pulls no image. Apply and check it through the same
`playbooks/host.yml` commands shown above. The first run may install packages;
an immediate second run must report `changed=0`.
## Workload accounts
The shared host play creates three locked service identities with deterministic
UID/GID and subordinate-ID ranges:
| Account | UID/GID | Purpose | Rootless Podman graph root |
| --- | --- | --- | --- |
| `gnai-production` | 980 | stable AI Production | `/var/lib/ghostnet-ai/production/.local/share/containers/storage` |
| `gnai-testing` | 981 | temporary AI Testing | `/var/lib/ghostnet-ai/testing/.local/share/containers/storage` |
| `gn-encoding` | 982 | scheduled CPU video encoding | `/var/lib/ghostnet-encoding/.local/share/containers/storage` |
Each account has a persistent systemd user manager and a separate,
administrator-managed directory below `/etc/containers/systemd/users/<UID>`.
Configuration is root-owned and group-readable; workload data belongs to its
service account. The two AI identities have `render` as their explicit
supplementary-group baseline; the encoding identity has no GPU group. The role
creates no workload containers.
## AMD GPU access
The `amd_gpu_access` role verifies exactly two PCI devices `1002:73bf` using
the `amdgpu` kernel driver. It maps their sysfs identities to the corresponding
DRM render nodes, checks `/dev/kfd`, derives the device groups and confirms
read/write access as both AI workload identities.
On the current host, the verified devices are `/dev/kfd`,
`/dev/dri/renderD128` and `/dev/dri/renderD129`; all use group `render`.
`gnai-production` and `gnai-testing` can access them, while `gn-encoding`
remains outside that group. The final convergence run reported
`ok=55 changed=0 unreachable=0 failed=0`.
This proves host-level device permissions. The separate container probe below
verifies the next layer without creating a permanent service.
## Rootless ROCm container probe
The dedicated verification playbook uses `gnai-testing` and an immutable amd64
manifest of the official `llama.cpp` ROCm image:
```text
version: b11058
revision: f072b103714dfa1eee531f80b24512faf38e3dd2
digest: sha256:b429b8be10d3d0353a09c98f4ab840fe516f779858a28f9fd4e5bdf10bc63cb4
```
It passes `/dev/kfd` and both discovered render nodes with
`--group-add=keep-groups`. `rocminfo` must report exactly two `gfx1030` agents,
and `llama-server --list-devices` must expose exactly two ROCm devices. No
architecture override is set.
Run the probe from `llm-server/host/ansible/`:
```bash
../../../.venv/bin/ansible-playbook \
--inventory inventories/host/hosts.yml \
playbooks/verify-rocm-container.yml \
--syntax-check
../../../.venv/bin/ansible-playbook \
--inventory inventories/host/hosts.yml \
playbooks/verify-rocm-container.yml \
--check \
--private-key ~/.ssh/id_ed25519_ghostnet_ai_ansible_deploy
../../../.venv/bin/ansible-playbook \
--inventory inventories/host/hosts.yml \
playbooks/verify-rocm-container.yml \
--private-key ~/.ssh/id_ed25519_ghostnet_ai_ansible_deploy
```
The verified live run on 2026-09-20 reported
`ok=33 changed=0 unreachable=0 failed=0`. The ephemeral probe containers are
removed; the pinned image remains in the separate Testing image store for the
next deployment step. This proves ROCm discovery and the `llama.cpp` backend,
but not model loading, multi-GPU splitting or inference.
+12
View File
@@ -0,0 +1,12 @@
[defaults]
home = .ansible
host_key_checking = True
interpreter_python = auto_silent
local_tmp = .ansible/tmp
retry_files_enabled = False
roles_path = roles:../../../shared/ansible/roles
timeout = 15
[ssh_connection]
pipelining = True
ssh_args = -o ControlMaster=auto -o ControlPersist=60s
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJE3UGGHyB+CMA2sqPpI/6efn9u3eLelL2MkHqdt2naB ansible-deploy@ghostnet-control-2026-09
@@ -0,0 +1,8 @@
---
all:
children:
llm_servers:
hosts:
ghostnet-ki:
ansible_host: 10.0.0.90
ansible_user: ghost
@@ -0,0 +1,8 @@
---
all:
children:
llm_servers:
hosts:
ghostnet-ki:
ansible_host: 10.0.0.90
ansible_user: ansible-deploy
@@ -0,0 +1,8 @@
---
all:
children:
llm_servers:
hosts:
ghostnet-ki:
ansible_host: 10.0.0.90
ansible_user: ghost-agent
@@ -0,0 +1,98 @@
---
- name: Bootstrap the dedicated Ansible deployment account
hosts: llm_servers
become: true
gather_facts: true
vars:
ansible_deploy_user: ansible-deploy
ansible_deploy_public_key_file: >-
{{ playbook_dir }}/../files/ssh/ansible-deploy.pub
ansible_deploy_ssh_sources:
- 10.0.0.0/24
- 10.8.0.0/24
pre_tasks:
- name: Refuse to bootstrap through the target deployment account
ansible.builtin.assert:
that:
- ansible_user != ansible_deploy_user
fail_msg: >-
Run this one-time bootstrap through an existing personal administrator
account, not through {{ ansible_deploy_user }}.
- name: Verify the local deployment public key
ansible.builtin.assert:
that:
- lookup("ansible.builtin.file", ansible_deploy_public_key_file) is match("^ssh-ed25519 ")
fail_msg: >-
The expected Ed25519 public key is missing or malformed at
{{ ansible_deploy_public_key_file }}.
tasks:
- name: Create the dedicated deployment account with a locked password
ansible.builtin.user:
name: "{{ ansible_deploy_user }}"
comment: Ghostnet AI Ansible deployment account
create_home: true
home: "/home/{{ ansible_deploy_user }}"
password_lock: true
shell: /bin/bash
state: present
- name: Protect the deployment account home directory
ansible.builtin.file:
path: "/home/{{ ansible_deploy_user }}"
state: directory
owner: "{{ ansible_deploy_user }}"
group: "{{ ansible_deploy_user }}"
mode: "0750"
- name: Create the deployment SSH directory
ansible.builtin.file:
path: "/home/{{ ansible_deploy_user }}/.ssh"
state: directory
owner: "{{ ansible_deploy_user }}"
group: "{{ ansible_deploy_user }}"
mode: "0700"
- name: Install the restricted deployment public key
ansible.builtin.copy:
content: |
restrict,from="{{ ansible_deploy_ssh_sources | join(',') }}" {{ lookup('ansible.builtin.file', ansible_deploy_public_key_file) | trim }}
dest: "/home/{{ ansible_deploy_user }}/.ssh/authorized_keys"
owner: "{{ ansible_deploy_user }}"
group: "{{ ansible_deploy_user }}"
mode: "0600"
- name: Grant explicit non-interactive root escalation for Ansible
ansible.builtin.copy:
content: |
# Managed by Ghostnet AI Ansible bootstrap.
{{ ansible_deploy_user }} ALL=(root) NOPASSWD: ALL
dest: "/etc/sudoers.d/90-{{ ansible_deploy_user }}"
owner: root
group: root
mode: "0440"
validate: /usr/sbin/visudo -cf %s
- name: Read the effective deployment sudo policy
ansible.builtin.command:
argv:
- /usr/bin/sudo
- -l
- -U
- "{{ ansible_deploy_user }}"
changed_when: false
register: ansible_deploy_sudo_policy
- name: Verify non-interactive root escalation is effective
ansible.builtin.assert:
that:
- 'ansible_deploy_sudo_policy.stdout is search("NOPASSWD: ALL")'
fail_msg: >-
The deployment account exists, but its validated NOPASSWD sudo policy
is not effective.
success_msg: >-
The deployment account and its validated sudo policy are ready for
the separate access verification playbook.
@@ -0,0 +1,15 @@
---
- name: Apply the shared Ghostnet AI host baseline
hosts: llm_servers
become: true
gather_facts: true
gather_subset:
- min
- network
- hardware
roles:
- role: host_baseline
- role: podman_runtime
- role: workload_accounts
- role: amd_gpu_access
@@ -0,0 +1,60 @@
---
- name: Verify read-only access to the Ghostnet AI server
hosts: llm_servers
become: false
gather_facts: true
gather_subset:
- min
- hardware
- network
tasks:
- name: Verify the expected host identity and operating system
ansible.builtin.assert:
that:
- ansible_facts["hostname"] == "ghostnet-ki"
- ansible_facts["distribution"] == "Debian"
- ansible_facts["distribution_major_version"] == "13"
- ansible_facts["default_ipv4"]["address"] == "10.0.0.90"
- ansible_facts["python"]["version"]["major"] >= 3
fail_msg: >-
The connected host does not match the documented Ghostnet AI server
baseline. Stop before using any deployment playbook.
success_msg: The expected Debian 13 host ghostnet-ki is connected.
- name: Read the expected AMD GPU PCI devices
ansible.builtin.command:
argv:
- lspci
- -D
- -nn
- -d
- "1002:73bf"
check_mode: false
changed_when: false
register: preflight_gpu_devices
- name: Verify both documented AMD GPUs are present
ansible.builtin.assert:
that:
- preflight_gpu_devices.stdout_lines | length == 2
fail_msg: >-
Expected two AMD Navi 21 PCI devices with ID 1002:73bf, but found
{{ preflight_gpu_devices.stdout_lines | length }}.
success_msg: Both documented AMD Navi 21 PCI devices are present.
- name: Report the verified read-only baseline
ansible.builtin.debug:
msg:
hostname: "{{ ansible_facts['hostname'] }}"
operating_system: >-
{{ ansible_facts['distribution'] }}
{{ ansible_facts['distribution_version'] }}
default_ipv4: "{{ ansible_facts['default_ipv4']['address'] }}"
processor_vcpus: "{{ ansible_facts['processor_vcpus'] }}"
memory_mib: "{{ ansible_facts['memtotal_mb'] }}"
python_version: >-
{{ ansible_facts['python']['version']['major'] }}.
{{- ansible_facts['python']['version']['minor'] }}.
{{- ansible_facts['python']['version']['micro'] }}
amd_gpu_devices: "{{ preflight_gpu_devices.stdout_lines }}"
@@ -0,0 +1,41 @@
---
- name: Verify the dedicated Ansible deployment account
hosts: llm_servers
become: false
gather_facts: false
tasks:
- name: Verify Ansible module execution without privilege escalation
ansible.builtin.ping:
- name: Read the SSH account identity
ansible.builtin.command:
argv:
- /usr/bin/id
- -un
changed_when: false
register: deploy_account_identity
- name: Verify the expected SSH account
ansible.builtin.assert:
that:
- deploy_account_identity.stdout == "ansible-deploy"
fail_msg: The SSH connection did not use the ansible-deploy account.
- name: Read the effective identity through sudo
become: true
ansible.builtin.command:
argv:
- /usr/bin/id
- -u
changed_when: false
register: deploy_root_identity
- name: Verify non-interactive root escalation
ansible.builtin.assert:
that:
- deploy_root_identity.stdout == "0"
fail_msg: Non-interactive sudo did not produce effective UID 0.
success_msg: >-
The dedicated deployment account can connect and escalate privileges
non-interactively.
@@ -0,0 +1,11 @@
---
- name: Verify rootless ROCm access for the AI Testing account
hosts: llm_servers
become: true
gather_facts: true
gather_subset:
- min
roles:
- role: amd_gpu_access
- role: rocm_container_probe
@@ -0,0 +1,16 @@
# AMD GPU access role
This shared host role verifies the two configured AMD GPUs and their `amdgpu`
kernel driver, discovers only the DRM render nodes belonging to their PCI IDs,
and checks `/dev/kfd`. It grants the Production and Testing service accounts
the minimal supplementary groups required by those device nodes.
When group membership changes, the role restarts both persistent systemd user
managers so later rootless Quadlets can use `GroupAdd=keep-groups`. It verifies
that each service account can read and write `/dev/kfd` and both selected
render devices.
The role installs no ROCm userspace packages, pulls no image and starts no
container. ROCm userspace will come from the pinned `llama.cpp` container. A
later workload check must still prove that the container detects both GPUs as
`gfx1030` and performs inference on them.
@@ -0,0 +1,10 @@
---
amd_gpu_vendor_id: "1002"
amd_gpu_device_id: "73bf"
amd_gpu_expected_count: 2
amd_gpu_workload_accounts:
- name: gnai-production
uid: 980
- name: gnai-testing
uid: 981
@@ -0,0 +1,10 @@
---
- name: Restart AI workload user managers
ansible.builtin.systemd_service:
name: "user@{{ amd_gpu_account.uid }}.service"
state: restarted
loop: "{{ amd_gpu_workload_accounts }}"
loop_control:
loop_var: amd_gpu_account
label: "{{ amd_gpu_account.name }}"
when: not ansible_check_mode
@@ -0,0 +1,212 @@
---
- name: Verify AMD GPU access declarations
ansible.builtin.assert:
that:
- amd_gpu_vendor_id is match("^[0-9a-fA-F]{4}$")
- amd_gpu_device_id is match("^[0-9a-fA-F]{4}$")
- amd_gpu_expected_count | int > 0
- >-
amd_gpu_workload_accounts | map(attribute="name") | unique | length ==
amd_gpu_workload_accounts | length
- >-
amd_gpu_workload_accounts | map(attribute="uid") | unique | length ==
amd_gpu_workload_accounts | length
fail_msg: AMD GPU identifiers and workload accounts must be valid and unique.
- name: Read matching PCI devices and kernel drivers
ansible.builtin.command:
argv:
- /usr/bin/lspci
- -Dnnk
- -d
- "{{ amd_gpu_vendor_id }}:{{ amd_gpu_device_id }}"
changed_when: false
check_mode: false
register: amd_gpu_lspci
- name: Verify expected GPUs use the amdgpu kernel driver
ansible.builtin.assert:
that:
- >-
amd_gpu_lspci.stdout_lines
| select("search", "(?i)" + amd_gpu_vendor_id + ":" + amd_gpu_device_id)
| list | length == amd_gpu_expected_count | int
- >-
amd_gpu_lspci.stdout
| regex_findall("Kernel driver in use: amdgpu")
| length == amd_gpu_expected_count | int
fail_msg: >-
Expected {{ amd_gpu_expected_count }} PCI devices
{{ amd_gpu_vendor_id }}:{{ amd_gpu_device_id }} using amdgpu.
- name: Find DRM render nodes in sysfs
ansible.builtin.find:
paths: /sys/class/drm
patterns: "renderD*"
file_type: link
recurse: false
register: amd_gpu_sysfs_render_nodes
- name: Read render node PCI vendor IDs
ansible.builtin.slurp:
src: "{{ amd_gpu_render_node.path }}/device/vendor"
loop: "{{ amd_gpu_sysfs_render_nodes.files }}"
loop_control:
loop_var: amd_gpu_render_node
label: "{{ amd_gpu_render_node.path | basename }}"
register: amd_gpu_render_vendor_ids
- name: Read render node PCI device IDs
ansible.builtin.slurp:
src: "{{ amd_gpu_render_node.path }}/device/device"
loop: "{{ amd_gpu_sysfs_render_nodes.files }}"
loop_control:
loop_var: amd_gpu_render_node
label: "{{ amd_gpu_render_node.path | basename }}"
register: amd_gpu_render_device_ids
- name: Initialize matching AMD render device list
ansible.builtin.set_fact:
amd_gpu_render_devices: []
- name: Select render devices belonging to the configured AMD GPUs
ansible.builtin.set_fact:
amd_gpu_render_devices: >-
{{ amd_gpu_render_devices
+ ['/dev/dri/'
+ (amd_gpu_sysfs_render_nodes.files[amd_gpu_render_index].path
| basename)] }}
loop: "{{ range(0, amd_gpu_sysfs_render_nodes.files | length) | list }}"
loop_control:
loop_var: amd_gpu_render_index
label: >-
{{ amd_gpu_sysfs_render_nodes.files[amd_gpu_render_index].path | basename }}
when:
- >-
amd_gpu_render_vendor_ids.results[amd_gpu_render_index].content
| b64decode | trim | lower == "0x" + amd_gpu_vendor_id | lower
- >-
amd_gpu_render_device_ids.results[amd_gpu_render_index].content
| b64decode | trim | lower == "0x" + amd_gpu_device_id | lower
- name: Verify one render device was found for each configured GPU
ansible.builtin.assert:
that:
- amd_gpu_render_devices | length == amd_gpu_expected_count
fail_msg: >-
Expected {{ amd_gpu_expected_count }} matching AMD render devices but found
{{ amd_gpu_render_devices | length }}: {{ amd_gpu_render_devices }}.
- name: Inspect the ROCm kernel device
ansible.builtin.stat:
path: /dev/kfd
register: amd_gpu_kfd
- name: Verify the ROCm kernel device exists
ansible.builtin.assert:
that:
- amd_gpu_kfd.stat.exists
- amd_gpu_kfd.stat.ischr | default(false)
- amd_gpu_kfd.stat.gr_name != "root"
fail_msg: /dev/kfd must exist as a character device with a dedicated group.
- name: Inspect matching AMD render devices
ansible.builtin.stat:
path: "{{ amd_gpu_render_device }}"
loop: "{{ amd_gpu_render_devices }}"
loop_control:
loop_var: amd_gpu_render_device
register: amd_gpu_render_device_stats
- name: Verify matching AMD render devices are usable character devices
ansible.builtin.assert:
that:
- amd_gpu_render_stat.stat.exists
- amd_gpu_render_stat.stat.ischr | default(false)
- amd_gpu_render_stat.stat.gr_name != "root"
loop: "{{ amd_gpu_render_device_stats.results }}"
loop_control:
loop_var: amd_gpu_render_stat
label: "{{ amd_gpu_render_stat.amd_gpu_render_device }}"
- name: Derive the minimal host groups required by the GPU devices
ansible.builtin.set_fact:
amd_gpu_required_group_names: >-
{{ ([amd_gpu_kfd.stat.gr_name]
+ (amd_gpu_render_device_stats.results
| map(attribute="stat.gr_name") | list))
| unique | sort | list }}
- name: Grant AI workload accounts access to the GPU device groups
ansible.builtin.user:
name: "{{ amd_gpu_account.name }}"
groups: "{{ amd_gpu_required_group_names | join(',') }}"
append: true
loop: "{{ amd_gpu_workload_accounts }}"
loop_control:
loop_var: amd_gpu_account
label: "{{ amd_gpu_account.name }}"
notify: Restart AI workload user managers
- name: Apply changed group memberships to persistent user managers
ansible.builtin.meta: flush_handlers
- name: Read effective AI workload account groups
ansible.builtin.command:
argv:
- /usr/bin/id
- -nG
- "{{ amd_gpu_account.name }}"
changed_when: false
loop: "{{ amd_gpu_workload_accounts }}"
loop_control:
loop_var: amd_gpu_account
label: "{{ amd_gpu_account.name }}"
register: amd_gpu_account_groups
when: not ansible_check_mode
- name: Verify AI workload account group memberships
ansible.builtin.assert:
that:
- >-
amd_gpu_required_group_names
| difference(amd_gpu_account_group.stdout.split()) | length == 0
fail_msg: >-
{{ amd_gpu_account_group.amd_gpu_account.name }} is missing a required GPU
device group.
loop: "{{ amd_gpu_account_groups.results }}"
loop_control:
loop_var: amd_gpu_account_group
label: "{{ amd_gpu_account_group.amd_gpu_account.name }}"
when: not ansible_check_mode
- name: Verify AI workload accounts can read and write required GPU devices
ansible.builtin.command:
argv:
- /usr/sbin/runuser
- --user
- "{{ amd_gpu_access_pair.0.name }}"
- --
- /usr/bin/python3
- -c
- >-
import os, sys; raise SystemExit(
0 if os.access(sys.argv[1], os.R_OK | os.W_OK) else 1)
- "{{ amd_gpu_access_pair.1 }}"
changed_when: false
loop: >-
{{ amd_gpu_workload_accounts
| product(['/dev/kfd'] + amd_gpu_render_devices) | list }}
loop_control:
loop_var: amd_gpu_access_pair
label: "{{ amd_gpu_access_pair.0.name }} -> {{ amd_gpu_access_pair.1 }}"
when: not ansible_check_mode
- name: Report verified AMD GPU access
ansible.builtin.debug:
msg:
pci_id: "{{ amd_gpu_vendor_id }}:{{ amd_gpu_device_id }}"
gpu_count: "{{ amd_gpu_expected_count }}"
render_devices: "{{ amd_gpu_render_devices }}"
device_groups: "{{ amd_gpu_required_group_names }}"
accounts: "{{ amd_gpu_workload_accounts | map(attribute='name') | list }}"
@@ -0,0 +1,19 @@
# Shared host baseline
The `host_baseline` role manages state shared by the Production and Testing
environments on `ghostnet-ki`.
Its first version deliberately has a narrow scope:
- refuse changes when hostname, Debian major version or primary IPv4 address
do not match the declared target;
- require at least 100 GiB free on the root filesystem; and
- create root-owned parent directories and the future shared-data root.
Environment-specific directory ownership is managed by `workload_accounts` so
the shared baseline and workload identities do not compete over permissions.
It does not install packages, drivers, Podman or applications and does not
start or stop services. Ownership below the directory roots will be assigned
to dedicated service accounts when the corresponding application roles are
implemented.
@@ -0,0 +1,16 @@
---
host_baseline_expected_hostname: ghostnet-ki
host_baseline_expected_distribution: Debian
host_baseline_expected_distribution_major_version: "13"
host_baseline_expected_ipv4_address: 10.0.0.90
# Preserve enough headroom for host operation and controlled model handling.
host_baseline_minimum_root_free_gib: 100
host_baseline_directories:
- path: /etc/ghostnet-ai
mode: "0755"
- path: /var/lib/ghostnet-ai
mode: "0755"
- path: /var/lib/ghostnet-ai/shared
mode: "0755"
@@ -0,0 +1,59 @@
---
- name: Verify the target host before changing shared state
ansible.builtin.assert:
that:
- ansible_facts["hostname"] == host_baseline_expected_hostname
- ansible_facts["distribution"] == host_baseline_expected_distribution
- >-
ansible_facts["distribution_major_version"] ==
host_baseline_expected_distribution_major_version
- >-
ansible_facts["default_ipv4"]["address"] ==
host_baseline_expected_ipv4_address
fail_msg: >-
The connected host does not match the declared Ghostnet AI host. No
baseline changes may be applied.
success_msg: The expected Debian host ghostnet-ki is connected.
- name: Select the root filesystem facts
ansible.builtin.set_fact:
host_baseline_root_mount: >-
{{ (ansible_facts["mounts"]
| selectattr("mount", "equalto", "/")
| list
| first)
| default({}) }}
- name: Verify the root filesystem was discovered
ansible.builtin.assert:
that:
- host_baseline_root_mount | length > 0
- host_baseline_root_mount["size_available"] is defined
fail_msg: Root filesystem capacity could not be determined. No changes were made.
- name: Verify minimum free capacity on the shared root filesystem
ansible.builtin.assert:
that:
- >-
host_baseline_root_mount["size_available"] | int >=
host_baseline_minimum_root_free_gib | int * 1024 ** 3
fail_msg: >-
The root filesystem has only
{{ (host_baseline_root_mount["size_available"] | int / 1024 ** 3) | round(1) }}
GiB free. At least {{ host_baseline_minimum_root_free_gib }} GiB are
required before applying the shared host baseline.
success_msg: >-
The root filesystem has
{{ (host_baseline_root_mount["size_available"] | int / 1024 ** 3) | round(1) }}
GiB free.
- name: Create the shared configuration and data directory roots
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
owner: root
group: root
mode: "{{ item.mode }}"
loop: "{{ host_baseline_directories }}"
loop_control:
label: "{{ item.path }}"
@@ -0,0 +1,17 @@
# Rootless ROCm container probe
This verification role pulls one immutable amd64 manifest of the official
`llama.cpp` `server-rocm` image into the `gnai-testing` rootless Podman store.
It verifies the OCI image revision and build version, then runs
`rocminfo` and `llama-server --list-devices` in ephemeral containers.
The probe uses the same security-relevant options planned for the permanent
Quadlet: `/dev/kfd`, only the discovered AMD render nodes and
`GroupAdd=keep-groups`. It sets no graphics-architecture override, publishes no
port, mounts no model and removes the probe container after execution. The
pinned image remains in the Testing store for the subsequent `llama.cpp`
deployment.
Success proves that ROCm sees two `gfx1030` agents and that `llama.cpp` exposes
both as usable ROCm devices to the rootless Testing container. It does not yet
prove model loading, multi-GPU splitting or inference.
@@ -0,0 +1,12 @@
---
rocm_probe_account:
name: gnai-testing
uid: 981
home: /var/lib/ghostnet-ai/testing
rocm_probe_image: >-
ghcr.io/ggml-org/llama.cpp@sha256:b429b8be10d3d0353a09c98f4ab840fe516f779858a28f9fd4e5bdf10bc63cb4
rocm_probe_expected_image_revision: f072b103714dfa1eee531f80b24512faf38e3dd2
rocm_probe_expected_image_version: b11058
rocm_probe_expected_architecture: gfx1030
rocm_probe_expected_device_count: 2
@@ -0,0 +1,204 @@
---
- name: Verify ROCm probe declarations and discovered devices
ansible.builtin.assert:
that:
- rocm_probe_account.name | length > 0
- rocm_probe_account.uid | int > 0
- rocm_probe_account.home is match("^/")
- rocm_probe_image is match("^ghcr\.io/.+@sha256:[0-9a-f]{64}$")
- rocm_probe_expected_image_revision is match("^[0-9a-f]{40}$")
- rocm_probe_expected_image_version | length > 0
- rocm_probe_expected_architecture is match("^gfx[0-9]+$")
- rocm_probe_expected_device_count | int > 0
- amd_gpu_render_devices | length == rocm_probe_expected_device_count | int
fail_msg: ROCm probe inputs or AMD render-device discovery are invalid.
- name: Read rootless Podman OCI runtime
ansible.builtin.command:
argv:
- /usr/sbin/runuser
- --user
- "{{ rocm_probe_account.name }}"
- --
- /usr/bin/env
- "HOME={{ rocm_probe_account.home }}"
- "XDG_RUNTIME_DIR=/run/user/{{ rocm_probe_account.uid }}"
- /usr/bin/podman
- info
- --format=json
chdir: "{{ rocm_probe_account.home }}"
changed_when: false
check_mode: false
register: rocm_probe_podman_info_command
- name: Parse rootless Podman OCI runtime
ansible.builtin.set_fact:
rocm_probe_podman_info: "{{ rocm_probe_podman_info_command.stdout | from_json }}"
- name: Require crun for rootless keep-groups support
ansible.builtin.assert:
that:
- rocm_probe_podman_info.host.ociRuntime.name == "crun"
- rocm_probe_podman_info.host.security.rootless
fail_msg: The ROCm probe requires rootless Podman with the crun OCI runtime.
- name: Check whether the pinned ROCm image is already present
ansible.builtin.command:
argv:
- /usr/sbin/runuser
- --user
- "{{ rocm_probe_account.name }}"
- --
- /usr/bin/env
- "HOME={{ rocm_probe_account.home }}"
- "XDG_RUNTIME_DIR=/run/user/{{ rocm_probe_account.uid }}"
- /usr/bin/podman
- image
- exists
- "{{ rocm_probe_image }}"
chdir: "{{ rocm_probe_account.home }}"
changed_when: false
failed_when: rocm_probe_image_exists.rc not in [0, 1]
check_mode: false
register: rocm_probe_image_exists
- name: Pull the pinned ROCm image into the Testing store
ansible.builtin.command:
argv:
- /usr/sbin/runuser
- --user
- "{{ rocm_probe_account.name }}"
- --
- /usr/bin/env
- "HOME={{ rocm_probe_account.home }}"
- "XDG_RUNTIME_DIR=/run/user/{{ rocm_probe_account.uid }}"
- /usr/bin/podman
- pull
- "{{ rocm_probe_image }}"
chdir: "{{ rocm_probe_account.home }}"
changed_when: true
when:
- not ansible_check_mode
- rocm_probe_image_exists.rc == 1
- name: Inspect the pinned ROCm image
ansible.builtin.command:
argv:
- /usr/sbin/runuser
- --user
- "{{ rocm_probe_account.name }}"
- --
- /usr/bin/env
- "HOME={{ rocm_probe_account.home }}"
- "XDG_RUNTIME_DIR=/run/user/{{ rocm_probe_account.uid }}"
- /usr/bin/podman
- image
- inspect
- --format=json
- "{{ rocm_probe_image }}"
chdir: "{{ rocm_probe_account.home }}"
changed_when: false
register: rocm_probe_image_inspect_command
when: not ansible_check_mode
- name: Parse the pinned ROCm image metadata
ansible.builtin.set_fact:
rocm_probe_image_metadata: >-
{{ (rocm_probe_image_inspect_command.stdout | from_json) | first }}
when: not ansible_check_mode
- name: Verify the pinned ROCm image metadata
ansible.builtin.assert:
that:
- >-
rocm_probe_image_metadata.Labels['org.opencontainers.image.revision'] ==
rocm_probe_expected_image_revision
- >-
rocm_probe_image_metadata.Labels['org.opencontainers.image.version'] ==
rocm_probe_expected_image_version
fail_msg: The pulled ROCm image metadata does not match the pinned build.
when: not ansible_check_mode
- name: Detect both AMD GPU architectures through ROCm in a rootless container
ansible.builtin.command:
argv: >-
{{
[
'/usr/sbin/runuser',
'--user', rocm_probe_account.name,
'--',
'/usr/bin/env',
'HOME=' + rocm_probe_account.home,
'XDG_RUNTIME_DIR=/run/user/' + (rocm_probe_account.uid | string),
'/usr/bin/podman',
'run', '--rm', '--pull=never',
'--group-add=keep-groups'
]
+ ((['/dev/kfd'] + amd_gpu_render_devices)
| map('regex_replace', '^', '--device=') | list)
+ [
'--entrypoint=/usr/bin/rocminfo',
rocm_probe_image
]
}}
chdir: "{{ rocm_probe_account.home }}"
changed_when: false
register: rocm_probe_rocminfo_output
when: not ansible_check_mode
- name: Verify ROCm reported both expected GPU architectures
ansible.builtin.assert:
that:
- >-
(rocm_probe_rocminfo_output.stdout + rocm_probe_rocminfo_output.stderr)
| regex_findall("(?m)^\\s*Name:\\s+" ~ rocm_probe_expected_architecture ~ "\\s*$")
| length == rocm_probe_expected_device_count | int
fail_msg: >-
rocminfo did not report the expected architecture for both AMD GPUs.
when: not ansible_check_mode
- name: Detect both AMD GPUs through llama.cpp in a rootless container
ansible.builtin.command:
argv: >-
{{
[
'/usr/sbin/runuser',
'--user', rocm_probe_account.name,
'--',
'/usr/bin/env',
'HOME=' + rocm_probe_account.home,
'XDG_RUNTIME_DIR=/run/user/' + (rocm_probe_account.uid | string),
'/usr/bin/podman',
'run', '--rm', '--pull=never',
'--group-add=keep-groups'
]
+ ((['/dev/kfd'] + amd_gpu_render_devices)
| map('regex_replace', '^', '--device=') | list)
+ [rocm_probe_image, '--list-devices']
}}
chdir: "{{ rocm_probe_account.home }}"
changed_when: false
register: rocm_probe_device_output
when: not ansible_check_mode
- name: Verify llama.cpp reported both expected ROCm devices
ansible.builtin.assert:
that:
- >-
(rocm_probe_device_output.stdout + rocm_probe_device_output.stderr)
| regex_findall("(?m)^\s*ROCm[0-9]+:")
| unique | length == rocm_probe_expected_device_count | int
fail_msg: >-
The rootless llama.cpp ROCm image did not report both expected GPUs.
when: not ansible_check_mode
- name: Report verified rootless ROCm devices
ansible.builtin.debug:
msg:
account: "{{ rocm_probe_account.name }}"
image: "{{ rocm_probe_image }}"
image_version: "{{ rocm_probe_expected_image_version }}"
image_revision: "{{ rocm_probe_expected_image_revision }}"
architecture: "{{ rocm_probe_expected_architecture }}"
devices: "{{ amd_gpu_render_devices }}"
when: not ansible_check_mode
@@ -0,0 +1,17 @@
# Workload accounts
The `workload_accounts` role creates separate locked service identities for AI
Production, AI Testing and CPU-based video encoding. Each identity receives a
stable UID/GID, a non-overlapping subordinate ID range, a private data root, an
administrator-managed rootless Quadlet directory and a persistent systemd user
manager.
Configuration remains owned by root and is readable by the corresponding
service group. Workload data and the rootless Podman store belong to the
service account. The role verifies that all three Podman stores are rootless
and use distinct graph roots.
The two AI accounts receive `render` as an explicit supplementary-group
baseline; the encoding account receives no GPU group. Hardware discovery and
effective device-access checks belong to `amd_gpu_access`. This role does not
define containers, networks, schedules or resource limits.
@@ -0,0 +1,45 @@
---
workload_accounts:
- name: gnai-production
uid: 980
gid: 980
comment: Ghostnet AI Production workload
home: /var/lib/ghostnet-ai/production
config_directory: /etc/ghostnet-ai/production
subid_start: 362144
supplementary_groups:
- render
- name: gnai-testing
uid: 981
gid: 981
comment: Ghostnet AI Testing workload
home: /var/lib/ghostnet-ai/testing
config_directory: /etc/ghostnet-ai/testing
subid_start: 427680
supplementary_groups:
- render
- name: gn-encoding
uid: 982
gid: 982
comment: Ghostnet video encoding workload
home: /var/lib/ghostnet-encoding
config_directory: /etc/ghostnet-encoding
subid_start: 493216
supplementary_groups: []
workload_subid_count: 65536
workload_quadlet_user_directory: /etc/containers/systemd/users
workload_data_directories:
- path: /var/lib/ghostnet-encoding/input
owner: 982
group: 982
mode: "0750"
- path: /var/lib/ghostnet-encoding/output
owner: 982
group: 982
mode: "0750"
- path: /var/lib/ghostnet-encoding/work
owner: 982
group: 982
mode: "0750"
@@ -0,0 +1,207 @@
---
- name: Verify workload account declarations
ansible.builtin.assert:
that:
- workload_accounts | map(attribute="name") | unique | length == workload_accounts | length
- workload_accounts | map(attribute="uid") | unique | length == workload_accounts | length
- workload_accounts | map(attribute="gid") | unique | length == workload_accounts | length
- >-
workload_accounts | map(attribute="subid_start") | unique | length ==
workload_accounts | length
fail_msg: Workload account names and numeric IDs must be unique.
- name: Create dedicated workload groups
ansible.builtin.group:
name: "{{ workload_account.name }}"
gid: "{{ workload_account.gid }}"
system: true
state: present
loop: "{{ workload_accounts }}"
loop_control:
loop_var: workload_account
label: "{{ workload_account.name }}"
- name: Create locked workload service accounts
ansible.builtin.user:
name: "{{ workload_account.name }}"
uid: "{{ workload_account.uid }}"
group: "{{ workload_account.name }}"
comment: "{{ workload_account.comment }}"
home: "{{ workload_account.home }}"
shell: /usr/sbin/nologin
groups: "{{ workload_account.supplementary_groups | join(',') }}"
append: false
create_home: true
system: true
password_lock: true
state: present
loop: "{{ workload_accounts }}"
loop_control:
loop_var: workload_account
label: "{{ workload_account.name }}"
- name: Assign deterministic subordinate UID ranges
ansible.builtin.lineinfile:
path: /etc/subuid
regexp: "^{{ workload_account.name | regex_escape }}:"
line: >-
{{ workload_account.name }}:{{ workload_account.subid_start }}:{{ workload_subid_count }}
owner: root
group: root
mode: "0644"
loop: "{{ workload_accounts }}"
loop_control:
loop_var: workload_account
label: "{{ workload_account.name }}"
- name: Assign deterministic subordinate GID ranges
ansible.builtin.lineinfile:
path: /etc/subgid
regexp: "^{{ workload_account.name | regex_escape }}:"
line: >-
{{ workload_account.name }}:{{ workload_account.subid_start }}:{{ workload_subid_count }}
owner: root
group: root
mode: "0644"
loop: "{{ workload_accounts }}"
loop_control:
loop_var: workload_account
label: "{{ workload_account.name }}"
- name: Assign workload data roots to their service accounts
ansible.builtin.file:
path: "{{ workload_account.home }}"
state: directory
owner: "{{ workload_account.uid }}"
group: "{{ workload_account.gid }}"
mode: "0750"
loop: "{{ workload_accounts }}"
loop_control:
loop_var: workload_account
label: "{{ workload_account.home }}"
- name: Keep workload configuration root-managed
ansible.builtin.file:
path: "{{ workload_account.config_directory }}"
state: directory
owner: root
group: "{{ workload_account.gid }}"
mode: "0750"
loop: "{{ workload_accounts }}"
loop_control:
loop_var: workload_account
label: "{{ workload_account.config_directory }}"
- name: Create workload-specific data directories
ansible.builtin.file:
path: "{{ workload_directory.path }}"
state: directory
owner: "{{ workload_directory.owner }}"
group: "{{ workload_directory.group }}"
mode: "{{ workload_directory.mode }}"
loop: "{{ workload_data_directories }}"
loop_control:
loop_var: workload_directory
label: "{{ workload_directory.path }}"
- name: Create the administrator-managed rootless Quadlet root
ansible.builtin.file:
path: "{{ workload_quadlet_user_directory }}"
state: directory
owner: root
group: root
mode: "0755"
- name: Create separate administrator-managed Quadlet directories
ansible.builtin.file:
path: "{{ workload_quadlet_user_directory }}/{{ workload_account.uid }}"
state: directory
owner: root
group: root
mode: "0755"
loop: "{{ workload_accounts }}"
loop_control:
loop_var: workload_account
label: "{{ workload_account.name }} (UID {{ workload_account.uid }})"
- name: Enable persistent user managers for rootless Quadlets
ansible.builtin.command:
argv:
- /usr/bin/loginctl
- enable-linger
- "{{ workload_account.name }}"
creates: "/var/lib/systemd/linger/{{ workload_account.name }}"
loop: "{{ workload_accounts }}"
loop_control:
loop_var: workload_account
label: "{{ workload_account.name }}"
- name: Wait for persistent user managers
ansible.builtin.command:
argv:
- /usr/bin/systemctl
- is-active
- "user@{{ workload_account.uid }}.service"
changed_when: false
register: workload_user_manager_status
retries: 5
delay: 1
until: workload_user_manager_status.stdout == "active"
loop: "{{ workload_accounts }}"
loop_control:
loop_var: workload_account
label: "{{ workload_account.name }}"
when: not ansible_check_mode
- name: Initialize and inspect each rootless Podman store
ansible.builtin.command:
argv:
- /usr/sbin/runuser
- --user
- "{{ workload_account.name }}"
- --
- /usr/bin/env
- "HOME={{ workload_account.home }}"
- "XDG_RUNTIME_DIR=/run/user/{{ workload_account.uid }}"
- /usr/bin/podman
- info
- --format=json
chdir: "{{ workload_account.home }}"
changed_when: false
register: workload_podman_info_commands
loop: "{{ workload_accounts }}"
loop_control:
loop_var: workload_account
label: "{{ workload_account.name }}"
when: not ansible_check_mode
- name: Parse rootless Podman information
ansible.builtin.set_fact:
workload_podman_infos: >-
{{ workload_podman_info_commands.results
| map(attribute="stdout")
| map("from_json")
| list }}
when: not ansible_check_mode
- name: Verify isolated rootless Podman stores
ansible.builtin.assert:
that:
- >-
workload_podman_infos | map(attribute="host.security.rootless")
| select("equalto", true) | list | length == workload_accounts | length
- >-
workload_podman_infos | map(attribute="store.graphRoot")
| unique | list | length == workload_accounts | length
fail_msg: Workload accounts do not have separate rootless Podman stores.
success_msg: All workload accounts use separate rootless Podman stores.
when: not ansible_check_mode
- name: Report workload account isolation
ansible.builtin.debug:
msg:
accounts: "{{ workload_accounts | map(attribute='name') | list }}"
uids: "{{ workload_accounts | map(attribute='uid') | list }}"
graph_roots: >-
{{ workload_podman_infos | map(attribute="store.graphRoot") | list }}
when: not ansible_check_mode