aim-web2.1.0rc9

This commit is contained in:
admin_rb
2026-09-22 19:23:17 +02:00
parent d095887d2e
commit 3dfc80b782
438 changed files with 31613 additions and 1510 deletions
+1
View File
@@ -0,0 +1 @@
aim-webgui 2.1.0rc9
+1
View File
@@ -0,0 +1 @@
/var/lib/aim/webgui/.credentials
+688
View File
@@ -0,0 +1,688 @@
# AIM WebGUI Add-on Installation
**Applies to:** AIM WebGUI 2.1.0rc9
**Required AIM Core:** 3.3.0rc8
**Core service / wire / event API:** 1.0
**Canonical Ansible Core:** 2.19.11, provided by the separately installed Core environment
**WebGUI HTTP API / database:** v2 / SQLite schema 5
**WebGUI Python:** Python 3.11+ with virtual-environment support
**Managed deployment:** Linux with systemd
**Prerequisite:** AIM Core 3.3.0rc8 `scripts/docs/INSTALLATION.md` (separately supplied Core guide)
This guide continues after the Core installation guide. It installs a **new AIM WebGUI add-on on an already configured AIM controller**; it does not install AIM Core again. Complete Core installation, establish approved customer data, and accept a controlled terminal run before enabling WebGUI execution. Core remains usable without this add-on. [C1][C2]
For an existing managed WebGUI installation, use [section 15](#15-updating-an-existing-add-on), not the fresh-install command. For a controller already running this exact add-on version, use the verification and acceptance sections; the deployer rejects a same-version reinstall. [W2]
**Reading order:** fresh installations follow sections 1-13; backup, updates and recovery are in sections 14-17. The completion checklist is at the end.
The commands below use `/etc/ansible`, the existing executor account `svc_bf-ansible`, and the default web account `aim-web`. These are the current release profile, not requirements to rename an established controller account. This document describes a release candidate and a source-checked procedure, not evidence that a new controller has passed live acceptance.
## 1. Complete the Core prerequisite first
Use Core's `INSTALLATION.md` for the source installation, dependency environments, canonical Ansible runtime, collections, controller configuration, inventories, Vaults and keys. Do not substitute the WebGUI virtual environment for either the AIM or Ansible environment. [C1, sections 1-12]
Before continuing, establish:
- `aim` and `aimctl` refer to the intended Core 3.3.0rc8 installation.
- The AIM Python environment contains its declared dependencies, including the documented `rich>=13,<15` range.
- The separately configured Ansible runtime contains exactly Core 2.19.11 and the approved collection/connection dependencies needed by the selected playbooks.
- Core 3.3.0rc8 requires `ansible.windows >=3.8.0,<4.0.0` for Windows playbooks. Upgrade that collection deliberately in the canonical Core/Ansible collection path before accepting rc8; WebGUI does not install or upgrade it.
- At least one approved test customer/host is available for the terminal and add-on acceptance run.
- The non-root execution account is authorized by Core and has the required filesystem access.
- Terminal AIM has completed an approved low-risk test independently of WebGUI.
Check the installed commands:
```bash
command -v aim
command -v aimctl
aim --version
aimctl --version
```
Both Core commands must report `3.3.0rc8`. A generic API version of `1.0` alone is not enough: this WebGUI candidate also checks its explicitly supported Core product version and capabilities. [W2][W3]
For a fresh setup, leave this existing Core setting disabled while provisioning the add-on:
```yaml
addons:
execution_enabled: false
```
Merge configuration changes into `/etc/ansible/scripts/aim.yml`; do not replace the entire file. The final opt-in is covered in section 10. Discovery and installation of the add-on do not grant permission to execute jobs. [C1, sections 8 and 13]
## 2. Confirm the two local service identities
The managed topology uses two different non-root accounts: [W2][W4]
| Component | Default account | Responsibility |
|---|---|---|
| `aim-web.service` | `aim-web` | HTTP, sessions, UI and private workflow database |
| `aim-web-worker.service` | `aim-web` | Queue, authorization rechecks, journal/report persistence and credential handoff |
| `aim-web-executor.service` | `svc_bf-ansible` | Fixed public `aimctl` calls and Core-owned native execution under that same UID |
The WebGUI installer can create the local `aim-web` system account and group when absent. It **does not create the Core executor account or change its persistent group memberships**. The executor must already exist, must not be root, and must differ from the web account. [W2]
Inspect the approved executor:
```bash
getent passwd svc_bf-ansible
id svc_bf-ansible
```
The passwd/NSS home must be a reviewed, usable account home. The installer will create or normalize its `.ansible` and `.ansible/tmp` directories to executor-owned `0700`, in addition to the separate `/var/lib/aim-web-executor` tree. Review pre-existing shared uses or symlinks before applying; do not use installation as a general home-directory repair procedure. [W2][W4]
Review Core's configured `required_group` and the account's active local/NSS memberships. Do not assume the example directory group in the Core installation guide or the local `aim-operators` group exists at every site. Provision authorization through the approved local/directory administration process, not through an add-on workaround. [C1][C2]
The account needs access to the configured Core launcher/runtime, customer inventory and encrypted Vault, required role payloads and collections, and Core's cooperating customer-lock paths. In customer-key mode, the canonical private key must be an owner-only `0600` file owned by the execution UID. A key filename matching the account name does not establish ownership. [C2][C3]
`service_user` is not a local UID switch. `runtime.private_key_owner` controls newly generated keys; it does not migrate an existing root-owned key or grant access. Existing-key migration is a separate, explicit Core/operator decision. Do not recursively change `/etc/ansible`, make private keys group-readable, or add the web account to root/operator groups to bypass this prerequisite. [C1, section 8][C2]
The managed executor preserves its native primary group and receives the `aim-web` group for this service only. A normal interactive `sudo -u svc_bf-ansible` session therefore need not be able to read WebGUI's `root:aim-web 0640` configuration. That is not a reason to make it world-readable. [W4]
## 3. Verify Core access as the executor
First check metadata through the installed public launcher:
```bash
sudo -u svc_bf-ansible \
/usr/local/bin/aimctl \
--config /etc/ansible/scripts/aim.yml \
capabilities
```
Then check a protected operation:
```bash
printf '%s\n' '{"api_version":"1.0","operation":"list_customers"}' \
| sudo -u svc_bf-ansible \
/usr/local/bin/aimctl \
--config /etc/ansible/scripts/aim.yml \
request
```
`capabilities` is unguarded metadata; success there does not prove authorization. The customer-list response must also succeed. An empty list can be a valid discovery result, but it is not a completed managed-host acceptance test. [C3]
The WebGUI integration expects the detailed progress, inventory hierarchy, target outcome, dual-home staging and operation-result contracts described by the current Core documentation. The installer probes live Core capabilities and protected customer discovery; do not edit a support snapshot or disable version checks to make an incompatible pair install. [C2][W2][W3]
### Confirm runtime and collection visibility
Use the executable configured in Core's `runtime.ansible_playbook`. For the separate environment shown in Core's fresh-install guide, the checks are:
```bash
sudo -u svc_bf-ansible \
/opt/ansible/venv/bin/ansible-playbook --version
sudo -u svc_bf-ansible \
/opt/ansible/venv/bin/ansible-galaxy collection list
/opt/ansible/venv/bin/ansible-galaxy collection list ansible.windows
```
If Core was accepted with `/usr/bin/ansible-playbook`, use its corresponding runtime instead; do not create a replacement just for WebGUI. Sibling Vault/Galaxy tools and collection discovery must match the approved native runtime. [C1, sections 4-5][C3]
For rc8, confirm the discovered `ansible.windows` version is at least 3.8.0 and below 4.0.0. Core advertises this floor in `capabilities.collection_baselines` and its readiness checks reject older versions before an affected playbook launches. Do not satisfy the check by installing a private collection visible only to root or to the WebGUI virtual environment.
The collection-install command in the Core guide uses `sudo`. Verify discovery under the executor as well as root. Collections available only in root's private home do not establish executor access. Use Core's documented `runtime.ansible_collections_path` when an approved shared/nonstandard collection location is needed; WebGUI does not install collections or expose root's home. [C1, section 5][C3]
These shell checks do not reproduce systemd restrictions. The installed executor's startup preflight and the real one-host test are still required.
## 4. Review HTTPS, SSH trust and the shipped site profile
### HTTPS and reverse proxy are prerequisites
The WebGUI deployer does not install Nginx, Nginx Proxy Manager, certificates, CA trust or DNS. It serves HTTP on its configured backend listener; the approved reverse proxy provides the browser's HTTPS endpoint. [W1][W2]
The bundled `deploy/webgui.example.toml` contains this **controller-specific** profile:
| Setting | Shipped value |
|---|---|
| Backend | `127.0.0.1:8080` |
| Browser origin | `https://aim.desq-gaming.de` |
| Immediate trusted proxy | `127.0.0.1` |
| Referenced upstream topology | NPM `192.168.20.3` -> verified HTTPS controller `192.168.20.46:8443` -> local Nginx -> loopback HTTP |
| WebGUI execution / credential entry | Enabled |
| Independent approval | Disabled |
| Eligible playbooks | The 14 explicit catalog keys in the shipped profile |
| Maximum requested hosts / execution timeout | 25 / 1,800 seconds |
| `transport_verified` | `true`, representing this site's operator attestation |
An allowlist does not establish that every playbook is executable or appropriate at the site. `transport_verified=true` does not provision or test TLS. Review the actual proxy path and trust before using these values. [W1][W5]
The `public_url` must be the exact browser origin: scheme, hostname and optional port, with no path or trailing slash. The trusted-proxy list identifies the immediate backend proxy, not the browser's remote address. Execution rejects wildcard proxy trust. [W5]
`deploy/nginx.example.conf` is a header reference, not an installable complete TLS server configuration. Preserve the browser Host and forwarded scheme through the approved proxy chain and verify streaming. WebGUI uses authenticated SSE, not WebSockets. No certificate setup or unverified replacement proxy configuration is supplied by this guide. [W1][W5][W9]
### Other sites: an explicit configuration gate
Do not treat the shipped domain, IP addresses or transport attestation as generic defaults.
The current deployer can select the Core launcher/configuration and account names, but **has no `--profile`, `--config-file` or public-origin override argument**. It replaces the whole installed TOML from its release template during install/update and restores the checkpoint's configuration on rollback. Editing the extracted template invalidates its manifest. [W2]
For repeatable release-managed deployment, obtain a reviewed release profile matching the new site. An operator can edit the installed `/etc/ansible/scripts/config/webgui.toml` before exposing it, but that is a **local override, not a durable site-profile mechanism**; the next managed update replaces it. Keep Core execution disabled and public ingress closed during any such adaptation. Preserve the managed state/socket paths and review account/path choices rather than copying a complete unrelated configuration.
If credentials or execution are disabled locally, disable `[credentials].enabled` before or together with `[execution].enabled`. The configuration validator does not allow credential entry without enabled execution and verified HTTPS. Run `sudo aim-web config-check` after editing; it validates settings, not certificates. Configuration is loaded by the service processes: apply edits in a quiesced window and restart the appropriate services before exposing the site. Leave the worker stopped/disabled when execution is intentionally disabled. [W5]
### SSH host trust is a separate prerequisite
For Linux/SSH targets, the executor needs independently verified host trust for the effective connection destination. The inventory's logical hostname may differ from the actual `ansible_host` address.
The existing approved controller-wide `/etc/ssh/ssh_known_hosts` arrangement can be retained. WebGUI does not enroll keys, overwrite that file or automatically copy root's personal trust decisions. Do not infer OpenSSH's effective user known-hosts path solely from the executor's substituted `HOME`. Review the effective SSH configuration and account context instead. [W4]
Windows WinRM connectivity, credentials, transport dependencies and certificate policy remain the independently accepted Core/operator configuration. Do not disable TLS or SSH verification to pass add-on acceptance. [C1][C3]
## 5. Obtain and verify the WebGUI release
Transfer both files through a trusted channel:
```text
AIM-WebGUI-2.1.0rc9.zip
AIM-WebGUI-2.1.0rc9.zip.sha256
```
Stage outside the active add-on and Core source directories:
```bash
cd /var/tmp
sha256sum -c AIM-WebGUI-2.1.0rc9.zip.sha256
unzip AIM-WebGUI-2.1.0rc9.zip
cd aim-web-2.1.0rc9
sha256sum -c MANIFEST.sha256
```
The expected ZIP SHA-256 is:
```text
bf0a6977d2672477bcb48c78b49085ff79e70195e780813b934d87ad334d7556
```
Use a new extraction directory, not a directory containing an older release. Never extract over `/etc/ansible/scripts/addons/webgui` or `/opt/aim-web/current`. A checksum verifies integrity, not publisher identity. The deployer additionally verifies manifest hashes, safe paths and required-file coverage. [W1][W2]
Do not edit manifest-covered files, remove integrity checks or regenerate a manifest just to bypass a deployment error. This documentation companion is not an updated release ZIP and should be kept outside the verified extraction.
## 6. Prepare WebGUI dependencies and inspect the deployer
The controller must provide Linux/systemd, Python 3.11+ with `venv` support and administrative permission to perform installation. Inspect the local Python before running the installer:
```bash
python3 --version
python3 -m venv --help
python3 deploy/deploy.py --help
```
The WebGUI deployer creates its **own versioned virtual environment** and installs the declared WebGUI/build dependencies using the supplied `constraints.txt`. It does not run pip in AIM's environment, install Ansible, or install the test dependency extra. There is no need to pre-install FastAPI or pytest globally. [W2]
If the deployment interpreter is not the Python intended for WebGUI, use the supported `--python /absolute/path/to/python3` option. Do not point it at an interpreter with an unsupported version. Separate environments can use the same Python installation without sharing package environments.
The package is not a complete offline dependency bundle. A new installation needs approved package/asset access or prepared offline artifacts. Browser assets are the exact pinned Bootstrap 5.3.8 CSS and HTMX 2.0.10 JavaScript; the installer verifies their SHA-384 pins and serves them locally. Do not substitute fixture assets or a newer version. [W1][W8]
For an offline installation, prepare wheels compatible with the chosen Python/OS/architecture, including constrained build dependencies, and an asset directory containing:
```text
bootstrap.min.css
htmx.min.js
```
Pass the existing `--wheelhouse` and `--assets-dir` options in section 7. The detailed wheelhouse preparation depends on the operator's build environment; the supplied guide does not establish a universal offline-build recipe. [W1][W2]
### Core and WebGUI deployers have different commands
| Operation | Core guide | WebGUI 2.1.0rc9 |
|---|---|---|
| Preview | `install --dry-run` | No equivalent dry-run mode |
| Fresh apply | `install --apply --quiesced` | `install` performs installation |
| Existing add-on update | Not applicable | `update` |
| Rollback selector | `--from-backup /path` | `--backup SNAPSHOT_ID` |
Do **not** append Core's `--dry-run`, `--apply`, `--quiesced` or `--aim-python` options to the WebGUI deployer. Review its help, profile and source/documentation before applying. Its internal checks are not a whole-system no-write preview: preparatory add-on directories, dependencies or the web account may be created before a later failure. [C1][W2]
An existing unrecognized installation, unrelated CLI link or unmanaged systemd drop-in stops deployment. Review and back up such files; do not delete overrides or disable guard checks blindly.
## 7. Apply a fresh add-on installation
Proceed only after the preceding Core, identity, profile and transport gates are satisfied. Do not run a Core source update or change its inventory/configuration concurrently.
From the verified fresh extraction:
```bash
sudo python3 deploy/deploy.py install \
--aim-scripts /etc/ansible/scripts \
--service-user aim-web \
--executor-user svc_bf-ansible \
--aimctl /usr/local/bin/aimctl \
--core-config /etc/ansible/scripts/aim.yml
```
These options make the default binding explicit. The Core account and launcher must already exist. [W2]
For the same installation using prepared offline artifacts, add:
```bash
sudo python3 deploy/deploy.py install \
--aim-scripts /etc/ansible/scripts \
--service-user aim-web \
--executor-user svc_bf-ansible \
--aimctl /usr/local/bin/aimctl \
--core-config /etc/ansible/scripts/aim.yml \
--wheelhouse /secure/wheelhouse \
--assets-dir /secure/aim-assets
```
The successful fresh-install sequence: [W2]
1. Verify the release, stage dependencies/assets and probe public Core metadata as the executor.
2. Create the web account if needed; create private WebGUI state and a protected deployment checkpoint.
3. Install the reviewed TOML and provision the managed executor staging directories.
4. Initialize schema 5 and the initial local administrator without resetting existing initialized accounts.
5. Activate the full source/virtualenv and the `aim-web` launcher; write the three managed service units.
6. Run executor startup checks, wait for its correctly permissioned socket, start HTTP and its local readiness check, and start the worker when the WebGUI execution setting is enabled.
The deployer does not change Core's `addons.execution_enabled`, Core source, customer keys/Vaults, persistent executor group memberships, SSH trust or proxy certificates. The shipped profile starts the queue worker even when Core's independent execution opt-in is still false; that does not bypass the Core gate.
Record the printed add-on source, configuration, checkpoint identifier and initial-credential location. They are needed for verification and recovery.
### Nondefault layouts
`--aim-scripts`, `--aimctl` and `--core-config` can bind the add-on to reviewed locations. They do not create an independent second WebGUI instance: `/opt/aim-web`, state paths, service names and the executor socket are fixed in this managed profile. The executor's inventory write exception also names `/etc/ansible/inventories`. A different inventory root or parallel installation needs a reviewed deployment profile; changing the CLI paths alone is insufficient. [W2]
## 8. Verify the installed services and permissions
Refresh command discovery:
```bash
hash -r
command -v aim-web
aim-web --version
sudo aim-web config-check
```
Expected version:
```text
AIM WebGUI 2.1.0rc9 (AIM compatibility: 3.3.0rc8 / service API 1.0)
```
The installer supplies `/usr/local/bin/aim-web -> /opt/aim-web/current/bin/aim-web`; do not manually copy a versioned launcher over it. `config-check` validates TOML only and can run as root without opening the workflow database. [W2][W6]
Check services and the actual startup preflight:
```bash
sudo systemctl status \
aim-web-executor.service \
aim-web.service \
aim-web-worker.service \
--no-pager
sudo journalctl -u aim-web-executor.service -n 80 --no-pager
systemctl show aim-web-executor.service \
-p User -p Group -p SupplementaryGroups
```
The intended executor identity is:
```ini
User=svc_bf-ansible
Group=svc_bf-ansible
SupplementaryGroups=aim-web
```
Numeric group IDs in `systemctl show` are acceptable when they resolve to the intended groups. Systemd may also initialize other account memberships; an empty explicit `SupplementaryGroups` field is not proof that the process has no supplementary groups. [W4]
Inspect the managed paths, substituting the actual passwd/NSS home if different:
```bash
sudo stat -c '%U:%G %a %n' \
/var/lib/aim/webgui \
/var/lib/aim/webgui/webgui.sqlite3 \
/var/lib/aim-web-executor \
/var/lib/aim-web-executor/.ansible \
/var/lib/aim-web-executor/.ansible/tmp \
/home/svc_bf-ansible/.ansible \
/home/svc_bf-ansible/.ansible/tmp \
/run/aim-web-executor \
/run/aim-web-executor/core.sock \
/etc/ansible/scripts/config/webgui.toml
```
| Resource | Expected owner/group | Mode |
|---|---|---|
| WebGUI state directory | `aim-web:aim-web` | `0700` |
| Workflow database | `aim-web:aim-web` | `0600` |
| Executor state and process-home `.ansible/tmp` hierarchy | Executor:native primary group | `0700` |
| Executor passwd-home `.ansible` and `.ansible/tmp` | Executor:native primary group | `0700` |
| `/run/aim-web-executor` | Executor:native primary group | `0711` |
| `core.sock` | Executor:`aim-web` | `0660` |
| `webgui.toml` | `root:aim-web` | `0640` |
| Managed unit files | `root:root` | `0644` |
The runtime-directory traverse permission does not grant access to the private executor state or database. Socket permissions and peer checks govern IPC. These are checks of installer-managed state, not a recursive repair recipe. [W2][W4]
### Both staging homes matter
The executor sets `HOME=/var/lib/aim-web-executor`, but delegated local Ansible tasks can expand `~svc_bf-ansible` through the account database. The installer provisions both applicable staging paths and only their narrow write exceptions while retaining `ProtectHome=read-only`, `ProtectSystem=strict`, `NoNewPrivileges=true` and empty capabilities. [C1, section 11][W4]
`ExecStartPre` runs `aim-web core-staging-check` inside the actual executor unit. Successful journal output followed by service startup is the relevant staging evidence. A plain interactive `sudo -u svc_bf-ansible aim-web core-staging-check` can fail to read the web-group configuration because it does not reproduce the unit's group context. A root invocation tests the wrong key/staging identity. Do not weaken file permissions to make those commands pass. [W4][W6]
## 9. Open the HTTPS site and initialize administration
Verify the approved proxy endpoint serves the correct installation with trusted HTTPS. For the shipped profile, the browser origin is:
```text
https://aim.desq-gaming.de
```
Do not use a raw LAN HTTP address as the browser origin or use disabled certificate validation as acceptance. No operational secret should be entered before transport has been accepted.
Read the generated bootstrap credential file **locally**:
```bash
sudo cat /var/lib/aim/webgui/.credentials
```
The fresh administrator username is `admin`. The JSON file contains the generated password and is protected as service-owned private state; it is not served over HTTP. Do not paste it into tickets, chat, deployment logs or this documentation. [W6][W7]
Sign in and perform the mandatory first password change. The application revokes the sessions for that account and invalidates/removes the bootstrap credential file after the password change. Sign in again with the new password as prompted. A later update or `init` command does not regenerate bootstrap credentials or reset an initialized administrator. [W7]
Use User administration to establish named accounts and reviewed execution grants. An eligible administrator or a matching customer/playbook grant is required in addition to the configured allowlist and Core authorization. Job/history/report visibility is owner-or-admin; supplying one-run credentials remains requester-only. [W7][W9]
Do not enable independent approval without providing a suitable separate approver. The shipped single-administrator site profile has `require_approval=false`; that does not bypass review of targets, mode, options or Core revisions.
## 10. Accept local readiness, then enable Core execution deliberately
Through the running add-on's executor:
```bash
sudo -u aim-web \
/opt/aim-web/current/bin/aim-web \
--config /etc/ansible/scripts/config/webgui.toml \
core-check
```
Confirm Core product version, live capabilities and authorized customer discovery. An `execution.enabled=false` value at this point is the expected independent Core gate. [C3][W6]
For one existing approved Linux host with customer-key mode, replace the two literal identifiers before running:
```bash
sudo -u aim-web \
/opt/aim-web/current/bin/aim-web \
--config /etc/ansible/scripts/config/webgui.toml \
core-check \
--customer CUSTOMER \
--playbook debug_test_connection \
--host HOST \
--key-mode customer
```
For a Windows/native-inventory test use `--key-mode none`. This is not forced password authentication. The CLI readiness request uses check mode and performs local preparation/runtime checks; it does not submit a job, unlock Vault, verify remote credentials or prove remote connectivity. [C3][W6]
Once Core terminal, transport, executor identity and local staging/readiness have been accepted, edit **Core's existing** configuration:
```bash
sudoedit /etc/ansible/scripts/aim.yml
```
Merge the approved opt-in, without creating a duplicate `addons` block:
```yaml
addons:
execution_enabled: true
```
Leave Core's accepted `runtime.ansible_playbook`, collection path, key-owner and other controller settings intact. Re-run `aim-web core-check` as `aim-web` and confirm that effective Core execution is now enabled. [C1, section 13][C3]
The gates remain separate:
```text
Core addons.execution_enabled
+ WebGUI execution/credential/transport policy
+ eligible playbook and host limit
+ enabled account/grant and any required approval
+ fresh reviewed Core revision
+ worker readiness and required one-run credentials
= an eligible execution request
```
No single `true` setting proves successful or safe managed-host execution.
## 11. Run the first controlled end-to-end test
Use a small approved scope. For a reporting acceptance test, **Detected host roles** is the Core guide's suggested starting operation. [C1, section 14][C4]
In the browser:
```text
New run
-> Select the test customer and Detected host roles
-> Select one approved host
-> Review options, Apply/Check mode and key handling
-> Review the normalized scope, warnings and report contract
-> Confirm and Run once
-> Unlock this run when the worker is ready
```
A saved plan is optional. For an encrypted Linux customer key, choose customer-key loading; the credential modal can use the Vault-stored passphrase where Core permits it. Windows/native-inventory runs do not need a Linux customer key. Only fields requested by Core are offered. A supplied connection password is a native default, not an override of inventory credentials. [C3][W9]
The modal's acceptance message means the handoff was accepted, not that a password was verified. The empty reservation lasts five minutes, followed by the existing bounded handoff/start interval. Each new attempt needs fresh credentials; never put passwords in CLI arguments, URLs, plans, config examples or a queued record. [W9]
Check all three result dimensions:
| Dimension | What to inspect |
|---|---|
| Core verdict | Status, stage, native exit and whether remote work may have started |
| Target outcomes | Each reviewed host's authoritative final outcome and counters |
| Operation report | Schema, availability, retained content and Apply/Check meaning |
The role report should expose `host_capabilities_v1` with the eight declared booleans. A missing report is not eight false values. A successful native exit can still produce `failed / result_validation` when required output is missing or invalid. Do not rerun automatically: remote changes may already be complete. [C3][C4][W9]
For a longer approved run, close/reopen the job, reload and check from another authorized device. Confirm recorded progress survives and new events continue without replaying execution or requesting the same credentials again. Validate Reports after finalization and verify the host activity link. No total-task percentage or ETA is promised.
After the first success, use Core's `SANITY.md` and WebGUI's `docs/CONTROLLER-PILOT.md` for separately approved Windows/Linux, mixed-result, cancellation, recovery and reporting cases. Updating packages, rebooting, starting services, exporting logs or deleting files needs its own test scope. One successful role query does not qualify every playbook. [C4][W10]
## 12. Review retention and storage capacity
Schema 5 stores accounts/workflows plus job-linked progress and operation reports. The shipped limits are: [W1][W9]
```toml
[journal]
max_events = 20000
max_bytes = 8388608
[reports]
max_bytes = 16777216
retain_configuration = false
```
The progress limit retains a bounded tail, not a complete raw terminal transcript. Omissions and capture gaps are disclosed. Report limits also remain subject to Core/catalog caps. Full parsed Checkmk configuration sections are not retained by default; availability/file/redaction metadata remain with a metadata-only label. Enabling full retention is a separate operator data decision and applies to future captured reports.
Deleting a job removes its journal and report rows and its contribution to the displayed host history. A compact audit deletion event remains. Existing backups have their own retention policy; deleting a job is not physical erasure of backups or storage remnants. Only WebGUI-owned retained runs contribute to Host Activity and Insights. [W9]
Allow space for the database, journals/reports, protected backups and multiple versioned virtual environments. The sources do not specify a universal minimum disk or memory size; capacity depends on retained jobs and report sizes. Monitor it rather than treating per-job limits as a total database cap.
The full `webgui.toml` is release-managed. Local policy edits are overwritten on a later managed update unless incorporated into the next reviewed release profile. [W1]
## 13. Know the managed layout and ownership boundary
```text
/usr/local/bin/aim-web -> /opt/aim-web/current/bin/aim-web
/opt/aim-web/current -> selected versioned virtual environment
/opt/aim-web/venvs/ versioned add-on environments
/opt/aim-web/deployment.json active deployment metadata
/opt/aim-web/pending.json interrupted/in-progress deployment journal
/etc/ansible/scripts/addons/webgui/ managed add-on source
/etc/ansible/scripts/config/webgui.toml managed add-on configuration
/var/lib/aim/webgui/ private accounts, workflows and evidence DB
/var/lib/aim-web-executor/ private executor process HOME/staging
/run/aim-web-executor/core.sock private group-accessible Core bridge
/var/backups/aim-web/ protected add-on deployment checkpoints
```
The passwd-home `.ansible` staging directories and the three systemd units are also release-managed as described above. An existing account-home `.ansible` parent is normalized to the managed owner-only policy; review shared/custom uses of that directory before installation. The whole home is not made writable inside the service. [W2][W4]
Core source, `aim.yml`, inventories, Vaults, canonical keys, collection installation, global SSH trust and certificates remain outside the add-on installer. Core and add-on recovery data are different directories with different deployer commands. [C1][W1]
### Patch-wave behavior after Core 3.3.0rc8
The public catalog adds the Windows-only Continue patching after reboot option (catalog hint false), plus reboot message/delay. Blank controls inherit native inventory/role policy; the add-on never forces continuation on. A successful Windows wave can require another reviewed run. See [Patch waves](docs/PATCH-WAVES.md) before testing update/reboot behavior. Reports show pending/unknown, reboot-deferred and fixed per-update failure metadata without parsing raw output or creating follow-up jobs. Historical patch reports retain their recorded schema.
## 14. Back up the add-on independently
The deployer creates checkpoints during install/update. Maintain an operator backup policy in addition to those checkpoints, especially now that the database contains retained reports. Keep Core runtime/customer data backups separate. [W1]
The supported database backup command uses SQLite's backup API. Run it as the database-owning web identity, with a **new, nonexisting** destination it can write:
```bash
BACKUP_FILE="/var/lib/aim/webgui/manual-backup-$(date -u +%Y%m%dT%H%M%SZ).sqlite3"
sudo -u aim-web \
/opt/aim-web/current/bin/aim-web \
--config /etc/ansible/scripts/config/webgui.toml \
db backup "$BACKUP_FILE"
```
The backup is created as `0600`. Transfer it through the approved protected backup process, preserving confidentiality, and record the matching Core/WebGUI versions and configuration. Database contents include account hashes, session/workflow data and retained operational reports. A database backup alone is not the deployer's full source/configuration recovery checkpoint. [W6][W11]
Do not use an uncoordinated live copy of `webgui.sqlite3` as a replacement for the SQLite backup operation. Do not install pytest into a production environment to perform an installation check.
## 15. Updating an existing add-on
For an existing compatible managed installation, use the **new release's** fresh extraction:
```bash
cd /var/tmp/aim-web-2.1.0rc9
sudo python3 deploy/deploy.py update
```
Use this exact command only when moving from an earlier version to 2.1.0rc9; it is not a same-version refresh. Preserve the previously selected `--aim-scripts` and account options when they differ from defaults. [W2]
When moving to Core 3.3.0rc8 from the older supported Core, finish/cancel work deliberately, stop submissions and quiesce terminal writers. Stop the add-on services before independently updating Core:
```bash
sudo systemctl stop \
aim-web-worker.service \
aim-web.service \
aim-web-executor.service
```
Follow Core's own preview/apply procedure, then deploy the compatible WebGUI release. Do not expect the older adapter to accept a newly installed Core automatically. [C1][W1]
From WebGUI 2.1.0rc3, this update keeps SQLite schema 5: no new database migration or historical report rewrite. From a schema-4 baseline, the existing schema-5 migration remains (pending/queued work blocked, running work interrupted, stale reviews removed). Source/schema changes still require fresh review; do not replay an old prepared request. [W1]
Ordinary 2.x updates do not need `--migrate-core`. That flag is reserved for the documented legacy 1.x integration/state migration and recognized key-export/capability retirement. Read `docs/MIGRATION-3.1.md` before using it. Never treat it as a general permission or version-check bypass.
## 16. Recover without changing Core accidentally
### Failed or interrupted installation/update
Record the complete deployment error and printed checkpoint identifier. The deployer attempts recovery where a checkpoint exists; an incomplete recovery leaves `pending.json` to prevent ambiguous repeated installation. Keep services stopped until the failure and recovery state are understood. [W1][W2]
For a recorded interrupted deployment, use the same release's extracted deployer:
```bash
cd /var/tmp/aim-web-2.1.0rc9
sudo python3 deploy/deploy.py recover
```
`recover` is not a generic repair command: it requires the pending deployment journal. Do not delete that journal or switch the `current` symlink manually to bypass recovery.
A failed first install preserves private state for deliberate recovery/retry; it does not automatically wipe accounts or generate another admin. A completed first-install checkpoint is not a prior installed version, and the rollback command rejects it. Inspect and preserve state instead of removing the database to force bootstrap. [W2][W7]
### Roll back to a previous managed add-on
After independently backing up current state and quiescing work, use the exact checkpoint **identifier**, not Core's `--from-backup` syntax:
```bash
sudo python3 deploy/deploy.py rollback \
--backup CHECKPOINT_ID
```
If the selected previous release requires schema 4, crossing back from schema 5 requires an explicit historical database restore:
```bash
sudo python3 deploy/deploy.py rollback \
--backup CHECKPOINT_ID \
--restore-auth-db
```
That can restore old password hashes and discard newer accounts, plans, jobs, journals and reports. Restored sessions are revoked. The backup ID must be one actually printed by this installation. [W1][W2]
The restored adapter is checked against the installed Core. If incompatible, restored add-on services remain stopped and disabled. Core rollback is separate; after validating a matched Core/WebGUI/database combination, explicitly enable/start the intended services. Neither rollback reverses completed remote changes, dependency installation or independent account/trust decisions.
### Disable the add-on
After active work is finished or deliberately canceled:
```bash
sudo systemctl disable --now \
aim-web-worker.service \
aim-web.service \
aim-web-executor.service
```
This disables the three add-on units, not AIM terminal. It is not a data purge. There is no `uninstall` command in this deployer; retain private state and backups until a separately approved removal decision. [W2]
## 17. Troubleshooting at the correct boundary
| Symptom | First check / action |
|---|---|
| `No managed add-on installation exists` | Use `install` for a genuinely new add-on, not `update`. |
| Missing manifest, coverage or hash error | Re-verify the published ZIP and use a fresh extraction. Do not disable the verifier. |
| Unmanaged unit drop-in blocks deployment | Inspect the exact file and preserve it for review; do not blindly delete or reinstate older capability overrides. |
| Root command reports unsafe database/staging ownership | Use the correct service identity or real executor startup context. Do not transfer service state to root. |
| Manual executor command gives `PermissionError` on `webgui.toml`, but startup passed | The plain shell may lack the unit-scoped `aim-web` group. Inspect service journal and active identity. |
| Web config says execution enabled, Core says disabled | Review Core's independent `addons.execution_enabled` opt-in. |
| Missing collections or wrong native version | Fix the separately approved Core runtime/discovery as executor; do not pip-install Ansible into WebGUI. |
| Customer key rejected | Inspect the selected canonical key's owner, `0600` mode and traversal as executor. `private_key_owner` does not migrate existing keys. |
| Delegated controller task becomes `UNREACHABLE` | Inspect actual task/delegation and both staging paths inside the sandbox before blaming WinRM. |
| Linux trust failure despite root terminal success | Review effective executor SSH trust and actual destination; retain the approved global trust file. |
| Browser origin/security-token rejection | Check the exact HTTPS origin, immediate proxy/header chain, current page/session and clock. Do not disable CSRF. |
| Queued job never asks for credentials | Check worker activity, Core execution policy, approval/schedule and current job state. Only its requester gets the modal. |
| Native exit 0 but failed at `result_validation` | Inspect report availability/schema. Remote work may have completed; no automatic replay. |
| Empty old timeline/report | Pre-capture history is not reconstructed. Check the job's version and capture/retention markers. |
| `pending.json` blocks another deployment | Investigate and use the same deployer's recorded recovery path. |
Start with version/status/configuration checks, the specific job's structured facts and the relevant service journal. Do not share passwords, full inventories/Vault dumps or unrestricted configuration/report bodies in diagnostics. Local Ansible/module logging may contain operational details even though the WebGUI journal excludes raw output. [C4][W1][W9]
## Fresh add-on completion checklist
Before broader operational use confirm:
- Core's prerequisite installation and controlled terminal acceptance are complete.
- The WebGUI ZIP checksum and release manifest pass, and the current profile fits the site.
- The existing executor is independently authorized and can discover the intended Core runtime/collections.
- HTTPS/proxy trust is accepted; no placeholder origin or unverified transport attestation was reused.
- Fresh installation used `install`, or a real managed upgrade used `update`.
- All three expected services start, and executor startup checks pass inside their actual sandbox.
- Account/group, socket, configuration, state and dual-home staging match the managed policy.
- The bootstrap admin password has been changed and appropriate named accounts/grants reviewed.
- Core add-on execution was enabled only through the separate operator opt-in.
- A small approved end-to-end run has accepted credentials, native outcomes and its declared report.
- Reload/second-device progress and finished-job evidence behave correctly without replaying work.
- Report retention, protected backups, storage growth and recovery checkpoints have been reviewed.
- Candidate acceptance is recorded by actual UID/sandbox/runtime/platform; unsupported or untested cases are not claimed as passed.
- Terminal AIM remains independently usable.
## Related documentation and source basis
This companion preserves the prerequisite boundary and terminology of the supplied Core guide. Add-on-specific commands and paths were checked against the published WebGUI archive. Core's installation guide has not been edited. This companion is updated with the add-on release; it is not a claim of a performed controller installation.
| Reference | Source | What it governs |
|---|---|---|
| C1 | Core `scripts/docs/INSTALLATION.md`, AIM 3.3.0rc8 | Prerequisite Core installation, independent environments, opt-in, terminal acceptance and Core recovery |
| C2 | Core `ADDON_AGENTS.md` | Same-UID public integration, ownership and hardening boundaries |
| C3 | Core `scripts/docs/ADDON_API.md` | Authorization, discovery, readiness, request/credential/result semantics |
| C4 | Core `scripts/docs/SANITY.md`, `VALIDATION.md`, `OPERATION_RESULTS.md` | Controller acceptance, candidate evidence and report rules |
| W1 | WebGUI `docs/DEPLOYMENT.md` | Coordinated migration, retention, backups and rollback |
| W2 | WebGUI `deploy/deploy.py`, `pyproject.toml`, `constraints.txt` | Actual supported installer flags, initialization, generated units, dependency handling and fixed paths |
| W3 | WebGUI `adapters/core_v1.py`, `core/executor.py` under `src/aim_webgui/` | Qualified public Core compatibility and executor boundary |
| W4 | WebGUI `docs/PERMISSIONS-ROLLOUT.md`, `AGENTS.md` | Resource ownership, split-home staging and known-hosts cautions |
| W5 | WebGUI `deploy/webgui.example.toml`, `deploy/nginx.example.conf`, `src/aim_webgui/config.py` | Actual shipped site profile, reference headers and configuration validation |
| W6 | WebGUI `src/aim_webgui/cli.py` | Version/configuration/readiness, backup and administration commands |
| W7 | WebGUI `src/aim_webgui/auth/service.py`, `workflows.py` | Bootstrap, first password change, account/grant and job authorization |
| W8 | WebGUI `deploy/fetch_assets.py` | Exact browser-asset pins and offline input names |
| W9 | WebGUI `docs/CREDENTIALS.md`, `RUN-COMFORT.md`, `EXECUTION.md`, `JOURNAL.md`, `REPORTS.md` | Credential modal, execution lifecycle and retained-evidence behavior |
| W10 | WebGUI `docs/CONTROLLER-PILOT.md`, `docs/VERIFICATION.md` | Operator tests and recorded qualification limits |
| W11 | WebGUI `src/aim_webgui/db/store.py` | Database ownership and consistent backup behavior |
Core topic files live in the independently installed Core tree. WebGUI topic/source paths above are relative to the verified `aim-web-2.1.0rc9` extraction unless stated otherwise. The current Core archive contains `scripts/docs/INSTALLATION.md`. Read that separately installed prerequisite; no Core source or runtime is copied into this add-on.
**Evidence limit:** this document was produced from the supplied guides and inspected release code. It does not add new installation, dependency-security, proxy, systemd or managed-host test evidence. Use the two products' current validation records and complete the controller acceptance steps for the actual deployment.
+97
View File
@@ -0,0 +1,97 @@
# AIM WebGUI global agent standards - 2.1.0rc9
Read this file before changing application, tests, deployment or documentation. Update it in the same release whenever a global convention or security boundary changes.
## Product boundary and compatibility
AIM Core is independently installed and managed. This candidate targets supplied Core **3.3.0rc8**, service/wire/event API **1.0**, `play_task_host_v1`, `inventory_hierarchy_v1`, `target_outcome_summary_v1`, and `native_defaults_preflight_v2`. WebGUI HTTP remains **v2** and SQLite remains **schema 5**. Do not call this production-qualified merely because source tests pass.
Core rc8 additionally advertises `collection_baselines.ansible.windows` as `>=3.8.0,<4.0.0`. Require that public capability declaration, but do not install, upgrade or privately discover collections from WebGUI; Core readiness and operator acceptance own the actual collection environment.
Read Core's ADDON_AGENTS.md, ADDON_API.md, ADDON_SUPPORT.md and RELEASE_HANDOFF.md. Use only documented fixed `aimctl` JSONL operations via the existing adapter/client. Never import private AIM/Ansible modules, parse Core inventories locally, rewrite Ansible argv, vendor Core, or modify its source/configuration/runtime/roles/Vaults/keys. Additive unknown response fields may be ignored; unsupported capabilities/errors must not become invented successes. Removing or breaking the add-on must not prevent terminal AIM from working.
The executor is an add-on-owned, pre-started non-root same-UID Core caller. Web and queue use aim-web; the executor uses the independently provisioned AIM execution identity. No sudo/setuid/key-export bridge, HTTP-started Ansible command or arbitrary command/path/actor/environment endpoint. Private Unix IPC uses peer checks. Shared local service identities are trusted controller actors, not hostile-tenant isolation.
## Execution, credentials and preserved workflows
A run needs fresh review, not a saved plan. Keep bounded private expiring reviews, explicit targets, frozen scope/options/mode/key handling/revision and current grant/approval checks at dispatch. One-run submissions are idempotent; saved names are optional with transactional NFKC/casefold/strip collision prevention, never upsert by title. Existing plans/jobs/accounts/audit survive normal releases.
Use Core's reported credential requirements and native inventory precedence. No forced Custom/password-only overrides, become-password UI, private-key uploads, prompt automation, credential guessing or secret cache. Secret collection stays authenticated, CSRF/origin-protected HTTPS POST with bounded bodies; credentials travel in a separate private FD, not request JSON, argv, environment, database, logs or files. Preserve literal UTF-8 and existing one-run deadlines. Python cleanup is not physical memory erasure.
Final Core status/exit/counters and target facts remain authoritative. Partially succeeded is a presentation label, not a rewritten Core result or database queue state. Never derive final host success from SSE or task counts. Missing response, cancellation and interruption may leave work unknown; cancellation is not rollback. Manual retry creates a new reviewed job and fresh secrets. Running jobs cannot be deleted. Deletion removes job/lifecycle/idempotency records while retaining a compact audit event, not a hidden transcript archive.
Only documented static labels, logical host names, fixed hints and numeric detail progress are rendered. Do not recover raw module output, decrypted variables or unsafe diagnostics. A bounded allowlisted structured journal is now retained with the job; rendered console strings and raw streams remain prohibited. Final report bodies are separate, declared and validated.
## Read-only experience: explicit data scope
Inventory Explorer consumes the current Core hierarchy and separate host metadata. All new explorer/activity/insights routes are GET-only and must never call prepare/execute, collect secrets or probe managed hosts. Current inventory is not persisted as a competing database. Group identities are full paths; direct/root membership and repeated host membership are preserved. Recursive counts are distinct hosts, not sum-of-appearance counts. The map is membership, not topology or live health.
Host Activity and Playbook Insights use ONLY retained **AIM WebGUI jobs** and final Core per-target facts already stored with those jobs. Do not collect terminal AIM execution history, external/core-wide history, audit-derived reconstructed results or a separate shadow archive. Use a customer plus exact logical hostname identity; never silently merge renamed/recreated machines.
History queries must use the same owner-or-admin visibility as job detail BEFORE aggregation. Saved plans remain owner-only, including for administrators. Inventory visibility does not confer access to another viewer's job history. Aggregate counts, filter choices, matrix cells and links must not leak hidden jobs. Preserve existing read policy rather than inventing tenant isolation.
Stream all matching retained job rows rather than reuse the latest-100 Jobs overview. Page displayed records and bound graph/matrix output; define time range, modes and coverage. Count one requested host participation per job. Apply is the default; Check is explicit and never evidence of installed changes. Success percentage = successful / (successful + failed + unreachable), with denominator visible; not-started/indeterminate/unavailable/outstanding are separate. Changed values count tasks; any derived metric must say so. No per-host duration from job elapsed times. Deleted jobs disappear from statistics; incomplete/legacy target data remains unavailable, never guessed successful.
A Core outage is not empty inventory or an offline host. Authorized retained history remains available when current inventory cannot be retrieved. Historical outcomes include timestamps and do not claim current health/compliance/software versions. No graph click launches a job.
## UI and mobile conventions
Use Jinja2, HTMX and small self-hosted JavaScript with Bootstrap 5. Keep centralized orange accents, charcoal/slate dark surfaces, sun/moon controls, semantic status chips, visible focus, keyboard navigation and reduced-motion behavior. No persistent special orange outline around Jobs/Saved plans. Non-sensitive theme preference may use browser storage; inventory/history/credentials must not.
Desktop above 760px retains the sidebar. Mobile uses a single compact header: **AIM home link left; light/dark controls then hamburger at the far right, vertically aligned**. The native details/summary menu opens below/right with viewport-bounded internal scrolling. Remove horizontal swipe rails, arrow controls and swipe instructions. Keep Escape/focus return, outside-click close, native keyboard/no-JavaScript opening, and account/logout controls reachable. Do not use ARIA menu roles for ordinary site navigation.
Inventory uses a deterministic linked SVG map and equivalent outline. Phone defaults to branch-focused outline; Map remains available with explicit zoom controls and internal scrolling. Breadcrumbs replace unbounded indentation. Host links open a real activity page. Never require hover, drag or pinch to obtain essential information. Direct host/group pages are bounded, searchable and paged.
Use mobile cards for historical results/matrices. Prevent page-level horizontal overflow. Group selectors retain two columns on narrow screens and aligned selection columns; explicit submitted hostnames remain authoritative. Live output autoscroll changes the console scrollTop, never the window; manual upward scrolling pauses follow. Filters, text labels and errors remain legible at 320px and short landscape heights.
Store/transport UTC, render semantic time[datetime] using browser locale, retain inspectable UTC; reformat initial load, HTMX replacements and pageshow. Schedule input stays explicitly UTC unless a separately tested conversion change is approved.
## Permissions and deployment
Preserve rc8's tested intent: executor primary group is its native account group; aim-web is unit-scoped supplementary group. Config root:aim-web0640; private web state aim-web0700; executor state and both process-home/passwd-home temp directories executor0700; runtime directory executor0711; socket executor:aim-web0660. Keep ProtectHome=read-only, narrow temp write exceptions, NoNewPrivileges and empty capabilities. Wait boundedly for socket readiness.
Core authorization groups, inventory/Vault/private-key ownership, system SSH trust, certificates and proxies remain operator/Core managed. Do not repair them from HTTP. Do not infer OpenSSH known_hosts from HOME: effective SSH configuration and passwd expansion are authoritative. No recursive chmod/chown of /etc/ansible.
Whole-release replacements, independent immutable versions, no Git requirement. Overwrite release-managed WebGUI config as requested; preserve private state. Stage dependencies and public Core metadata before downtime; backup matching source/config/units/DB and handle rollback explicitly. Unknown unit overrides require review. Do not modify either production Python environment for tests. Existing 1.x migration needs --migrate-core; ordinary 2.x updates do not.
## Tests and evidence
Test authentication, request boundaries, Core contract/FD transport, different-UID executor, migrations, existing workflow guards, read-only authorization/aggregates/deletion, >100 retained jobs, repeated membership, same hostnames across customers, outages/empty states, and escaped output. Browser QA covers mobile hamburger/alignment/focus, graph links/zoom, cards, themes, short heights, local timestamps and internal console scrolling. Distinguish real Core metadata from fake native execution and fixture browser assets.
Verify the actual final extracted ZIP through deploy.verify_release, canonical manifest paths and required-file coverage, compile/templates/JS, wheel resources and pristine Core hashes. Never claim skips as passes, fake native tests as SSH/WinRM qualification, fixture events as live HTMX/SSE, or static unit tests as systemd deployment. Keep VERIFICATION.md and machine-readable results aligned with observed evidence.
## Credential dialog and attention - 2.1.0rc9
Use a progressively enhanced native dialog for deliberate owner-initiated credential entry; keep the authenticated full-page form as the no-JavaScript/unsupported-dialog fallback. Use Bootstrap 5 native radio/label button groups, never Bootstrap 4 JavaScript or jQuery. A key-passphrase source choice is allowed only for the exact Core requirement `ssh_key_passphrase_or_customer_vault_value` alongside `vault_password`; an explicit `ssh_key_passphrase` requirement remains required. This is not a Vault/Custom authentication override.
Keep dialog contents outside HTMX-polled fragments. Load the form lazily for the current reservation, never pre-populate secrets or automatically open/focus a password field. Show the reviewed customer, playbook, target count and mode. Preserve focus containment, Escape and explicit Close, focus return after replaced triggers, short-viewport internal scrolling, persistent labels, paste, Show/Hide and Caps Lock hints. A backdrop tap must not accidentally discard typing. Close dismisses the form, not the job.
Clear all marked secret inputs on submission, closure, expiry/revocation and pagehide, including revealed text inputs. Keep submitted secrets out of URLs, storage, logs, titles, status responses and history snapshots. Do not claim clearing DOM/references erases all browser/runtime memory. Preserve five-per-minute submission throttling, existing worker single-claim semantics, five-minute empty reservation and 60-second handoff/start deadline. Do not extend a reservation on a GET or while typing.
Use a monotonic client countdown synchronized to server timestamps for display only. The server remains authoritative. An accepted handoff is not a verified password or completed execution. On an uncertain/lost POST response, clear inputs, lock submission and poll owner-only status; never automatically resend credentials. Reopening an uncertain job in the same page must not offer another POST. Terminal status, cancellation or lost eligibility must close the input opportunity without manufacturing a retry.
Needs your attention is a non-secret, read-only view: your own live credential reservations and, for eligible administrators, other requesters' jobs pending independent approval. It does not reveal other owners' credential forms, automatically approve/execute jobs or use the latest-100 history limit as its population. Existing policy and grants still apply. Keep ordinary execution failures separate from jobs currently waiting on user input.
The canonical credential POST is `/api/v2/runs/{id}/credentials`; retain the shipped `/api/v2/jobs/{id}/credentials` alias. New reservation-status GETs must not return secrets, override scope, renew a deadline, or invoke Core. Tests must exercise the shared rate limit, missing required keys, revoked sessions/grants, expiry, cancellation, duplicate submission, literal passwords, ambiguous acknowledgements and the real existing worker handoff socket. Browser fixtures are not live pinned-asset/CSP/HTTPS/HTMX qualification.
## Core3.3 reports and persistent evidence
The approved contract is Core3.3.0rc8/service-event1.0, aim_output_v1 publisher and aim_operation_result_v1 final result. Negotiate live capabilities; preserve validated result_contract with review. Do not add request output flags, read schemas from Core files or invoke private validators. Revalidate final mode/host/schema/scope/data against the recorded contract. Reports arrive only at finalization. A final event is not a final response; persist one report, never duplicate samples.
Keep execution verdict, native target outcome, report availability and local retention distinct. Native exit0/result_validation stays failed even when all targets succeeded. An unavailable report is not empty/zero/false. Unknown versions stay unknown; pending updates are not installed; excluded services are not failed starts; ignored/rescued native semantics stay intact. Generic supported schemas may render safely, never execute custom code or external references.
Progress is captured worker-side with no viewers, via bounded nonblocking queue/batched committed rows. IDs correlate interleaved tasks; no invented future task list/ETA/percentage. Record durable local cursors and deduplicate(job,run,sequence). Tail omissions, queue/storage loss, crash gaps and last-observed time are explicit. SSE replay checks session/job authorization throughout and cannot replay execution or credentials. Missing final response never becomes success from progress.
Schema5 evidence tables are job-linked with cascade deletion. Journal default20,000 events/8MiB tail; latest checkpoint bounded; report default16MiB subject to reviewed slot bounds. Keep reports out of heavy ordinary job/history reads; load one authorized payload on demand. Parsed Checkmk sections are opt-in; default is labeled metadata_only and not a complete report body. Do not retain a secret cache, terminal history or shadow archive; backups/physical erasure have separate policy.
Escape all labels/report text. Never follow returned paths/URLs or render HTML. Browser data storage remains forbidden for evidence. Preserve working modal/hamburger/theme and internal scrolling; no new privileges/services. Public result framing may grow independently of unchanged request/credential limits. Test full-size/split/torn/duplicate-final transport, all9 declarations, mixed outcomes, no_log/secret canaries, generic/global fixtures, two viewers/reload/revocation, retention pressure/deletion, migrations and matched rollback. Report native/browser/fixture boundaries honestly and update all current guides in the same release.
## Core 3.3.0rc8 patch-wave presentation
Obtain reboot/message/delay/continuation controls from public catalog metadata; do not maintain a second defaults table. Blank means omitted/inherited, not a forced value. Surface the Windows-only continuation flag and its catalog hint (false in rc8); explicit true is never inferred from reboot permission. Review normalized options before any run.
Use only validated recorded patch_summary_v1 fields. A successful wave with continuation_required stays successful and calls for review, not an automatic job. remaining_updates_known=false prohibits presenting pending or an empty list as an authoritative next-wave state; retain original structured JSON separately. True refers to the dated final read-only discovery only. Missing optional fields in older reports remain unknown; validate new data with its prepared schema, not an old schema merely sharing the identifier.
Show pre/post reboot/deferred observations, reviewed delay and cycles when supplied. Core owns bounded HRESULT codes/reasons/messages. Never parse raw failure output or equate install_not_allowed (including 0x80240016) with the separate preexisting_reboot_required preflight result. Native failure, report validation, report completeness and next-wave needs remain separate. No auto-enable reboot/rescan, no generic retry shortcut, no new permissions/services/secret handling.
+556
View File
@@ -0,0 +1,556 @@
# Changelog
## 2.1.0rc9 - Core 3.3.0rc8 release reconciliation
Compatibility: separately deployed Core 3.3.0rc8; public service/wire/event 1.0; WebGUI HTTP v2; SQLite schema 5 unchanged.
- Repackage the rc8-compatible WebGUI line as rc9 after reconciling current-release metadata, operator guidance and verification evidence with the supplied Core 3.3.0rc8 archive.
- Preserve the rc8 public contract and live capability gates, including `ansible.windows >=3.8.0,<4.0.0`, `play_task_host_v1`, `inventory_hierarchy_v1`, `target_outcome_summary_v1`, `native_defaults_preflight_v2` and structured operation results.
- Remove stale current-candidate rc4/rc5 labels from active documentation while retaining historical release notes and historical Core reviews as provenance.
- Refresh release verification against the supplied rc8 source tree; no Core files, database schema, service API, permission model, credential model or execution semantics are changed by this repackaging.
- Correct a stale legacy rollback comment (`rc18-only`) to describe the actual pre-2.x adapter boundary; runtime behavior is unchanged.
## 2.1.0rc5 - Core 3.3.0rc8 compatibility
Compatibility: separately deployed Core 3.3.0rc8; public service/wire/event 1.0; WebGUI HTTP v2; SQLite schema 5 unchanged.
- Requalify the exact adapter/executor/deployment gates for Core 3.3.0rc8 while retaining live capability negotiation.
- Require Core's advertised `ansible.windows >=3.8.0,<4.0.0` collection baseline. WebGUI does not install or upgrade collections.
- Accept and render the additive `patch_summary_v1.reboot_reasons_before` native reboot-source observations without changing job verdicts or triggering follow-up work.
- Update Windows filesystem-report wording to attached local storage volumes; mapped/network drives are intentionally excluded by Core rc8.
- Preserve patch continuation, remaining-update knowledge, HRESULT presentation, report/journal retention, credentials, mobile UX, database schema 5 and the existing permission/systemd model.
- Treat Core rc8 Checkmk script relocation/ACL hardening as Core-owned runbook behavior; do not reconstruct paths/ACLs or add private APIs.
- Update fresh-install, deployment, controller-pilot and agent guidance for the new native collection floor and Core rc8 acceptance boundary.
## Preserved previous release notes
## 2.1.0rc4 - Core 3.3.0rc3 patch-wave support
Compatibility: separately deployed Core3.3.0rc3; public service/wire/event1.0; WebGUI HTTPv2; SQLite5 unchanged from rc3.
- Update exact adapter/executor/CLI/deployment compatibility checks while retaining live capability negotiation. Core remains unchanged.
- Expose reboot message/delay and Windows-only post-reboot continuation through existing public catalog forms, showing platform applicability and catalog hints. Blank still omits overrides. Review distinguishes explicit reboot/continuation/delay from inherited inventory/role policy.
- Present continuation_required independently from successful/failed Core and job status. No automatic follow-up jobs, reboot enablement or continuation override.
- Render reboot before/after/deferred state, observed AIM reboot, reviewed delay, cycles, stop reasons and per-update unsigned/hex HRESULT, fixed reason and safe message. No raw fatal-text/event-log parsing.
- Treat remaining_updates_known=false as unknown rather than an authoritative empty/old queue. True labels a final read-only, dated discovery, not an expanded install queue or current compliance. Original validated JSON stays available.
- Preserve old patch reports using their recorded contracts, including the older closed patch_summary_v1 shape; do not invent absent fields or validate old history against the new catalog.
- Keep modal/mobile/graph/history, retained journal/report policy, target outcomes, execution review, credential boundaries and the generated permission/unit contract unchanged.
- Include the requested ADDON-INSTALLATION.md in the release and update deployment/patch acceptance/agent guidance. No new database migration, dependencies, services or write exceptions.
- Add public-Core rc3, historical-report, patch-state, escaped-output and review regression coverage. Actual results and environment limitations are in docs/VERIFICATION.md.
## Preserved previous release notes
## 2.1.0rc3 - Core3.3 reports and retained execution evidence
- Target independently managed Core3.3.0rc1 with existing service/wire/event1.0; WebHTTPv2, additive SQLite schema5.
- Preserve negotiated result contracts in review; validate final operation reports and new result_validation/error families while keeping native outcome and report availability separate.
- Separate large public-result framing from unchanged inbound credential/request limits; reject malformed, torn or oversized payloads without replay.
- Retain structured worker-side progress with bounded batched writes, tail/checkpoints, durable cursor replay and explicit coverage gaps. No raw output or browser-dependent collection.
- Retain eight ordinary report types by default and only metadata for parsed Checkmk config unless opted in. All9 schema-keyed summaries and generic supported JSON views; lazy per-slot payload reads, dated Host Activity links.
- Cascade report/journal deletion with jobs, preserving only compact audit metadata. Historical jobs are not backfilled and stale pre-upgrade work is stopped by migration.
- Preserve modal, mobile header, hierarchy/history, normal executor identity and all narrow staging/IPC permissions. Add restored-adapter Core compatibility check before service restarts on rollback.
- Qualification evidence is recorded in docs/VERIFICATION.md; no new live-host certification is implied.
## Historical releases
# Changelog
## 2.1.0rc2 - one-run credential dialog and attention
Compatibility: AIM Core 3.2.1rc2; service/event API 1.0; WebGUI HTTP v2; SQLite schema 4. No Core, deployment, permission or dependency-pin change.
- Replace default navigation to password entry with an owner-initiated native modal; retain full-page/no-JavaScript fallback. The modal is outside polled job fragments.
- Show reviewed job context, server-synchronized reservation countdown, required fields, Show/Hide and Caps Lock feedback. Short/mobile viewports scroll within the dialog.
- Add Bootstrap 5 segmented radio choices for customer-Vault versus separate SSH key passphrase only when Core permits both. Explicitly required key passphrases stay required. No Custom authentication override.
- Clear revealed/masked inputs on dismissal, submit, invalidation and navigation. Treat lost acknowledgement as uncertain; reconcile with read-only status rather than automatically resending credentials.
- Add Needs your attention to Overview/Jobs with owner credential actions and existing independent-administrator review links. Do not change approval or execution policy.
- Add canonical /api/v2/runs/{id}/credentials POST alias, retaining /api/v2/jobs/{id}/credentials; add owner-only credential-status and guarded HTML fragment GETs. Both POST routes share existing throttling and worker handoff.
- Preserve literal secrets, CSRF/origin/session/grant checks, bounded bodies and existing one-run deadlines. Improve ambiguous-handoff wording; enforce an explicitly required SSH key passphrase.
- Add synthetic endpoint and real local worker-socket tests plus mobile/desktop credential-dialog browser fixtures. See VERIFICATION.md for measured results and limitations.
## 2.1.0rc1 - read-only experience candidate
Compatibility: independently managed AIM Core3.2.1rc2; service/wire/event1.0; WebGUI HTTPv2; SQLite4. Based on WebGUI2.0.0rc8. No new Core requirement, database migration, runtime permission or execution-policy change.
- Replace the mobile swipe rail with a compact AIM-home / sun-moon / right-aligned hamburger row and native keyboard-operable dropdown; desktop sidebar retained.
- Add current Core Inventory Explorer with linked SVG group/host map, branch focus, distinct counts, search and equivalent mobile outline. Graph nodes navigate, never execute.
- Add Host Activity from retained WebGUI final per-target results with mode/time/playbook/outcome filters, paginated timeline, counter details and own saved-plan references.
- Add Playbook Insights with explicit metric denominators, outcome distribution, paged host-by-playbook matrix and mobile cards. Never scrape terminal/core-wide history or infer live health.
- Scope aggregation to underlying job authorization before reading records; cover more than100 jobs, legacy/unknown data and deletion without a shadow archive.
- Connect existing host lists, job target summaries, Jobs and Saved Plans previews to activity pages. Keep partial host/parent outcomes distinct and current inventory outages visible.
- Preserve one-run review/idempotency, optional collision-safe names, credentials, retry/deletion controls, bounded live output and existing managed permission model.
- Reconcile current agent/read-only/mobile documentation and add unit, real-Core read, browser-fixture and synthetic large-history coverage. Actual qualification is recorded in docs/VERIFICATION.md, not inferred from older RC counts.
## Preserved historical record
The following is the changelog as supplied in2.0.0rc8, including its duplicated historical headings. It is retained as provenance; it is not new2.1 behavior or new verification.
# Changelog
## 2.0.0rc8
- Require and integrate AIM Core 3.2.1rc2 / service API 1.0 while preserving the independent Core/WebGUI release boundary.
- Consume `target_outcome_summary_v1` and retain Core's authoritative overall `failed` status while presenting mixed requested-target outcomes as `Partially succeeded` in WebGUI.
- Show Core-provided per-target outcome and task counters on Job detail; Jobs overview shows successful/requested and failed/unreachable/not-started/indeterminate counts. No task-event reconstruction is used for final host state.
- Consume read-only `inventory_hierarchy_v1` for nested parent/subgroup selection and inventory display. Parent scopes include Core-declared descendant hosts; execution still submits explicit reviewed hostnames.
- Require `native_defaults_preflight_v2`; keep rc6 release-managed split-home staging and executor sandbox unchanged.
- Remove the special persistent orange outline around Jobs and Saved plans; active/hover navigation styling remains consistent with the rest of the menu.
- Preserve one-run jobs, optional collision-safe plan names, detailed safe live output, running-job deletion protection, API v2, SQLite schema 4, and the rc6 managed permission model.
## 2.0.0rc6
- Fix delegated `localhost` execution under the hardened executor sandbox. Ansible 2.19 local connections expand `~svc_bf-ansible/.ansible/tmp` from the passwd database even when the executor service sets `HOME=/var/lib/aim-web-executor`.
- Release-manage `/home/<executor>/.ansible` and `/home/<executor>/.ansible/tmp` as executor-owned `0700`, and grant `ReadWritePaths` only to that exact local staging path while keeping `ProtectHome=read-only`.
- Extend `core-staging-check` so startup validates both Core controller-local staging and Ansible delegated-local staging inside the actual systemd sandbox.
- Do not set global `ANSIBLE_REMOTE_TMP`; doing so would also alter POSIX temp paths on managed Linux hosts.
- Preserve Core 3.2.1rc1 detailed progress, managed socket/runtime permissions, API v2, SQLite schema 4, and existing credential boundaries.
## 2.0.0rc5
- Fix the rc4 executor-start race: deployment now waits for the `Type=simple` executor to bind and permission `/run/aim-web-executor/core.sock` before validating runtime ownership/mode. A temporarily missing socket is treated as startup-in-progress rather than immediate migration failure.
- Fail deterministically if the executor service exits before binding, or if the managed socket does not become ready within the bounded startup window.
- Preserve the rc4 release-managed identity model: `svc_bf-ansible` primary user/group, unit-scoped `aim-web` supplementary group, executor-owned `0711` runtime directory, and `executor:aim-web 0660` socket.
- No Core, database schema, API, credential, inventory, Vault, key-ownership, or global SSH-trust changes.
## 2.0.0rc5
- Fix the rc3 runtime-directory ownership transition: systemd now owns `/run/aim-web-executor` as the executor identity with mode `0711`; authorization remains on `core.sock` as `executor:aim-web 0660`. This removes runtime `chgrp` and allows upgrades from rc2 without manual `/run` repair.
- Preserve the release-managed executor primary group plus unit-scoped `aim-web` supplementary group, staging checks, config/state ownership, and detailed Core 3.2.1rc1 progress rendering.
## 2.0.0rc5 — Core 3.2 detailed progress, operational UI, and managed permissions
Compatibility: AIM 3.2.1rc1; public core service/wire/event 1.0; detail schema `play_task_host_v1`; WebGUI HTTP v2; SQLite 4.
- Negotiate `progress_mode=detail` and render safety-filtered play/task/host progress in the live job console while keeping raw module stdout/stderr unavailable.
- Adopt Core 3.2.1rc1 controller staging preflight requirements in the executor unit: private `.ansible/tmp`, scoped `ReadWritePaths`, and startup staging check.
- Add semantic job state chips, richer Jobs and Saved plans scope previews, stronger navigation emphasis, aligned Audit filters, and viewport-bounded internal console scrolling.
- Preserve running-job deletion protection, one-run execution, optional collision-safe plan names, API v2, schema 4, and separate non-root core executor architecture.
- Preserve the executor account's normal primary group and grant `aim-web` only as a unit-scoped supplementary group; no `/etc/group` mutation is performed.
- Release-manage and verify WebGUI config/state, executor HOME/staging, systemd unit ownership, runtime directory group, and core socket mode/ownership during deployment.
- Keep Core-owned inventory/Vault/private-key permissions and global SSH trust outside WebGUI's ownership boundary.
## 2.0.0rc1 — independent core API migration
Compatibility: AIM3.1.0; public core service/wire/event1.0; WebGUI HTTPv2; SQLite4.
Release candidate: no live-controller execution qualification is implied.
- Replace private rc18 imports/command hooks/Ansible strategies and sudo key export
with the documented aimctl protocol. Pre-started non-root add-on executor under
the existing authorized key-owning account; no automatic core/user/permission edits.
- New run -> review -> one-run submission, without a saved plan. Mode/key handling
included in immutable review. Saving is secondary and optional.
- Generated unique titles for blank names, transactional casefold/NFKC duplicate
rejection for explicit account-local titles. Never overwrite by title.
- Preserve authentication, grants, history, audit, bulk deletion, explicit retry,
existing orange light/dark/mobile shell and browser-local timestamp lifecycle.
- Schema4 retains records; old pending/queued jobs blocked, running interrupted.
Old plans require fresh core review. Historical duplicate titles retained.
- Retire unsupported Custom/raw-console features rather than misrepresent native
inventory defaults or bypass the new API. Platform groups remain selectable;
core1.0 lacks subgroup paths. Structured final core result/counters are shown.
- Major migration requires --migrate-core; stages public core checks as executor,
backs up/removes known legacy helpers/drop-in, uses no privileged capabilities.
Rollback to legacy adapter remains stopped with incompatible core.
- Verify the release manifest before deployment; use a separate executor HOME for
native caches and independently verified host trust. Alternative direct-HTTP
browsing profile keeps execution/credentials disabled.
- New API/migration/security/contract-review/agent guidance and regression coverage.
## Historical WebGUI1.x entries (superseded architecture)
# Changelog
## 2.0.0rc5
- Fix the rc3 runtime-directory ownership transition: systemd now owns `/run/aim-web-executor` as the executor identity with mode `0711`; authorization remains on `core.sock` as `executor:aim-web 0660`. This removes runtime `chgrp` and allows upgrades from rc2 without manual `/run` repair.
- Preserve the release-managed executor primary group plus unit-scoped `aim-web` supplementary group, staging checks, config/state ownership, and detailed Core 3.2.1rc1 progress rendering.
## 1.1.0rc10 - 2026-09-19 (ephemeral live job console and execution diagnostics)
- Adds an authenticated same-origin Server-Sent Events job console backed by an owner-only local Unix socket. Playbook output is bounded in memory, sanitized before leaving the worker child, cleared on navigation, and never stored in SQLite, audit history, or regular files.
- Keeps the console outside the HTMX-polled status fragment so polling cannot erase the stream. SSE responses disable proxy buffering and use keepalives for reverse-proxy compatibility without WebSockets.
- Classifies non-zero Ansible runs into remote connection/authentication, playbook task, controller/playbook-loading, or generic execution failures using sanitized output only.
- Retains rc9 credential/runtime fixes and AIM 3.0.0rc18 compatibility; no AIM or database-schema changes.
## 1.1.0rc9 - 2026-09-19 (Ansible 2.19 runtime qualification fixes)
- Treat resolved Vault/connection secret values as literal data. Standard AIM exact variable references are dereferenced once; the resulting password/passphrase is never recursively templated, so Jinja-looking password text remains unchanged.
- Remove the empty `ANSIBLE_CALLBACKS_ENABLED` environment override. Ansible Core 2.19.11 interprets an empty callback name as an invalid plugin and aborts before playbook execution.
- Correct the synthetic SSH-key runtime test to use owner-only `0600`, matching the secure canonical-key policy.
- Correct `credential-check` acceptance-test guidance for a disposable test environment and `AIM_TEST_ANSIBLE_PYTHON`.
- No AIM 3.0.0rc18, SQLite schema, permission model, HTTP API version, or credential lifetime change.
## 1.1.0rc8 - 2026-09-19 (secure key handoff + history QOL)
- Fix the rc7 secure-key preflight: the resolver no longer opens canonical service-owned `0600` SSH private keys as `aim-web`. It validates path/type/mode metadata only; the restricted export helper running as AIM `service_user` is the sole reader of canonical key bytes.
- Add bulk deletion to the Jobs overview for terminal jobs and to the Saved Plans overview for the requesting account's own plans. Individual deletion and append-only deletion audit records remain.
- Add **Retry as new job** for failed jobs. Retry is requester-only, manual, creates a new job ID, revalidates current grants/source revisions/execution policy, preserves reviewed non-secret parameters and credential mode, and requires fresh one-run credentials.
- Preserve the secure permission model: inventory/Vault sharing through `aim-runtime`; canonical private keys owned by AIM `service_user` at `0600`; one-job WebGUI key copies only.
- No AIM 3.0.0rc18 or SQLite schema change. HTTP API v1 gains the additive manual retry endpoint.
## 1.1.0rc7 - 2026-09-19 (rc6 deployment packaging fix)
- Fix deployment of the restricted SSH key-export helper after the staged source directory is atomically activated. rc6 moved the staging directory before reading `key_export.py`, then attempted to read the obsolete staging path and rolled back. rc7 reads the helper from the activated managed source tree.
- No AIM 3.0.0rc18, SQLite schema, execution policy, credential protocol, or WebGUI API changes.
- Retains the rc6 secure permission model, terminal-job deletion, saved-plan deletion, and browser-local timestamp behavior.
## 1.1.0rc6 - 2026-09-18 (Ansible 2.19 Vault/runtime qualification fixes)
- Use Ansible Core 2.19.11's runtime `VaultSecret` API with UTF-8 bytes for one-run Vault passwords; remove the unavailable `TextVaultSecret` test-helper import exposed by controller qualification.
- Keep `/usr/bin` as the shipped Ansible binary directory for this controller profile and resolve `/usr/bin/python3` from the actual `ansible-playbook` runtime rather than assuming a venv.
- Distinguish missing, unreadable and empty SSH private keys using sanitized fixed messages. Permission failures now point to the shared `aim-runtime` read/traverse policy rather than collapsing into a generic Vault/reference error.
- Preserve the rc4 fixes for literal special characters, explicit Vault-decrypt preflight, browser-local timestamp rendering, grant de-duplication and one-run credential lifetime.
- Document the qualified filesystem model: AIM/WebGUI runtime identities need read/traverse access to encrypted Vault and configured customer private keys; AIM source remains externally managed and unchanged.
- No AIM, HTTP API or SQLite schema change.
## 1.1.0rc3 - 2026-09-18 (grant picker de-duplication)
- Scoped execution grant forms now refresh the playbook choices for the selected account/customer and omit exact grants the account already has.
- If every catalog playbook is already granted for that scope, the form shows a disabled explanatory option instead of offering a duplicate grant.
- Duplicate grant submissions are rejected server-side with a conflict response rather than silently succeeding.
- Existing grants remain visible below the form for review and revocation.
- No schema, credential, execution, API version, AIM compatibility, or deployment-policy changes.
## 1.1.0rc2 - 2026-09-18 (administration layout fix)
- Removed the duplicate **Scoped execution grants** panel from the User administration page subtitle area.
- Kept a single grant-management panel below Local accounts / Add account so account administration reads top-to-bottom without repeated controls.
- No schema, credential, execution, API, AIM compatibility, or deployment-policy changes.
## 1.1.0rc2 - 2026-09-18 (credential feature candidate)
**Compatibility:** AIM **3.0.0rc18 only, unchanged**; HTTP API v1 with additive
credential routes; SQLite schema **3** (additive from 2); Python >=3.11.
Credential execution targets **Ansible Core 2.19.11 exactly**, in the existing
external Ansible environment. No Ansible/AIM package is installed or updated.
**Candidate, not production-qualified:** the build environment could not obtain
Ansible 2.19.11 or OpenSSH client tools. Local protocol/route/policy tests and
fake-worker regression tests pass; real Ansible/SSH/WinRM qualification remains
required. Read docs/VERIFICATION.md, not a test count as a certification.
### Added
- Separate disabled-by-default [credentials] enabled switch, plus existing
execution allowlist, HTTPS transport attestation and approval requirements.
- Accessible Vault/Custom mode control at job review. The custom username and
mode become immutable reviewed metadata. Passwords are collected only after
approval and the single worker reserves the job; they are not plan fields.
- Five-minute empty worker reservation and 60-second single-run hand-off/start
deadline. No secrets are held for approval or scheduled-job delays. A retry or
check-to-apply is a new credential submission. No automatic replay on restart.
- Private Unix socket hand-off, requester/session/job checks, anonymous child
pipe and job-private password-source helpers. Add-on code never persists
infrastructure passwords to SQLite, helper text, argv or environment values.
- Resolver in the existing Ansible Python: standard customer Vault, effective
host/group connection references, per-host Windows NTLM credentials, existing
customer SSH keys and separate Vault key passphrase. Unused legacy SSH
password references do not block key authentication.
- Custom mode overrides connection identity/password for every selected host;
disables prior SSH control sockets and key/agent fallback. SSH uses an add-on
ASKPASS helper, avoiding the native 2.19 named password shared-memory helper.
- Dedicated linear strategy adapter and final launch authorization. Endpoint
changes, unsupported auth transports and selected source patterns fail closed.
- aim-web credential-check for non-secret exact-runtime/import/tool checks;
six opt-in synthetic actual-Ansible tests and an execution acceptance guide.
- Mobile/desktop credential page and no-echo error handling. Existing appearance,
account policy, selection semantics and network/TLS profile remain unchanged.
### Deliberate restrictions
- Standard SSH and WinRM/NTLM only; no network/API plugins, become-password
collection, private-key uploads, saved passwords, raw output streaming or SSO.
- Credential jobs reject delegation, asynchronous tasks, explicit strategies,
dynamic task imports/inventory changes, external roles and SSH argument escape
hatches. Some rc18 catalog plays (notably delegated Checkmk work) remain terminal
only. An allowlist entry is not a guarantee that a play is eligible.
- Custom credentials may still require Vault unlock for unrelated play variables.
- Passwords use small HTTPS POST bodies. Proxy/body buffering and trusted playbook
behavior remain deployment responsibilities; process separation/shared UID is
not a privilege sandbox, and Python does not promise physical memory erasure.
### Upgrade and rollback
- Whole-release replacement and overwritten TOML continue. Credentials and
execution are both disabled in shipped defaults; the backend TLS setup is not
touched. Existing accounts, sessions, plans and jobs survive forward migration.
- Schema 3 adds only credential phase/deadline metadata, not a credential store.
- Rollback to 1.0.0/schema2 requires explicit historical database restoration,
losing post-checkpoint changes and potentially restoring old passwords. Make
a current protected backup first. Do not manually repoint the active venv.
- Global AGENTS.md and security/API/deployment/execution docs updated together.
## 1.0.0 - 2026-09-18
**Compatibility:** AIM 3.0.0rc18 only (unchanged); HTTP API v1; SQLite schema **2**;
Python >=3.11. Independently versioned add-on, not an AIM release.
### Added
- Named-administrator setup and bootstrap retirement, with forced first-password
change and last-admin/session protections retained.
- Status/config-check/doctor CLI and administrator System diagnostics.
- Inventory search, group filters, sorting and 100-row browse pagination;
private selection restoration, clear/visible select-all controls.
- Typed catalog forms and private non-secret saved plans/preset reuse, using
existing rc18 input parsing and target validation.
- Searchable/paginated administrative audit history and lifecycle events.
- Optional disabled-by-default worker using rc18's existing PlaybookManager.run
and its external_presentation hook; no monkey patches or duplicated AIM CLI.
- Exact customer/playbook execution grants; catalog allowlist; independent
administrator approval; one active job; host/timeout/start-window policies;
idempotent submission; one-shot UTC schedules; cancellation and crash recovery.
- API v1 plan, selection, diagnostics, audit and job routes. Existing preflight
remains validation-only. POST /api/v1/runs returns 501 while execution is off.
- Source-revision checks at submission and dispatch; interrupted jobs are never
automatically replayed. Potentially sensitive raw command output is discarded.
### Fixed
- Mobile group count badges have their own constrained grid slot instead of
overflowing two-column group cards. Long labels wrap without moving counts out.
- The mobile navigation rail now shows scroll instructions and arrow controls.
- Deployment journals before replacing TOML; checks installed candidate identity;
manages the worker with rollback; tests schema compatibility before rollback.
- Verified, unchanged installed browser assets can be reused without a download.
### Deployment changes requiring review
- Release-managed TOML now uses loopback 127.0.0.1:8080 and trusts 127.0.0.1,
matching NPM -> HTTPS controller:8443 -> local Nginx -> WebGUI. NPM/certificates
are externally managed and are NOT modified by the add-on deployment.
- Additive schema 1 -> 2 migration preserves accounts. Rollback to 0.1.x needs
explicit --restore-auth-db and loses post-checkpoint data; read the guide.
- Execution is off, has an empty allowlist, and has no TLS verification
attestation by default. A release upgrade does not silently enable jobs.
- Raw output streaming and interactive infrastructure credentials remain deferred.
- See docs/VERIFICATION.md for actual test results and live-controller limits.
# AIM WebGUI changelog
WebGUI uses its own version sequence. An add-on release does not imply any AIM
release, source edit or upgrade. Every entry must include supported AIM versions,
auth schema compatibility, HTTP API compatibility and upgrade/rollback notes.
## 0.1.6 - 2026-09-17
### Compatibility
| Component | Contract |
| --- | --- |
| AIM base | **3.0.0rc18 only; unchanged and externally managed** |
| Python | 3.11+ |
| HTTP API | v1 unchanged |
| Add-on database | Schema 1 unchanged |
| WebGUI config | Release-managed; overwritten on install/update/rollback |
### Added / changed
- Made mobile/responsive behavior a global UI standard. At 760px and below the desktop sidebar becomes a compact sticky header with one non-wrapping, horizontally scrollable navigation rail, preventing staggered/wrapped top navigation.
- Reworked the mobile account/display bar so theme controls and account actions remain level and usable, long usernames ellipsize, and the desktop layout remains unchanged.
- Added narrow-screen rules for touch-friendly group controls, contained table scrolling, stacked forms/actions, responsive system facts, reduced card spacing, and device safe-area insets.
- Expanded the repository-root `AGENTS.md` into the authoritative AI-agent/contributor standards document covering the immutable AIM boundary, release/state ownership, security invariants, design system, mobile requirements, selection semantics, authentication, testing, verification, and documentation upkeep.
### Upgrade / rollback
Whole-release replacement semantics are unchanged. The release-managed deployment profile remains the validated `https://aim.desq-gaming.de` / Nginx Proxy Manager configuration from 0.1.5. `/var/lib/aim/webgui` auth/runtime state remains preserved across normal updates. AIM rc18 is never modified.
## 0.1.5 - 2026-09-17
### Compatibility
| Component | Contract |
| --- | --- |
| AIM base | **3.0.0rc18 only; unchanged and externally managed** |
| Python | 3.11+ |
| HTTP API | v1 unchanged |
| Add-on database | Schema 1 unchanged |
| WebGUI config | Release-managed; overwritten on install/update/rollback |
### Added / changed
- Replaced the text Light/Dark selector with compact sun/moon theme controls while retaining explicit accessible labels and pressed-state semantics.
- Tidied inventory-group bulk-selection controls and aligned the select-all header checkbox exactly with host-row selection checkboxes. Selection behavior and explicit-host-only preflight semantics are unchanged.
- Managed deployment now creates `/usr/local/bin/aim-web` as a guarded symlink to `/opt/aim-web/current/bin/aim-web`. It follows update/rollback automatically and refuses to overwrite unrelated files or symlinks.
### Upgrade / rollback
Whole-release replacement semantics are unchanged. The release-managed config still uses `https://aim.desq-gaming.de`, backend listener `0.0.0.0:8080`, and trusted Nginx Proxy Manager `192.168.20.3`. `/var/lib/aim/webgui` auth/runtime state remains preserved across normal updates. AIM rc18 is never modified.
## 0.1.4 - 2026-09-17
### Compatibility
| Component | Contract |
| --- | --- |
| AIM base | **3.0.0rc18 only; unchanged and externally managed** |
| Python | 3.11+ |
| HTTP API | v1 unchanged |
| Add-on database | Schema 1 unchanged |
| WebGUI config | Release-managed; overwritten on install/update/rollback |
### Added / changed
- Added a global Light/Dark display selector in the WebGUI top bar. The preference is browser-local presentation state only; no authentication/session material is stored with it.
- Added a dark palette built from charcoal/slate surfaces instead of pure black, while retaining the existing AIM orange accent and accessible focus/selection states.
- Centralized additional surface, table, note, badge and control colors into theme tokens so pages switch consistently between light and dark modes.
- Added a select-all checkbox to the explicit-host table header with checked/indeterminate synchronization.
- Added inventory-group bulk selection controls with host counts. Overlapping groups and manual host selections update group controls to checked/indeterminate states.
- Group selection remains presentation-only: preflight continues to submit explicit inventory hostnames and never Ansible patterns or group expressions. The existing server-side explicit-target validation and 500-target limit are unchanged.
### Upgrade / rollback
Whole-release replacement semantics are unchanged. The release-managed config still uses `https://aim.desq-gaming.de`, backend listener `0.0.0.0:8080`, and trusted Nginx Proxy Manager `192.168.20.3`. `/var/lib/aim/webgui` auth/runtime state remains preserved across normal updates. AIM rc18 is never modified.
## 0.1.3 - 2026-09-17
### Compatibility
| Component | Contract |
| --- | --- |
| AIM base | **3.0.0rc18 only; unchanged and externally managed** |
| Python | 3.11+ |
| HTTP API | v1 unchanged |
| Add-on database | Schema 1 unchanged; users/passwords/auth state preserved |
| WebGUI config | Release-managed; overwritten on install/update/rollback |
### Fixed / changed
- Hardened browser CSRF-origin handling for reverse-proxy deployments. `Origin` remains authoritative when present and must match `public_url`; a same-origin `Referer` is accepted only when `Origin` is absent.
- If both `Origin` and `Referer` are absent, unsafe browser requests are accepted only with `Sec-Fetch-Site: same-origin`, and the existing per-session CSRF token remains mandatory. `Origin: null`, cross-site Fetch Metadata, and mismatching Origin/Referer values remain rejected.
- Changed the response `Referrer-Policy` from `no-referrer` to `same-origin` so browsers can provide the safe Referer fallback without leaking referrers cross-origin.
- Improved origin-rejection messages to distinguish mismatched Origin, mismatched Referer, cross-site Fetch Metadata, and missing same-origin browser metadata.
- Corrected the managed Nginx Proxy Manager trust address for the validated deployment profile to `192.168.20.3`; backend remains `192.168.20.46:8080` and public origin remains `https://aim.desq-gaming.de`.
### Upgrade / rollback
Whole-release replacement semantics from 0.1.2 are unchanged. The release-managed WebGUI configuration is overwritten with the 0.1.3 profile during update; `/var/lib/aim/webgui` authentication/runtime state is preserved. Rollback restores the prior release source/config while leaving the auth database intact unless explicitly requested. AIM rc18 is never modified.
## 0.1.2 - 2026-09-17
### Compatibility
| Component | Contract |
| --- | --- |
| AIM base | **3.0.0rc18 only; unchanged and externally managed** |
| Python | 3.11+ |
| HTTP API | v1 unchanged |
| Add-on database | Schema 1 unchanged; users/passwords/auth state preserved |
| WebGUI config | **Release-managed; overwritten on install/update/rollback** |
### Changed
- Managed deployments now treat `/etc/ansible/scripts/config/webgui.toml` as part of the versioned WebGUI release rather than operator state. Every install/update writes the release copy, and rollback restores the copy captured with the rolled-back release.
- Update sequencing now snapshots and validates the active release before replacing configuration, then validates the candidate with the candidate runtime. An older WebGUI runtime is never asked to parse a newer configuration schema.
- Authentication database checkpoints now use Python SQLite's native online backup API directly, so backup and update no longer depend on the previous WebGUI runtime or its ability to parse any configuration schema.
- Private runtime state under `/var/lib/aim/webgui`, including the SQLite authentication database and consumed bootstrap state, remains preserved across normal updates.
- The managed release profile binds `0.0.0.0:8080`, uses `https://aim.desq-gaming.de`, and trusts forwarded headers only from Nginx Proxy Manager at `192.168.10.11`.
### Upgrade semantics
`deploy/deploy.py update` performs whole-release replacement without Git. Source, venv, systemd unit and WebGUI configuration advance together. The SQLite authentication database is retained. On deployment failure, the previous source, venv, unit and configuration are restored automatically; AIM rc18 is never modified.
## 0.1.1 - 2026-09-17
### Compatibility
| Component | Contract in this release |
| --- | --- |
| AIM baseline | Original `AIM-Ansible-3.0.0rc18.zip` only; unchanged |
| Upgrade from WebGUI | `0.1.0` supported by managed replacement update |
| Add-on database | Schema 1 unchanged; users/passwords/sessions preserved |
| HTTP interface | API v1 unchanged |
| Reverse proxy | Explicit trusted forwarded-header support added |
### Fixed / changed
- Added explicit non-loopback listener support for reverse-proxy deployments. The safe default remains `127.0.0.1`.
- Added `proxy_headers` and `forwarded_allow_ips`; Uvicorn now trusts forwarded headers only when configured.
- Added sectioned TOML configuration while retaining full read compatibility with the 0.1.0 flat configuration.
- Non-loopback listeners require an HTTPS `public_url`, proxy-header processing, and at least one explicitly trusted proxy address/network.
- Managed readiness checks now work with wildcard listeners (`0.0.0.0` / `::`) by probing loopback while sending the configured public Host header.
- Documented Nginx Proxy Manager deployment validated with `aim.desq-gaming.de`, backend `192.168.20.46:8080`; site-specific values are examples, not package defaults.
- Clarified that the managed systemd unit supplies AIM rc18's required group as a supplementary process group; manual tests launched with `sudo -u` may not inherit that group.
### Upgrade / rollback
Use `deploy/deploy.py update` from a freshly unpacked 0.1.1 release. The active add-on source and isolated venv are replaced as a unit; operator configuration and `/var/lib/aim/webgui/webgui.sqlite3` are retained. 0.1.0 flat TOML remains valid after upgrade. Rollback to the managed 0.1.0 snapshot remains supported without restoring the auth database unless explicitly requested. AIM rc18 is never modified.
## 0.1.0 - 2026-09-17
### Compatibility
| Component | Contract in this release |
| --- | --- |
| AIM baseline | Original `AIM-Ansible-3.0.0rc18.zip` only |
| AIM product version | `3.0.0rc18`, unchanged |
| AIM rc19 / later | Not claimed; startup refuses unapproved versions |
| Python | Requires 3.11+; executable tests run on CPython 3.13.5 |
| Managed deployment | Linux with systemd; separate unprivileged service account |
| Add-on database | Schema 1, SQLite rollback-journal mode |
| HTTP interface | `/api/v1`, cookie authentication and CSRF for unsafe methods |
| Frontend | Jinja2, HTMX 2.0.10, Bootstrap 5.3.8; no EJS/Node |
| Core change requirement | None; no service/API facade added to AIM |
### Added
Independent `aim-webgui` Python distribution and `aim-web` executable. FastAPI /
Uvicorn server, Jinja2 pages/partials, local static asset preparation and
centralized orange theme with explicit Bootstrap component overrides.
Read-only rc18 adapter for Config, group membership, CustomerManager,
InventoryDocument/InventoryEditor, catalog parsing, InputSpec.parse/validate
and PlaybookManager.validate_overrides. Target/platform filtering mirrors the
rc18 target screen without importing its terminal UI. Inputs inherit role
defaults unless explicitly supplied; the existing cleanup-off safety default
is retained. Customer path traversal and escaping symlinks are rejected.
SQLite bootstrap admin with random password written to private `.credentials`,
mandatory first password change, Argon2id hashes, server-side hashed session IDs,
CSRF/origin checks, idle/absolute expiry, request limits, login throttling,
local account administration, last-admin protection and terminal recovery.
Add-on-only, non-git staged replacement deployment. It prepares dependencies
before stopping WebGUI, stores SQLite backups using its backup API, retains
operator config/auth state, replaces old source rather than merging, and
switches between permanent isolated venv paths. Readiness failures attempt
rollback; interrupted operations leave a root-owned recovery journal.
Code-only rollback preserves accounts by default. Database restoration requires
an explicit destructive flag, and session tokens are always revoked on rollback.
### Deliberately not included
Browser-triggered playbook execution, infrastructure writes, Vault/SSH/WinRM
credential collection, job workers, customer-scoped roles, MFA, SSO, API bearer
tokens, generic plugin discovery, or any change to the base AIM installation.
The `POST /api/v1/runs` compatibility boundary explicitly returns 501 after
normal authentication/CSRF checks. It never starts a job.
### Upgrade and rollback compatibility
This is the first independent Python add-on release. It is NOT an upgrade path
for the historical Node/EJS rc19 WebGUI or its API daemon. Leave those separate
artifacts unused when deploying against rc18.
Future updates must use a new add-on version and an explicitly recorded AIM
compatibility contract. The installer refuses same-version replacement and
downgrades through `update`. Use `rollback` for an installed prior snapshot.
Migrations only advance schema versions; startup refuses a newer database.
Do not restore an old database merely to roll back compatible code: that would
also revert password, user and audit changes after the snapshot.
### Verification limits
See `docs/VERIFICATION.md` for actual results. No managed-host Ansible operations
were run. Linux/systemd activation and an online dependency installation were
not exercised against the operator's controller. The build container could not
download browser assets or dependency wheels; deployment has a required pinned,
integrity-checked online/offline preparation step. No claim of a penetration
test or a current complete dependency vulnerability audit is made.
## 1.1.0rc7
- Preserve canonical AIM SSH private keys as service-owned `0600`; WebGUI uses a restricted service-user export bridge and job-private `0600` copies only.
- Add deletion of terminal job history while retaining append-only audit deletion records. Saved-plan deletion remains supported.
- Re-run browser-local timestamp formatting after HTMX settle and browser page-cache restores.
- Document the `aim-runtime` shared-read model: inventories/Vaults `root:aim-runtime`, private keys service-owned `0600`.
+160
View File
@@ -0,0 +1,160 @@
1f1384af56beddd47178a8c60a2237d9f507097aa05c8cd670bae4d741c2d357 ADDON-INSTALLATION.md
dc99c09506dc06a25ee247d09911149acbd38369de27d4ae818a17d7bafc09fb AGENTS.md
c51cfc40c62dd261c5dbab1c9f320a408103433a59530d261b6ba5473dfed740 CHANGELOG.md
d1997dbfb80c93ee9c407f134dee90d22a52217f62a54bf99a618043cb560ff1 README.md
ec7dd6d823636327eea262d158f9357bf2c620df8cee377814389a8a8a9688de constraints.txt
37142da0781c809f7c10ece7da94a1eea3cc01c88ec1468b440838bbfd7ee299 deploy/deploy.py
0fea12d2a4a707a64770a5d29a0a4647c0ec7195ecb07ec121b0a3f54e61c36e deploy/fetch_assets.py
dae90333281d07cf827585775516dd7091b62fcf0443b3685080739749f86d5c deploy/nginx.example.conf
df104ed57e4e0d12c5a280cf8b521680747ec762fce4f7972c9099d4d3b0042d deploy/profiles/direct-npm.toml
506e69c23372af047185de91f1a449bf0ac602192a1cd727eac7cf5c46f92cd2 deploy/webgui.example.toml
80415ff7c4d6a56692405eb7f598592dd934753debd9ba0b6c25587bd17ca461 docs/API.md
2673309dbf6a037cd9a35da7fb913bb6dc8d32486a52897378941e60a5cc4724 docs/ARCHITECTURE.md
917e8808e7e8c661ece0e267c604a776bc73b90c0bd2fe38979ee9928dc27f7c docs/CONTROLLER-PILOT.md
c3705f7efbbfa4cbe87d962b166522abb52cc0674cdade0014e1e65299828e60 docs/CORE-3.1-REVIEW.md
d888ee7d061301b9648b50d1736ecac145735c20895b58f4e33477ac9ad443de docs/CORE-3.2.1RC1-REVIEW.md
a81d360c0e3cb43c7fca22ebc377d69794a64daf171603dd4d2189e540819739 docs/CORE-3.2.1RC2-REVIEW.md
8ccc3df07cc54d10048676692e522822719fdfaa44d26f6eb87be4f736ea09e8 docs/CORE-3.3-REVIEW.md
dd4ef39a61f99eda3ff02d339210572ab0332cf30b0c7321b895a1e8f602b291 docs/CORE-3.3.0RC3-REVIEW.md
6774b0924ce5a8293c0c6b7c1bf146cd3949be5da5f70c5e095e98993217b499 docs/CORE-3.3.0RC8-REVIEW.md
969f07f03a6423894c678dbb63e1f5651f16f24f8d1bd3b754612ab30a4d09aa docs/CREDENTIALS.md
f1df31bc38df55641d109be5f59086472b179e6f4643f88c52e6f1742e99adc8 docs/DEPLOYMENT.md
cb9f965d7070d0bb0a3fe7bf76b7d6bec2ddac758d7d5b559d4150d5a41b875b docs/EXECUTION.md
d2d8cb462e3589d05f3f335936aefccb5c8e3b5254e7a6157324fef81802e967 docs/JOURNAL.md
a0b450bb134e0e9c729ebb3beeff426565b0ccb2c9c5c43b129377f757f93bd7 docs/MIGRATION-3.1.md
408d97151591f55216b83c4e6994f1fb0f42eb04c35e5c57f81b8db8ca648226 docs/PATCH-WAVES.md
82976cd54ac75b37c1061070fcd88d35a115b55ad53bade6682e3dae8596412d docs/PERMISSIONS-ROLLOUT.md
2b31c7fb4d52361be9e2eadb4e445f466ed4c6bbd82ee7dec4a788c94b1a2100 docs/PRODUCT-COMPARISON.md
f8462d2dd45f035d1c15ef5561f11de4b9ee64b0c5b5abdf7a1c7964dc2da022 docs/READ-ONLY-EXPERIENCE.md
3955e965c2ad13f081f38fcefa64927e7f28c77cd96a069308beec860d43a591 docs/REPORTS.md
48c3d5fa43902fd62472eaa59cb36a0291e34c7dd77a7e160fd8e78c4e60eff9 docs/ROADMAP.md
76fc73c2cf6cf8b8ffab2b20fb4edc8033f8fefe25d654e2e3e8cacb6059a9de docs/RUN-COMFORT.md
eb8982fa542381d877b723736423da1f8f79d1730e50a76870ae85e5e23316fe docs/SECURITY.md
eccda2e276cc59c58e52e9dd79987253fd92e1eabf4ade1387b097480a140d89 docs/VERIFICATION.md
bb981b7b25ac6596d0b4e39c4377563afdab1ba309411568f0028123b0cd1865 docs/verification-results.json
076e15dec2cccb30d41db0caf8c119723989a3db19a8e7c747c7865ec8e4d39f pyproject.toml
f8f4b4ca210adbceb2d9c0caad5de35dfa32c8b729c21925fd87ce3330978e2d requirements-test.txt
c8883045777b542ac62894e833dc22b332528bbe13ddec90e3a28be82aadc2c9 src/aim_webgui/__init__.py
7579c8c24cf1822096cdd914b5a60057e94e0e7c21d8e46d50ac2c3416c62ff8 src/aim_webgui/__main__.py
92c961cf11f9c5e907e00f8ccfc53baac2b2b4901d9518bc9987d4b597b7464e src/aim_webgui/activity.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 src/aim_webgui/adapters/__init__.py
ae71a963c320b131b85b45ec65525a6cc207bd32b8f0925ef579eb029a353e7f src/aim_webgui/adapters/core_v1.py
11d793eb6cf2f4c4862a82e467e5230bf7a391520ede165b151a175a69cce6ef src/aim_webgui/app.py
d5f74e126b9e969d93e5156b34d047b3d97471d58de3f0ad14b0948652912acc src/aim_webgui/assets.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 src/aim_webgui/auth/__init__.py
8178c02694ba9e52529bf6863281a170d394ebc010962b4f3c86f84cc16494d4 src/aim_webgui/auth/service.py
2aee1ed63c9e6a63ddd685b49763488eb495f44c24647c13d6d72d493fcef95a src/aim_webgui/cli.py
7f7d74e6f4bc16be096cdae7b4abdfda8feb830d6b15ad94b6150436531b0d9d src/aim_webgui/config.py
a4821e251f2fa79fd5998bcbc4c00871ba23f64d39c014a67c9931b7a02d7ffa src/aim_webgui/console.py
ef57a644af7e925375105a9139f0bc4d083be3a18aad21311479e844519c4817 src/aim_webgui/core/__init__.py
4b40a2f61190f50ddd77cdfea88a61f99a3c5e3288be921a4e2911b8960d4547 src/aim_webgui/core/client.py
41589bd35f6bd15db01d36f3888f378e913a7e11e633f2adb76d939cf95c9f4b src/aim_webgui/core/executor.py
50c900f3df331fb42d7c55b82e4f76103128b5cb98034765b44e1b5b8bec812c src/aim_webgui/core/jsonio.py
293206bcb4bea0a6c28662f288762a70ee2428059f5b0536cc4d89e11ade0f0c src/aim_webgui/core/limits.py
55fa4979bf80dcf786129208cbd47e891d7d5dff0845884f1975ffe163ff192e src/aim_webgui/core/process.py
eb5d9868d930de6c9b2c72c97fdde759c97163a527fb840a54e8f1e01286d3c0 src/aim_webgui/core/protocol.py
9c0d2343103c62060b901616490b349d27cf82f92700426db9a0de03d241bda2 src/aim_webgui/core/reports.py
5478f1c990d81f677a3067b6cc9150f8384929b4288d6206970a4b4f459ec95e src/aim_webgui/credentials/__init__.py
80fa44f632827f756f07dce4ea074b7c43f2414902fcbc3aeb1d58c4c306057b src/aim_webgui/credentials/presentation.py
c16ab7e2c0a48e2ac63b53af4e72b137b5f3cb3141972f723da72289ab8169b6 src/aim_webgui/credentials/service.py
541f6300ebe90b74f9f8cd99ccfd2346f1182b67a611436cef59326162390dcb src/aim_webgui/credentials/wire.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 src/aim_webgui/db/__init__.py
31d4dbe46e301e745cf1f9965cfa31761a75978468c2cc0a9ab47eb9f7649e2a src/aim_webgui/db/migrations/0001_initial.sql
712492fdcb23d8837c7c5c78de2124f1e3e5dc6956b788b2ae93465df7c2d9f8 src/aim_webgui/db/migrations/0002_workflows.sql
6a6512b135382c23301b64c7ba88332d20c929fab3dc88ed1a5ab3178bf34985 src/aim_webgui/db/migrations/0003_credentials.sql
2130f09e21d26a9ebfaddc321cdeb5c8952a3b31d8160ea47dea575ee2aea2ec src/aim_webgui/db/migrations/0004_core_v1.sql
18b507b2dcf38a682f24db8771d60dd5fa039a5a110eba2447fec44adcc250f9 src/aim_webgui/db/migrations/0005_job_evidence.sql
6fb41e826fc86396c54f9b665355eb5612ca58f34b9e4e310687ee3b95851f0a src/aim_webgui/db/store.py
56463b5c5aefa0df0751df2ddbeaed29f1f211170dcc7e1d34c0ae593d63087e src/aim_webgui/diagnostics.py
2d0fa9753846cadc0eb131a774e5355bb25050fb46ae1bc9a0f9dcca3d8e7ce5 src/aim_webgui/errors.py
66dcd5b4d83e4c77eb21f651dbf2494ede6ab95c1711887ae905287853217ef9 src/aim_webgui/evidence_views.py
b691092718c1ded269ef8c8fe7e5c30b30f1bbaab5796ac052ba8ec93890260c src/aim_webgui/explorer.py
6b3302db5b97761d48aee621900a1f35bca2a32050479361c8e6e23cc9ebf04e src/aim_webgui/journal.py
ce868ddf856ff4c36565cad2734f5e4d16182db822bc06ac802c3f4e95dac32d src/aim_webgui/names.py
d982dc030bbaf30785aa337432662471c5970f680d0c64634096d6d5c3aea86d src/aim_webgui/patch_view.py
d152c65c95a855e2f553923a0e620c106e9451d181b7ae087757481478425a18 src/aim_webgui/read_views.py
5db233b25ce1316adb12dc95fcd16e08fb3b9d991e3c48d4232712ff13814c3b src/aim_webgui/reports.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 src/aim_webgui/routes/__init__.py
8c53fbe43006ada151f4abe6a15c50638fdc6c2b4590ce88129d02ed4e728fca src/aim_webgui/routes/workflows.py
0ab884dcef0d56fd7707ff0ba1f7c7b60e485d9923a9db2cccaeb7d7d9555698 src/aim_webgui/security.py
a562f9af41fccf20b7d344a8015cffab03b24cc043d146746c16c958e0081640 src/aim_webgui/static/css/aim.css
bd42da3a3466e2cf14f99046c0a3fec29c9498bb3ada1aaed2f32b5280b769c7 src/aim_webgui/static/css/bootstrap-overrides.css
96e738bd85dc3105c0f5fd463f5ca72424347bb06ab9f5fbc94d4fede05df83b src/aim_webgui/static/css/credentials.css
a976ecb03caf249f6cfc382863d370b1e94714a653bebdaba332b7503474ea9e src/aim_webgui/static/css/evidence.css
220b3ffb332bb98a87ac1d187ae13b3e324d876d8531a8001ec3c32fb5e0a964 src/aim_webgui/static/css/experience.css
f7d2db283169d8a04f73f256c7df9b3c513cab36b635032e96ff7a1853c12c3f src/aim_webgui/static/css/tokens.css
78fc1d9449080b97716284ce7c261a0ea3fbf5c76d6ccfec16eba0f8f2f27b95 src/aim_webgui/static/js/aim.js
00a4f188d4bc1131c7e88cca63a5ce5f9cbc15f4c393aa4fcfc054585735bb3b src/aim_webgui/static/js/credentials.js
2b45ee9845893852503b031e343046c8b72eecddb3824caca1d09a1d375adf50 src/aim_webgui/static/js/evidence.js
8ce9d1eafe0e96c8d65f045f0256542e88821521bb8fe4db58e97fedd1385d19 src/aim_webgui/static/js/experience.js
1b4b4a0d37f3a9d4de5a149cf3d79d573d2dfdbf6abbf317929ed35910edec11 src/aim_webgui/static/js/theme.js
f9b169a6ac2d46579997299da9a56488a0240db9e4880da653777ccb4c2a03de src/aim_webgui/static/vendor/THIRD-PARTY.txt
3208cbbb47ea11d71bd1800d550dd6042e27e7bb26c433c568ecb397c7a9c534 src/aim_webgui/templates/layouts/base.html
16112906e032601178e3c9828d678859d8eadc0a8c652f137bc0bd934f288787 src/aim_webgui/templates/pages/activity.html
e6ec444c021b7b042bffae5267950ac56dbaa82833b85d1ee6c3c90c3e58f949 src/aim_webgui/templates/pages/audit.html
b498800995024eba45adbdc82c5307b38e1f6b32054d253d372acaf711d5731b src/aim_webgui/templates/pages/credentials.html
4f10734406e80f907d662ba7d43cf0faff232013773cb4c7cf72a113ab1a4b6c src/aim_webgui/templates/pages/customers.html
28a413663f40f4862b1d5ebe8cc59f5bd6ffc262b5773cc73a49861360cd82da src/aim_webgui/templates/pages/error.html
42a58b0e19ae4de3a40d90fcbf9e558a9dea9b45a72ae35f60a58f56a03e20f8 src/aim_webgui/templates/pages/explorer.html
a819d71da97d0739f21578a961dc3ed5eb5ed1067946e263867307b85944a620 src/aim_webgui/templates/pages/hosts.html
eafde0574c181c1e176bdd63a5a09b37f8c7aae32e9ad69dadb16ba9074a7a60 src/aim_webgui/templates/pages/insights.html
9322f24f8476864838705775fcf50555cd00922ebef203c58e8bc8e09ea8821f src/aim_webgui/templates/pages/job.html
d1258ec71642ab5e8b93d101ca2acc9a119380944f2b533d39f03a1b52223ab2 src/aim_webgui/templates/pages/jobs.html
2592a7aaabd1b9ed8d55645fe93900b33bad307902c049572acb52f19efdc9a4 src/aim_webgui/templates/pages/login.html
b642639de0e76bc9070fe8f2d176241787d993a153153ca72c1142b534002e16 src/aim_webgui/templates/pages/overview.html
f2b7067d2f24e48e6ed40590ba968283c1fe7b54fed11d903d3cd9147b30339e src/aim_webgui/templates/pages/password.html
268ab10c351a1e2bbd5df26ad0c134455a7f2e7122173b59853f1dae484d3f5c src/aim_webgui/templates/pages/plan.html
6fb355b248007b755abb827a923aac8a6b4da5df4ec02606daa19b6a361147b3 src/aim_webgui/templates/pages/plan_detail.html
4ccfe0e41a0f9fec427880f49add50e835b3ca78747bffbc2bfc862c7454e8a8 src/aim_webgui/templates/pages/plans.html
ffe2f058a075bc69ddcc520874928e98c6840079deca7e54f33840be56777a86 src/aim_webgui/templates/pages/playbooks.html
4e25189e533f9f0a9a2137f4faa8a436fd2a9da3e706790a85c996909e4ac6b3 src/aim_webgui/templates/pages/preflight.html
e3cf11300103b24baee21156423f410c62d1901b7ee9c21d89662647d5c2686a src/aim_webgui/templates/pages/progress.html
86251827563519b1ab7fed7c57c28db891ccbc319753f29905b9187b20760ce3 src/aim_webgui/templates/pages/report.html
f02c5011a8022943b770fe67f6c862f9ce2801fd2b88a08079ff4a61b63b99cd src/aim_webgui/templates/pages/setup.html
4bb8da42b5b57bce49131be876ecdeadd292a8440f89bde384a3275fac78afc7 src/aim_webgui/templates/pages/system.html
e61b65c17fa24dbe99df9088b6e6e78915b8ca06fe29afabf84fa06a3f538bd1 src/aim_webgui/templates/pages/users.html
370c78ed200254418afa3faf6496591150fdcaf6d7b97af954bb83cbdf6a7e38 src/aim_webgui/templates/partials/activity_macros.html
218ce522d1f38d11e005b518a44a6d6d511745836a6a7495103ed33c8359bc38 src/aim_webgui/templates/partials/attention.html
47e9b1cc70960f0c0f2d37bc90a60f8ffda278d2a8cf9f4ea662670991438939 src/aim_webgui/templates/partials/credential_dialog.html
633092741d3a0d76582a4a608228360bbae4f6ec2cf47c23f5bee873b4d13bfb src/aim_webgui/templates/partials/credential_panel.html
804af94cf92c3e83577ff508872da5a015b7963da8fca35be79b8cb9ce22838e src/aim_webgui/templates/partials/customers.html
098a41c6cd6137d19af6f416c03ade15b4ac890b99fda1079cf7a297d89fe393 src/aim_webgui/templates/partials/error.html
ee1f145576c0ed017e501e85053aee9ff28bb2b16b09feefc75875d211fd5d46 src/aim_webgui/templates/partials/grant_fields.html
1baa4777f7fd31f56e3bed2b77dfae16f9fc2117c28d746d9aa7596035f90e6a src/aim_webgui/templates/partials/hosts.html
7281ecceb20c7f74d9f025a2f37dcacdffd4592d766015f307e15576d85f6455 src/aim_webgui/templates/partials/job.html
59c4cd9e7192ca65038f140b414c885b1c0bf1fe99d3c7e26fd720d6c87bc633 src/aim_webgui/templates/partials/navigation.html
5a7ccab88b0314ba6df9ebf2b9b731d93fc447bc5b69bdb391b754c8828b09db src/aim_webgui/templates/partials/patch_report.html
5d09d14049e13fffafbb0d5a788bd185b1ddec7fa2370708cdbfe9d9944309ab src/aim_webgui/templates/partials/preflight.html
1460829bf004a878742edc683917054e252c19a96412197239f4418ae1726560 src/aim_webgui/templates/partials/progress.html
0554bcf57db81e330cafd3df916975f7b4acc0313e9cbfdb9ab37b8fa8682339 src/aim_webgui/templates/partials/reports.html
61e594655689bda7f7483b52c381115a9392bff4378e5629937e702d270eb363 src/aim_webgui/worker.py
1a6ad0a12a43f9e25977552f5af4535f3fd447819e864b7e5fff4b2302cf68dd src/aim_webgui/workflows.py
5ed4630e94de88f231e7d01527efb6e8ab0ef2cb0ebd86c51d753a396a7c8886 tests/benchmark_journal.py
922138d85c03b14ad2df4861bbc28a9192647904a1e9e0d77ef1ea2a2a552061 tests/benchmark_read_history.py
4880f3fa6f7a5645a5d5e77532c1c6c118c2a781c7ad935654b46c2c2945de00 tests/browser_credentials_qa.py
fb3e951f74dfdb923356a35d11e25033ef555ec579d09490c545b4aacfd2b20e tests/browser_evidence_qa.py
979b612a5898c9519399f5aa4037b3bdf8265ae1721d9c2942105b0361d08a7a tests/browser_patch_qa.py
3258a1f7ea5997bc83f549060a9421c1c267de7a2a635b5640936427bfd1e3f5 tests/browser_qa.py
1a7a29381207f04e06c49502165c3f4cbf25f8db3bdd6d71ffa0d9b9a7c2cc8d tests/conftest.py
cc3e91a6a0c76c7cb37f843cbbff69c9401281287767fca30a07f6931b24f847 tests/evidence_fixtures.py
b8a3cf8c9fd5987a3103177c5ab035faf0a1835a21a48ced2f0a1747188ac908 tests/fixtures/patch-summary-rc1.json
e92ab1266cd139b77065ff4e5fa65c6489a1705e5dda3b0a33cd6e3d3e4d8fb1 tests/patch_fixtures.py
bc55efde812758dedfff615d249aec28feebfe0ec8c03e7c51c11f5f2b37dc6e tests/run_release_tests.py
776a7cfdd21704e44070f1e608b64345fdd0b620b356f7c9af6638de5bdab1bd tests/test_auth.py
03e5ffbc9b3bdc6627c8a755a481311c071b8ac6773b39c0e355e0804cac77a7 tests/test_core_contract.py
7fbb53e5a1382d648b0368c6a06c31a8444c780a1c545f9ddc446b59c2986812 tests/test_core_execution.py
67bfc54b30fb1252358f06c551a877a89b1eca8f9cddeeeb3fba21459f62ef61 tests/test_core_rc8_patch.py
2fa80cc2eb4b26b882634da157fb4cc293da4e12288a8fd8c3077ed1e60d096d tests/test_core_reports.py
5f88ab6355a31b79ea29f9536316e215d187c0b3059175747f203bc65e60212c tests/test_credential_ux.py
0df192ca4e46f9f2956012e7ca733d7cfcacc73536ac89bf73e9824c5f7cd888 tests/test_deployment_v2.py
2e1ddfe17869b6f0ef60527a8e10c129658b310b2bada8c857f643d336d4afa7 tests/test_executor_unix.py
4d8888c7588523ba9f1feb01139aa18187a89d349a7ce6017d9b87b1b6eb0e12 tests/test_journal_v5.py
aad1fa10665dacf53ec405586ccc60a112b9005d40bebf7a48691b19052a2af4 tests/test_migration_v4.py
d6756e12b74e6ca8134c4ac949fef93d0f25dd0fd9bedb287a73ed5294b93b54 tests/test_migration_v5.py
30539c491ed9d8e6edc2583d2a43de5ab17ca3fd76082b5c1e43259d965a1f7f tests/test_read_experience.py
01bf651842bd35fc1d40e7f8e5ec8fd1c5a95ab4243974d7228df5ceee0213db tests/test_report_transport.py
2be7f712af36b182db40a9418a6ab238d269ea73278f5e55a8a23ff1eca93c0f tests/test_reports_v33.py
88c67c59202b9f1cbb2f74bf5b96bb40e3c24798a9df288c16ac8e98a6f5271a tests/test_routes_v2.py
5f3595836e79758fe7c451cb9a851cd00485fc6c91c292acb7ea8903b578070f tests/test_transport.py
12adb2e7af91a9de4f6b782273ae91e626fd5ae896fcb4b0f85c7c89f89ee5ab tests/test_worker_v2.py
5c8ecd8a5440b1c0ae36870c20e4610b030fe8eae07808f7c63e73a844c28673 tests/test_workflows_v2.py
+84
View File
@@ -0,0 +1,84 @@
# AIM WebGUI 2.1.0rc9
Independent add-on for **AIM Core 3.3.0rc8**, service/wire/event API **1.0**.
WebGUI HTTP API **v2**; SQLite **schema 5**. This remains a release candidate,
not a native Windows Update/controller acceptance certificate.
## This update
Core 3.3.0rc8 keeps service/wire/event API 1.0 and the nine structured report
contracts, while tightening the native Windows baseline and several runbook internals.
WebGUI now qualifies exactly against rc8 and requires the live capability metadata to
advertise `ansible.windows >=3.8.0,<4.0.0`. Collection installation remains Core/operator
owned; the add-on does not upgrade it.
The patch report accepts rc8's additive `reboot_reasons_before` facts from native
`ansible.windows.win_reboot_info`, renders them as dated observations, and keeps them
separate from the existing reboot-required/blocked/continuation semantics. The overall
patch policy remains unchanged: no automatic retry, no automatic follow-up job, and no
silent post-reboot continuation.
Windows filesystem reporting is relabeled to match rc8: it represents attached local
storage volumes. Mapped/network drives are intentionally outside that host-capacity
report. Existing retained reports continue to render against their recorded contracts.
Core also relocates several AIM-managed Checkmk scripts and hardens their Windows ACLs.
Those are Core/runbook changes, not WebGUI API changes; WebGUI continues to display the
validated structured reports rather than reconstructing filesystem paths or ACL state.
## Preserved functionality
The credential modal, grouped passphrase controls, compact mobile hamburger/theme
header, inventory map, Host Activity, Playbook Insights, one-run review, optional
collision-safe plan names and per-target/partial-success views remain.
The worker-side structured journal survives page reloads and other-device viewing:
tail of20,000 events or8MiB by default. Final reports for nine schemas are retained
separately with a16MiB per-job budget; full parsed Checkmk configuration stays opt-in.
No raw output archive, credential cache, terminal-history collector, inventory/Vault
manager or alternative execution engine is added. Job deletion removes linked evidence.
## Install or upgrade
Fresh installations: follow [ADDON-INSTALLATION.md](ADDON-INSTALLATION.md) after Core's
separate `scripts/docs/INSTALLATION.md`.
Upgrades: read [Deployment](docs/DEPLOYMENT.md). Quiesce work, independently deploy
Core3.3.0rc8 with its own preview/apply procedure, then install from a fresh add-on
extraction. The old add-on's exact Core gate must not be bypassed.
```bash
cd /var/tmp
sha256sum -c AIM-WebGUI-2.1.0rc9.zip.sha256
unzip AIM-WebGUI-2.1.0rc9.zip
cd aim-web-2.1.0rc9
sudo python3 deploy/deploy.py update
```
No --migrate-core flag is needed from2.x. From2.1.0rc3, no database schema change is
needed. Accounts/plans/jobs/journals/reports/audit are retained. The approved full
webgui.toml is still release-managed and overwritten. Existing unit/permission/staging
contracts, dependency pins and browser pins are unchanged. No new ports or services.
Unknown local unit overrides still require operator review.
```bash
aim-web --version
aim-web config-check
sudo systemctl status aim-web-executor.service aim-web.service aim-web-worker.service --no-pager
sudo -u aim-web aim-web core-check
```
## Guides
- [Patch-wave inputs, reports and semantic boundaries](docs/PATCH-WAVES.md)
- [Reviewed Core rc8 delta](docs/CORE-3.3.0RC8-REVIEW.md)
- [Operation reports](docs/REPORTS.md) and [retained progress](docs/JOURNAL.md)
- [Credential modal](docs/RUN-COMFORT.md) and [read-only history](docs/READ-ONLY-EXPERIENCE.md)
- [Controller acceptance](docs/CONTROLLER-PILOT.md)
- [Current verification and limits](docs/VERIFICATION.md)
- [Public API](docs/API.md), [security](docs/SECURITY.md), [agent standards](AGENTS.md)
The release is not a complete offline dependency bundle. Bootstrap5.3.8 / HTMX2.0.10
remain locally served and integrity-checked by the installer. Tests are separate from
managed production environments; fixture browser assets and native-command simulators
are never release acceptance of actual target updates or the production service sandbox.
+25
View File
@@ -0,0 +1,25 @@
# Deployment constraints: tested resolved runtime versions, independent of AIM.
# No claim of being the newest releases. Review advisory updates before promotion.
fastapi==0.128.2
starlette==0.50.0
uvicorn==0.48.0
Jinja2==3.1.6
argon2-cffi==25.1.0
argon2-cffi-bindings==25.1.0
ruamel.yaml==0.18.17
ruamel.yaml.clib==0.2.15
pydantic==2.13.4
pydantic-core==2.46.4
annotated-types==0.7.0
annotated-doc==0.0.4
typing-extensions==4.16.0
typing-inspection==0.4.2
anyio==4.13.0
idna==3.17
click==8.1.8
h11==0.16.0
MarkupSafe==3.0.3
cffi==2.0.0
pycparser==3.0
setuptools==82.0.1
packaging==25.0
+890
View File
@@ -0,0 +1,890 @@
#!/usr/bin/env python3
"""Root-only, staged replacement of the WebGUI add-on. Never runs pip for AIM.
Run from a freshly unpacked release directory, not the active add-on directory.
Linux + systemd + Python >=3.11. See docs/DEPLOYMENT.md before use.
"""
from __future__ import annotations
import argparse
from dataclasses import dataclass
from datetime import datetime, timezone
import fcntl
import grp
import hashlib
import json
import os
from pathlib import Path
import pwd
import re
import shutil
import sqlite3
import subprocess
import sys
import tempfile
import time
import tomllib
from urllib.request import Request, ProxyHandler, build_opener
from urllib.parse import urlsplit
from fetch_assets import prepare
SERVICE = 'aim-web.service'
PREFIX = Path('/opt/aim-web')
STATE = Path('/var/lib/aim/webgui')
BACKUPS = Path('/var/backups/aim-web')
UNIT = Path('/etc/systemd/system') / SERVICE
META = PREFIX / 'deployment.json'
PENDING = PREFIX / 'pending.json'
CLI_LINK = Path('/usr/local/bin/aim-web')
EXECUTOR_UNIT = UNIT.with_name('aim-web-executor.service')
EXECUTOR_STATE = Path('/var/lib/aim-web-executor')
LEGACY_FILES = (
Path('/usr/local/libexec/aim-web-key-export'),
Path('/etc/sudoers.d/aim-web-key-export'),
Path('/etc/systemd/system/aim-web-worker.service.d/10-key-export-capabilities.conf'),
)
def executor_service(action):
if EXECUTOR_UNIT.is_file():
run(['systemctl', action, EXECUTOR_UNIT.name])
def unit_text(user, group, config, command, *, executor=False, executor_group=None, supplementary_group=None, executor_local_home=None):
description = 'AIM WebGUI core executor (AIM remains separately managed)' if executor else 'AIM WebGUI ' + command
state = EXECUTOR_STATE if executor else STATE
runtime = 'RuntimeDirectory=aim-web-executor\nRuntimeDirectoryMode=0711\n' if executor else ''
# Native core takes its own inventory locks. DAC permissions still decide write
# access. The add-on does not chown or otherwise modify the inventory tree.
executor_home = EXECUTOR_STATE
staging = executor_home / '.ansible/tmp'
executor_local_tmp = Path(executor_local_home) / '.ansible/tmp' if executor and executor_local_home is not None else None
writable = f'{state} {staging} {executor_local_tmp} -/etc/ansible/inventories' if executor else str(state)
after = 'After=network.target\n' if executor else 'After=network.target aim-web-executor.service\nWants=aim-web-executor.service\n'
executor_env = f'Environment=HOME={executor_home}\n' if executor else ''
executor_pre = f'ExecStartPre={PREFIX}/current/bin/aim-web --config {config} core-staging-check\n' if executor else ''
service_group = executor_group if executor and executor_group is not None else group
supplementary = f'SupplementaryGroups={supplementary_group}\n' if executor and supplementary_group is not None else ''
return f'''[Unit]
Description={description}
{after}
[Service]
Type=simple
User={user}
Group={service_group}
{supplementary}WorkingDirectory=/
ExecStart={PREFIX}/current/bin/aim-web --config {config} {command}
Environment=PYTHONDONTWRITEBYTECODE=1
Environment=PYTHONNOUSERSITE=1
{executor_env}{executor_pre}UMask=0077
Restart=on-failure
RestartSec=3
KillMode=control-group
TimeoutStopSec=30
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome={'read-only' if executor else 'true'}
ReadWritePaths={writable}
{runtime}RestrictSUIDSGID=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
CapabilityBoundingSet=
AmbientCapabilities=
LockPersonality=true
LimitCORE=0
[Install]
WantedBy=multi-user.target
'''
def provision_executor_staging(executor_user):
account=pwd.getpwnam(executor_user)
base=EXECUTOR_STATE / '.ansible'
staging=base / 'tmp'
local_base=Path(account.pw_dir) / '.ansible'
local_staging=local_base / 'tmp'
for path in (EXECUTOR_STATE,base,staging,local_base,local_staging):
path.mkdir(mode=0o700,parents=True,exist_ok=True)
os.chown(path,account.pw_uid,account.pw_gid)
path.chmod(0o700)
def require_owner_mode(path: Path, uid: int, gid: int, mode: int, *, kind: str = 'path'):
if path.is_symlink() or not path.exists():
raise ValueError(f'Managed {kind} is missing or a symlink: {path}')
st=path.stat()
actual=st.st_mode & 0o777
if st.st_uid!=uid or st.st_gid!=gid or actual!=mode:
raise ValueError(
f'Managed {kind} has unexpected ownership/mode: {path}; '
f'expected uid={uid} gid={gid} mode={mode:04o}, got '
f'uid={st.st_uid} gid={st.st_gid} mode={actual:04o}.')
def validate_managed_permissions(service_user: str, executor_user: str, config: Path):
web=pwd.getpwnam(service_user); executor=pwd.getpwnam(executor_user)
require_owner_mode(STATE,web.pw_uid,web.pw_gid,0o700,kind='WebGUI state directory')
require_owner_mode(config,0,web.pw_gid,0o640,kind='WebGUI configuration')
require_owner_mode(EXECUTOR_STATE,executor.pw_uid,executor.pw_gid,0o700,kind='executor state directory')
require_owner_mode(EXECUTOR_STATE/'.ansible',executor.pw_uid,executor.pw_gid,0o700,kind='executor Ansible directory')
require_owner_mode(EXECUTOR_STATE/'.ansible/tmp',executor.pw_uid,executor.pw_gid,0o700,kind='executor controller-local staging directory')
require_owner_mode(Path(executor.pw_dir)/'.ansible',executor.pw_uid,executor.pw_gid,0o700,kind='executor delegated-local Ansible directory')
require_owner_mode(Path(executor.pw_dir)/'.ansible/tmp',executor.pw_uid,executor.pw_gid,0o700,kind='executor delegated-local staging directory')
for path in (UNIT,worker_unit(),EXECUTOR_UNIT):
require_owner_mode(path,0,0,0o644,kind='systemd unit')
def validate_executor_runtime_permissions(service_user: str, executor_user: str, socket_path: Path):
web=pwd.getpwnam(service_user); executor=pwd.getpwnam(executor_user)
require_owner_mode(socket_path.parent,executor.pw_uid,executor.pw_gid,0o711,kind='executor runtime directory')
require_owner_mode(socket_path,executor.pw_uid,web.pw_gid,0o660,kind='executor socket')
def wait_executor_runtime_permissions(service_user: str, executor_user: str, socket_path: Path, *, timeout: float = 10.0):
"""Wait for the Type=simple executor to bind and permission its managed socket.
systemctl start returns after the process is launched, not after Executor.run()
has completed capability negotiation and listener.bind(). Treat a missing socket
during that short window as startup-in-progress, not as a migration failure.
"""
deadline=time.monotonic()+timeout
last=None
while True:
try:
validate_executor_runtime_permissions(service_user,executor_user,socket_path)
return
except ValueError as exc:
last=exc
active=subprocess.run(['systemctl','is-active','--quiet',EXECUTOR_UNIT.name]).returncode==0
if not active:
raise ValueError('Executor service exited before its managed socket became ready.') from last
if time.monotonic()>=deadline:
raise ValueError(f'Executor managed socket did not become ready within {timeout:g}s: {socket_path}') from last
time.sleep(0.1)
def validate_legacy_files():
for path in LEGACY_FILES:
if path.is_symlink():
raise ValueError(f'Refusing a symlink at legacy bridge: {path}')
if not path.exists():
continue
text = path.read_text()
if path.name == 'aim-web-key-export':
if 'key' not in text or ('aim-web' not in text and 'private' not in text.lower()):
raise ValueError(f'Unrecognized legacy bridge; review manually: {path}')
elif 'CapabilityBoundingSet=CAP_SETUID CAP_SETGID' not in text:
raise ValueError(f'Unexpected worker capability override; review manually: {path}')
# Other unit overrides can silently retain the old identity/capabilities.
for name in ('aim-web.service','aim-web-worker.service','aim-web-executor.service'):
directory = UNIT.parent / (name + '.d')
if directory.exists():
unexpected = [p for p in directory.glob('*.conf') if p not in LEGACY_FILES]
if unexpected:
raise ValueError('Review and temporarily move unmanaged unit drop-ins before migration: ' + ', '.join(map(str,unexpected)))
def restore_extra(snapshot, record):
for number,path in enumerate((EXECUTOR_UNIT,*LEGACY_FILES)):
saved=snapshot/f'extra-{number}'
if saved.exists():
path.parent.mkdir(parents=True,exist_ok=True)
atomic_bytes(path,saved.read_bytes(),record['extra_modes'][str(path)])
os.chown(path,0,0)
else:
path.unlink(missing_ok=True)
def run(args, *, capture=False, **kwargs):
return subprocess.run([str(a) for a in args], check=True, text=True, capture_output=capture, **kwargs)
def atomic_bytes(path: Path, data: bytes, mode=0o600):
if path.is_symlink():
raise ValueError(f'Refusing a symlink at managed file: {path}')
fd, name = tempfile.mkstemp(prefix='.aim-web-', dir=path.parent)
temp = Path(name)
try:
with os.fdopen(fd, 'wb') as stream:
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
temp.chmod(mode)
os.replace(temp, path)
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
temp.unlink(missing_ok=True)
def write_json(path, value):
atomic_bytes(path, (json.dumps(value, indent=2) + '\n').encode())
def sqlite_backup(source: Path, destination: Path):
"""Create a consistent SQLite checkpoint without importing any WebGUI runtime."""
if not source.is_file():
raise ValueError(f'Authentication database is missing: {source}')
destination.unlink(missing_ok=True)
src = sqlite3.connect(f'file:{source}?mode=ro', uri=True)
dst = sqlite3.connect(destination)
try:
src.backup(dst)
dst.commit()
finally:
dst.close()
src.close()
destination.chmod(0o600)
def current_env() -> Path | None:
link = PREFIX / 'current'
return link.resolve() if link.is_symlink() else None
def switch_env(env: Path):
link = PREFIX / '.current-next'
if link.exists() or link.is_symlink():
link.unlink()
link.symlink_to(env)
os.replace(link, PREFIX / 'current')
def ensure_cli_link():
"""Expose the active release on PATH without copying a versioned executable."""
target = PREFIX / 'current/bin/aim-web'
if CLI_LINK.exists() and not CLI_LINK.is_symlink():
raise ValueError(f'Refusing to overwrite an unmanaged CLI file: {CLI_LINK}')
if CLI_LINK.is_symlink():
if Path(os.readlink(CLI_LINK)) != target:
raise ValueError(f'Refusing to replace an unrelated CLI symlink: {CLI_LINK}')
return
CLI_LINK.parent.mkdir(parents=True, mode=0o755, exist_ok=True)
temporary = CLI_LINK.parent / ('.aim-web-link-' + new_id())
try:
temporary.symlink_to(target)
os.replace(temporary, CLI_LINK)
finally:
temporary.unlink(missing_ok=True)
def remove_managed_cli_link():
target = PREFIX / 'current/bin/aim-web'
if CLI_LINK.is_symlink() and Path(os.readlink(CLI_LINK)) == target:
CLI_LINK.unlink()
def worker_unit() -> Path:
return UNIT.with_name('aim-web-worker.service')
def worker_active() -> bool:
return worker_unit().is_file() and subprocess.run(
['systemctl', 'is-active', '--quiet', 'aim-web-worker.service'], check=False).returncode == 0
def worker_service(action):
if worker_unit().is_file():
run(['systemctl', action, 'aim-web-worker.service'])
def active() -> bool:
return subprocess.run(['systemctl', 'is-active', '--quiet', SERVICE], check=False).returncode == 0
def service(action):
run(['systemctl', action, SERVICE])
def web_config_value(config: dict, section: str, key: str, legacy: str, default):
values = config.get(section, {})
if isinstance(values, dict) and key in values:
return values[key]
return config.get(legacy, default)
def safe_absolute(path: Path) -> Path:
# Protect systemd syntax and prevent unexpectedly following operator symlinks.
if not path.is_absolute() or not re.fullmatch(r'/[A-Za-z0-9_./-]+', str(path)):
raise ValueError('Deployment paths must be absolute and contain only letters, digits, /, _, . and -.')
if path.resolve() != path:
raise ValueError(f'Use a canonical path without symlinks or dot components: {path}')
return path
def release_order(version: str) -> tuple[int, ...]:
match = re.fullmatch(r'(\d+)\.(\d+)\.(\d+)(?:rc(\d+))?', version)
if not match:
raise ValueError('Use independent x.y.z or x.y.zrcN add-on versions.')
major, minor, patch, rc = match.groups()
return (int(major), int(minor), int(patch), int(rc is None), int(rc or 0))
def new_id() -> str:
return datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S%fZ')
@dataclass
class Deployment:
scripts: Path
user: str
required_gid: int
@property
def target(self):
return self.scripts / 'addons/webgui'
@property
def config(self):
return self.scripts / 'config/webgui.toml'
def as_user(self, command, *, capture=False):
account = pwd.getpwnam(self.user)
groups = list(set(os.getgrouplist(self.user, account.pw_gid) + [self.required_gid]))
def drop():
os.setgroups(groups)
os.setgid(account.pw_gid)
os.setuid(account.pw_uid)
os.umask(0o077)
env = {'PATH':'/usr/sbin:/usr/bin:/sbin:/bin', 'HOME':str(STATE), 'LANG':'C.UTF-8',
'PYTHONDONTWRITEBYTECODE':'1', 'PYTHONNOUSERSITE':'1'}
return run(command, preexec_fn=drop, cwd='/', env=env, capture=capture)
def as_executor(self, user, command, *, capture=False):
account=pwd.getpwnam(user)
web=pwd.getpwnam(self.user)
def drop():
# Match the managed systemd identity: preserve the executor account's
# normal primary GID and add only the WebGUI group needed for the
# release-managed config/socket boundary. No /etc/group mutation.
os.setgroups(list(set(os.getgrouplist(user,account.pw_gid) + [web.pw_gid])))
os.setgid(account.pw_gid)
os.setuid(account.pw_uid)
os.umask(0o077)
environment={'PATH':'/usr/local/bin:/usr/bin:/bin','HOME':str(EXECUTOR_STATE),'LANG':'C.UTF-8',
'PYTHONDONTWRITEBYTECODE':'1','PYTHONNOUSERSITE':'1'}
return run(command,preexec_fn=drop,cwd='/',env=environment,capture=capture)
def cli(self, env, *args, capture=False):
return self.as_user([env / 'bin/aim-web', '--config', self.config, *args], capture=capture)
def snapshot(self, old: dict | None) -> Path:
stamp = BACKUPS / new_id()
stamp.mkdir(mode=0o700)
data = {'previous':old, 'was_active':active(), 'worker_active':worker_active(), 'worker_present':worker_unit().exists(), 'config_present':self.config.exists(),
'unit_present':UNIT.exists(), 'database_present':STATE.joinpath('webgui.sqlite3').exists()}
if self.target.exists():
shutil.copytree(self.target, stamp / 'source', symlinks=True)
if self.config.exists():
shutil.copy2(self.config, stamp / 'webgui.toml')
if UNIT.exists():
shutil.copy2(UNIT, stamp / 'aim-web.service')
if worker_unit().exists():
shutil.copy2(worker_unit(), stamp / 'aim-web-worker.service')
if data['database_present']:
# Use SQLite's online backup API directly. Snapshotting must not depend on
# the previous WebGUI runtime being able to parse the current config.
sqlite_backup(STATE / 'webgui.sqlite3', stamp / 'webgui.sqlite3')
os.chown(stamp / 'webgui.sqlite3', 0, 0)
data['executor_active'] = EXECUTOR_UNIT.exists() and subprocess.run(['systemctl','is-active','--quiet',EXECUTOR_UNIT.name],check=False).returncode==0
data['extra_modes']={}
for number,path in enumerate((EXECUTOR_UNIT,*LEGACY_FILES)):
if path.exists():
if path.is_symlink():raise ValueError(f'Unexpected symlink: {path}')
shutil.copy2(path,stamp/f'extra-{number}')
data['extra_modes'][str(path)] = path.stat().st_mode & 0o777
write_json(stamp / 'snapshot.json', data)
return stamp
def restore_db(self, snapshot: Path):
source = snapshot / 'webgui.sqlite3'
if not source.is_file():
raise ValueError('This snapshot has no database to restore.')
target = STATE / 'webgui.sqlite3'
temporary = STATE / '.restore.sqlite3'
temporary.unlink(missing_ok=True)
shutil.copyfile(source, temporary)
account = pwd.getpwnam(self.user)
os.chown(temporary, account.pw_uid, account.pw_gid)
temporary.chmod(0o600)
# Service must be stopped. Preserve failed data in the snapshot before replacement.
for suffix in ('', '-wal', '-shm', '-journal'):
file = Path(str(target) + suffix)
if file.exists():
shutil.copy2(file, snapshot / ('pre-restore-' + new_id() + suffix + '.sqlite3'))
if suffix:
file.unlink()
os.replace(temporary, target)
def restore(self, snapshot: Path, *, database: bool, config: bool = False):
record = json.loads((snapshot / 'snapshot.json').read_text())
old = record['previous']
worker_service('stop')
executor_service('stop')
restore_extra(snapshot,record)
if old is None:
# An early first-install failure may not have created/loaded a unit.
if UNIT.exists() or active():
service('stop')
# A failed first install preserves private state for a deliberate retry.
if self.target.exists():
shutil.rmtree(self.target)
(PREFIX / 'current').unlink(missing_ok=True)
remove_managed_cli_link()
META.unlink(missing_ok=True)
subprocess.run(['systemctl','disable',SERVICE,'aim-web-worker.service',EXECUTOR_UNIT.name],check=False,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
UNIT.unlink(missing_ok=True)
worker_unit().unlink(missing_ok=True)
if config:
self.config.unlink(missing_ok=True)
run(['systemctl', 'daemon-reload'])
return
service('stop')
if database:
self.restore_db(snapshot)
staged = self.target.parent / ('.webgui-restore-' + new_id())
shutil.copytree(snapshot / 'source', staged, symlinks=True)
displaced = self.target.parent / ('.webgui-displaced-' + new_id())
if self.target.exists():
os.replace(self.target, displaced)
os.replace(staged, self.target)
if displaced.exists():
shutil.rmtree(displaced)
switch_env(Path(old['venv']))
ensure_cli_link()
if config and record['config_present']:
atomic_bytes(self.config, (snapshot / 'webgui.toml').read_bytes(), 0o640)
os.chown(self.config, 0, pwd.getpwnam(self.user).pw_gid)
if record['unit_present']:
atomic_bytes(UNIT, (snapshot / 'aim-web.service').read_bytes(), 0o644)
if record.get('worker_present'):
atomic_bytes(worker_unit(), (snapshot / 'aim-web-worker.service').read_bytes(), 0o644)
else:
if worker_unit().exists():
run(['systemctl', 'disable', 'aim-web-worker.service'])
worker_unit().unlink()
write_json(META, old)
run(['systemctl', 'daemon-reload'])
# Revoking sessions also avoids restoring live session tokens from backups.
self.as_user([Path(old['venv']) / 'bin/python', '-c',
'from aim_webgui.auth.service import Auth; from aim_webgui.config import Settings; '
'from pathlib import Path; import sys; a=Auth(Settings.load(Path(sys.argv[1]))); '
'db=a.store.connect(); db.execute("DELETE FROM sessions"); db.commit(); db.close()', self.config])
if release_order(old['version'])[0] < 2:
# The core was separately upgraded to 3.3.0rc8. The legacy 1.x adapter
# cannot be declared healthy here. Restored history/code remains stopped.
subprocess.run(['systemctl','disable',SERVICE,'aim-web-worker.service'],check=False,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
print('Restored legacy WebGUI but left services STOPPED and disabled: coordinate a separate AIM core rollback before restarting. AIM was not changed.',file=sys.stderr)
return
# Source and state restoration must not restart an adapter whose exact
# independently managed Core contract is no longer available. Probe using
# the restored package/config and the executor identity, never root.
if not self.restored_core_compatible(old):
subprocess.run(['systemctl','disable',SERVICE,'aim-web-worker.service',EXECUTOR_UNIT.name],
check=False,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
print('Restored add-on source/config/state; services remain STOPPED and disabled. '
'The restored adapter does not qualify the currently installed Core. '
'Coordinate the independent Core rollback, then explicitly re-enable services. '
'No Core files were changed.',file=sys.stderr)
return
if record.get('executor_active'):
executor_service('start')
self.cli(Path(old['venv']), 'check')
if record['was_active']:
service('start')
self.health()
if record.get('worker_active'):
worker_service('start')
def restored_core_compatible(self, old):
try:
self.as_executor(old.get('executor_user','svc_bf-ansible'),
[Path(old['venv'])/'bin/python','-B','-c',
'from dataclasses import replace; from pathlib import Path; import sys; '
'from aim_webgui.config import Settings; from aim_webgui.adapters.core_v1 import CoreAdapter; '
'CoreAdapter(replace(Settings.load(Path(sys.argv[1])),core_transport="stdio"))',self.config],capture=True)
return True
except (OSError,ValueError,subprocess.SubprocessError):
return False
def health(self):
with self.config.open('rb') as stream:
config = tomllib.load(stream)
host = web_config_value(config, 'server', 'host', 'host', '127.0.0.1')
port = web_config_value(config, 'server', 'port', 'port', 8080)
# A wildcard listener is not a routable health-check destination.
check_host = '127.0.0.1' if host in {'0.0.0.0', '::'} else host
authority = f'[{check_host}]' if ':' in check_host else check_host
url = f'http://{authority}:{port}/readyz'
origin = urlsplit(web_config_value(config, 'server', 'public_url', 'public_url', 'http://127.0.0.1:8080'))
opener = build_opener(ProxyHandler({}))
for _ in range(12):
try:
req = Request(url, headers={'Host':origin.netloc})
with opener.open(req, timeout=10) as response:
if response.status == 200 and json.load(response).get('status') == 'ready':
return
except Exception:
pass
time.sleep(.5)
raise RuntimeError('Readiness check failed. Inspect journalctl -u aim-web.service.')
def verify_release(source):
"""Verify the ZIP payload manifest before staging/provisioning any resource.
The separately verified ZIP digest checks the expected release file;
this manifest detects extraction damage, not publisher signatures.
"""
manifest=source/'MANIFEST.sha256'
if not manifest.is_file() or manifest.is_symlink():
raise ValueError('Missing release MANIFEST.sha256. Use a fresh release ZIP.')
seen=set()
for line in manifest.read_text(encoding='utf-8').splitlines():
if not line: continue
digest,sep,name=line.partition(' ')
rel=Path(name)
normalized=rel.as_posix()
if not sep or not re.fullmatch('[0-9a-f]{64}',digest) or not name or rel.is_absolute() or '..' in rel.parts or normalized in seen:
raise ValueError('Invalid release manifest entry.')
seen.add(normalized)
path=source/rel
if any(p.is_symlink() for p in (path,*path.parents)) or not path.is_file():
raise ValueError('Release manifest references an unsafe or missing file.')
if hashlib.sha256(path.read_bytes()).hexdigest()!=digest:
raise ValueError('Release integrity mismatch: '+name)
if not {'pyproject.toml','deploy/deploy.py','src/aim_webgui/__init__.py'}<=seen:
raise ValueError('Release manifest does not cover required files.')
def install(args):
scripts = safe_absolute(args.aim_scripts)
source = Path(__file__).resolve().parents[1]
verify_release(source)
target = scripts / 'addons/webgui'
if source == target:
raise ValueError('Unpack the new ZIP in a separate staging directory; do not update from the active source.')
if not scripts.is_dir():
raise ValueError('Deploy the separately managed AIM core first.')
core_launcher = args.aimctl.absolute()
if not core_launcher.is_file() or not os.access(core_launcher,os.X_OK):
raise ValueError('The configured aimctl launcher is missing or not executable. Deploy AIM 3.3.0rc8 first; this installer never creates it.')
safe_absolute(args.core_config)
if not re.fullmatch(r'[a-z_][a-z0-9_-]{0,30}',args.executor_user):raise ValueError('Invalid executor account name.')
try: executor_account=pwd.getpwnam(args.executor_user)
except KeyError: raise ValueError('Provision and authorize the existing non-root AIM execution/key-owning account first.') from None
if executor_account.pw_uid==0 or args.executor_user==args.service_user:
raise ValueError('Managed topology needs distinct non-root web and execution accounts.')
validate_legacy_files()
if any(p.exists() for p in LEGACY_FILES) and not args.migrate_core:
raise ValueError('Legacy key-export/capability files exist. Review migration and pass --migrate-core to retire only these backed-up files.')
with (source / 'pyproject.toml').open('rb') as stream:
project = tomllib.load(stream)['project']
version = project['version']
if not re.fullmatch(r'[0-9A-Za-z.+-]+', version):
raise ValueError('Invalid add-on version.')
previous = json.loads(META.read_text()) if META.exists() else None
if args.command == 'update' and previous is None:
raise ValueError('No managed add-on installation exists. Use install first.')
if args.command == 'install' and previous:
raise ValueError('Add-on already installed. Use update; existing accounts will be retained.')
if previous and release_order(previous['version'])[0] < 2 and not args.migrate_core:
raise ValueError('Major core integration migration: pass --migrate-core after reviewing MIGRATION-3.1.md. Old queued work is stopped; schema changes require a backup.')
if previous and (previous['scripts'] != str(scripts) or previous['user'] != args.service_user):
raise ValueError('Update must retain the installed AIM scripts path and service identity.')
if previous and release_order(version) < release_order(previous['version']):
raise ValueError('Downgrades use rollback, not update.')
if previous and previous['version'] == version:
raise ValueError('This add-on version is already installed. Published versions are immutable; use a new release number.')
if target.exists() and (not previous or not (target / '.aim-web-managed').is_file()):
raise ValueError('Target is not a recognized managed WebGUI directory. Back it up and move it aside manually.')
if not previous and UNIT.exists():
raise ValueError('An unmanaged aim-web.service already exists; refusing to overwrite it.')
if target.is_symlink():
raise ValueError('The active add-on source must be a real directory, not a symlink.')
for path in source.rglob('*'):
if path.is_symlink():
raise ValueError(f'Release contains an unexpected symlink: {path}')
if not target.parent.exists():
target.parent.mkdir(parents=True, mode=0o755)
target.parent.chmod(0o755)
stage = Path(tempfile.mkdtemp(prefix='.webgui-stage-', dir=target.parent))
environment = PREFIX / 'venvs' / (version + '-' + new_id())
snapshot = None
deployment = None
activated = False
try:
shutil.copytree(source, stage, dirs_exist_ok=True,
ignore=shutil.ignore_patterns('__pycache__', '*.pyc', '.pytest_cache', '.credentials', '*.egg-info', 'build', 'dist', 'wheelhouse', 'offline-assets', '*.zip'))
# Network/offline artifacts are prepared BEFORE stopping the running service.
prepare(stage / 'src/aim_webgui/static/vendor', args.assets_dir,
reuse=target / 'src/aim_webgui/static/vendor' if previous else None)
environment.parent.mkdir(parents=True, mode=0o755, exist_ok=True)
environment.parent.chmod(0o755)
# Runtime code is root-owned but readable/executable by the service account.
os.umask(0o022)
run([args.python, '-m', 'venv', environment])
pip = [environment / 'bin/python', '-m', 'pip', 'install', '--no-compile', '--no-cache-dir']
if args.wheelhouse:
pip.extend(['--no-index', '--find-links', args.wheelhouse])
run([*pip, '-c', stage / 'constraints.txt', 'setuptools'])
run([*pip, '--no-build-isolation', '-c', stage / 'constraints.txt', stage])
run([environment / 'bin/python', '-m', 'pip', 'check'])
identity = run([environment / 'bin/python', '-B', '-c',
'import aim_webgui,importlib.metadata; '
'print(aim_webgui.__version__); print(importlib.metadata.version("aim-webgui"))'], capture=True)
if identity.stdout.splitlines() != [version, version]:
raise ValueError('Candidate package identity does not match this release; nothing was activated.')
os.umask(0o077)
# Do not parse or import private core configuration. Public protocol probe follows.
if not re.fullmatch(r'[a-z_][a-z0-9_-]{0,30}', args.service_user):
raise ValueError('Use a local service account name, not a shell expression.')
try:
account = pwd.getpwnam(args.service_user)
except KeyError:
shell = shutil.which('nologin') or '/usr/sbin/nologin'
run(['useradd','--system','--user-group','--home-dir',STATE,'--shell',shell,args.service_user])
account = pwd.getpwnam(args.service_user)
if account.pw_uid == 0:
raise ValueError('The web service must not run as root.')
if not STATE.parent.exists():
STATE.parent.mkdir(parents=True, mode=0o755)
STATE.parent.chmod(0o755)
STATE.mkdir(mode=0o700, exist_ok=True)
if STATE.is_symlink():
raise ValueError('State directory must not be a symlink.')
os.chown(STATE, account.pw_uid, account.pw_gid)
STATE.chmod(0o700)
required_gid = account.pw_gid
deployment = Deployment(scripts, args.service_user, required_gid)
if not deployment.config.parent.exists():
deployment.config.parent.mkdir(parents=True, mode=0o755)
deployment.config.parent.chmod(0o755)
# Stage and parse the candidate config without changing the installed config.
text=(stage/'deploy/webgui.example.toml').read_text().replace('/etc/ansible/scripts',str(scripts))
text=text.replace('command = ["/usr/local/bin/aimctl"]',f'command = [{json.dumps(str(core_launcher))}]')
text=text.replace('config = "/etc/ansible/scripts/aim.yml"',f'config = {json.dumps(str(args.core_config))}')
text=text.replace('executor_user = "svc_bf-ansible"',f'executor_user = {json.dumps(args.executor_user)}')
text=text.replace('client_user = "aim-web"',f'client_user = {json.dumps(args.service_user)}')
release_config=tomllib.loads(text)
if release_config['state']['state_dir']!=str(STATE):raise ValueError('Unexpected managed state location.')
candidate=stage/'candidate.toml'
candidate.write_text(text);candidate.chmod(0o640);os.chown(candidate,0,account.pw_gid);stage.chmod(0o755)
deployment.as_user([environment/'bin/aim-web','--config',candidate,'check','--without-db','--without-core'])
# Test public capabilities and authorization in the EXISTING core environment,
# as the eventual executor identity. No dependency installation or core writes.
probe=deployment.as_executor(args.executor_user,[environment/'bin/python','-B','-c',
'from dataclasses import replace; from pathlib import Path; import sys; '
'from aim_webgui.config import Settings; from aim_webgui.adapters.core_v1 import CoreAdapter; '
'a=CoreAdapter(replace(Settings.load(Path(sys.argv[1])),core_transport="stdio")); '
'print(a.version); print("Authorized customer listing:",len(a.customers()))',candidate],capture=True)
print(probe.stdout.strip())
candidate.unlink()
snapshot=deployment.snapshot(previous)
write_json(PENDING,{'backup':snapshot.name,'candidate':str(environment),'phase':'before-stop',
'scripts':str(scripts),'user':args.service_user,'required_gid':required_gid})
if previous:
worker_service('stop');service('stop');executor_service('stop')
sqlite_backup(STATE/'webgui.sqlite3',snapshot/'webgui.sqlite3')
os.chown(snapshot/'webgui.sqlite3',0,0)
atomic_bytes(deployment.config,text.encode(),0o640);os.chown(deployment.config,0,account.pw_gid)
# Neither group memberships nor inventory/key ownership are touched.
if EXECUTOR_STATE.exists() and (EXECUTOR_STATE.is_symlink() or EXECUTOR_STATE.stat().st_uid!=executor_account.pw_uid):
raise ValueError('Existing executor state directory has an unexpected owner or type.')
EXECUTOR_STATE.mkdir(mode=0o700,exist_ok=True);os.chown(EXECUTOR_STATE,executor_account.pw_uid,executor_account.pw_gid);EXECUTOR_STATE.chmod(0o700)
provision_executor_staging(args.executor_user)
if previous:deployment.cli(environment,'db','migrate')
else:deployment.cli(environment,'init')
deployment.cli(environment,'check','--without-core')
for directory in list(stage.rglob('__pycache__')) + list(stage.glob('src/*.egg-info')) + [stage / 'build', stage / 'dist']:
if directory.is_dir():
shutil.rmtree(directory)
# Source is replaced wholesale. No stale files, git, patch hunks or core overlay.
for item in stage.rglob('*'):
if not item.is_symlink():
item.chmod(0o755 if item.is_dir() else 0o644)
(stage / '.aim-web-managed').write_text('aim-webgui ' + version + '\n')
(stage / '.credentials').symlink_to(STATE / '.credentials')
stage.chmod(0o755)
if target.exists():
displaced = target.parent / ('.webgui-old-' + new_id())
os.replace(target, displaced)
else:
displaced = None
os.replace(stage, target)
activated = True
if displaced:
shutil.rmtree(displaced)
switch_env(environment)
ensure_cli_link()
atomic_bytes(UNIT,unit_text(args.service_user,account.pw_gid,deployment.config,'serve').encode(),0o644)
atomic_bytes(worker_unit(),unit_text(args.service_user,account.pw_gid,deployment.config,'worker').encode(),0o644)
atomic_bytes(EXECUTOR_UNIT,unit_text(args.executor_user,account.pw_gid,deployment.config,'executor',executor=True,executor_group=executor_account.pw_gid,supplementary_group=account.pw_gid,executor_local_home=executor_account.pw_dir).encode(),0o644)
for managed_unit in (UNIT,worker_unit(),EXECUTOR_UNIT): os.chown(managed_unit,0,0);managed_unit.chmod(0o644)
validate_managed_permissions(args.service_user,args.executor_user,deployment.config)
# Approved major migration retires the known old bridge and elevated worker
# override; snapshot restoration restores matching legacy files if needed.
for path in LEGACY_FILES:path.unlink(missing_ok=True)
write_json(META, {'version':version,'scripts':str(scripts),'user':args.service_user,
'required_gid':required_gid,'venv':str(environment),'backup':snapshot.name,
'executor_user':args.executor_user,'core_version':'3.3.0rc8','api_version':'1.0'})
write_json(PENDING, {'backup':snapshot.name,'candidate':str(environment),'phase':'activated',
'scripts':str(scripts),'user':args.service_user,'required_gid':required_gid})
run(['systemctl','daemon-reload'])
run(['systemctl','enable',EXECUTOR_UNIT.name])
executor_service('start')
wait_executor_runtime_permissions(args.service_user,args.executor_user,Path(release_config['core']['socket']))
run(['systemctl','enable',SERVICE])
service('start')
deployment.health()
if release_config.get('execution', {}).get('enabled', False):
run(['systemctl', 'enable', 'aim-web-worker.service'])
worker_service('start')
else:
worker_service('stop')
run(['systemctl', 'disable', 'aim-web-worker.service'])
deployment.as_user([environment / 'bin/python', '-B', '-c',
'import sys; from pathlib import Path; from aim_webgui.config import Settings; '
'from aim_webgui.db.store import Store,audit; s=Store(Settings.load(Path(sys.argv[1])).database); '
'db=s.connect(); audit(db,"deployer","release-installed",sys.argv[2]); db.commit(); db.close()',
deployment.config, version])
PENDING.unlink()
print(f'Installed AIM WebGUI {version}; separately managed AIM 3.3.0rc8 was not modified.')
print(f'Add-on source: {target}')
print(f'Configuration: {deployment.config}')
print(f'Rollback snapshot: {snapshot.name}')
if (STATE / '.credentials').exists():
print(f'Initial admin credentials (local file only): {(target / ".credentials").as_uri()}')
print(f'Read locally: sudo cat {STATE}/.credentials')
print(f'CLI: {CLI_LINK} -> {PREFIX}/current/bin/aim-web')
print('Service: systemctl status aim-web.service')
except BaseException:
if snapshot and deployment and PENDING.exists():
print('Deployment failed. Attempting to restore the prior add-on; AIM was not modified.', file=sys.stderr)
try:
deployment.restore(snapshot, database=bool(previous), config=True)
PENDING.unlink(missing_ok=True)
except Exception:
print(f'Automatic recovery did not complete. Keep service stopped; recovery snapshot: {snapshot}', file=sys.stderr)
raise
finally:
if stage.exists():
shutil.rmtree(stage)
# Failed venvs are left for diagnosis; never delete one referenced by a snapshot.
def rollback(args):
if not META.exists():
raise ValueError('No active deployment metadata. Recover using docs/DEPLOYMENT.md and pending.json.')
latest = json.loads(META.read_text())
if not args.backup or not re.fullmatch(r'[0-9]{8}T[0-9]{12}Z', args.backup):
raise ValueError('Use --backup with the exact snapshot identifier printed during deployment.')
selected = BACKUPS / args.backup
record = json.loads((selected / 'snapshot.json').read_text())
if not record.get('previous'):
raise ValueError('This is the first-install checkpoint, not an earlier installed version.')
if record['previous']['scripts'] != latest['scripts']:
raise ValueError('Snapshot is for a different installation.')
deployment = Deployment(Path(latest['scripts']), latest['user'], latest['required_gid'])
if not args.restore_auth_db:
# Validate the rollback runtime against the configuration stored with that release.
old_env = Path(record['previous']['venv'])
schema = deployment.as_user([old_env / 'bin/python', '-B', '-c',
'from aim_webgui import SCHEMA_VERSION; print(SCHEMA_VERSION)'], capture=True)
with sqlite3.connect((STATE / 'webgui.sqlite3').as_uri() + '?mode=ro', uri=True) as db:
current_schema = db.execute('PRAGMA user_version').fetchone()[0]
if current_schema != int(schema.stdout.strip()):
raise ValueError('Rollback crosses a database schema boundary. Use --restore-auth-db explicitly after backing up current data.')
with tempfile.TemporaryDirectory(prefix='.rollback-config-', dir=PREFIX) as directory:
check_dir = Path(directory)
check_dir.chmod(0o755)
check_config = check_dir / 'webgui.toml'
shutil.copyfile(selected / 'webgui.toml', check_config)
check_config.chmod(0o640)
os.chown(check_config, 0, pwd.getpwnam(latest['user']).pw_gid)
deployment.as_user([old_env / 'bin/aim-web', '--config', check_config, 'check', '--without-db', '--without-core'])
current_snapshot = deployment.snapshot(latest)
write_json(PENDING, {'backup':current_snapshot.name,'phase':'manual-rollback',
'scripts':latest['scripts'],'user':latest['user'],'required_gid':latest['required_gid']})
try:
deployment.restore(selected, database=args.restore_auth_db, config=True)
PENDING.unlink(missing_ok=True)
print('Rollback completed. Release configuration restored. All WebGUI sessions revoked.')
print('Auth database restored from snapshot.' if args.restore_auth_db else 'Current users and passwords retained.')
print('Undo-rollback snapshot:', current_snapshot.name)
except BaseException:
deployment.restore(current_snapshot, database=args.restore_auth_db, config=True)
PENDING.unlink(missing_ok=True)
raise
def recover():
if not PENDING.is_file():
raise ValueError('No interrupted deployment journal exists.')
pending = json.loads(PENDING.read_text())
snapshot = BACKUPS / pending['backup']
record = json.loads((snapshot / 'snapshot.json').read_text())
deployment = Deployment(Path(pending['scripts']), pending['user'], pending['required_gid'])
deployment.restore(snapshot, database=bool(record['previous']), config=True)
PENDING.unlink()
print('Interrupted deployment recovered. AIM was not changed.')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('command', choices=['install','update','rollback','recover'])
parser.add_argument('--aim-scripts', type=Path, default=Path('/etc/ansible/scripts'))
parser.add_argument('--service-user', default='aim-web')
parser.add_argument('--executor-user',default='svc_bf-ansible',help='Existing authorized non-root key-owning AIM account; never created or modified.')
parser.add_argument('--aimctl',type=Path,default=Path('/usr/local/bin/aimctl'))
parser.add_argument('--core-config',type=Path,default=Path('/etc/ansible/scripts/aim.yml'))
parser.add_argument('--migrate-core',action='store_true',help='Acknowledge 1.x to 2.x API/state/service migration; read MIGRATION-3.1.md first.')
parser.add_argument('--python', default=sys.executable)
parser.add_argument('--wheelhouse', type=Path, help='Offline wheels for this Python/OS/architecture, including build dependencies.')
parser.add_argument('--assets-dir', type=Path, help='Offline pinned bootstrap.min.css and htmx.min.js.')
parser.add_argument('--backup', help='Rollback snapshot identifier.')
parser.add_argument('--restore-auth-db', action='store_true', help='DESTRUCTIVE: discard account changes since selected snapshot.')
args = parser.parse_args()
if os.geteuid() != 0:
parser.exit(1, 'Run deployment with sudo/root. Runtime will use an unprivileged account.\n')
if not Path('/run/systemd/system').is_dir():
parser.exit(1, 'Managed deployment requires Linux/systemd. See manual installation instructions.\n')
os.umask(0o077)
for location in (PREFIX, BACKUPS, STATE):
safe_absolute(location)
PREFIX.mkdir(mode=0o755, parents=True, exist_ok=True)
if PREFIX.stat().st_uid != 0:
parser.exit(1, 'The managed /opt/aim-web directory must be root-owned.\n')
PREFIX.chmod(0o755)
BACKUPS.mkdir(mode=0o700, parents=True, exist_ok=True)
if BACKUPS.stat().st_uid != 0:
parser.exit(1, 'The WebGUI backup directory must be root-owned.\n')
BACKUPS.chmod(0o700)
lock = os.open(PREFIX / '.deploy.lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
if PENDING.exists() and args.command != 'recover':
raise ValueError('Interrupted deployment detected in /opt/aim-web/pending.json. Recover before retrying.')
if args.command == 'recover':
recover()
elif args.command == 'rollback':
rollback(args)
else:
install(args)
except Exception as exc:
parser.exit(1, f'Deployment stopped: {exc}\n')
finally:
os.close(lock)
if __name__ == '__main__':
main()
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Fetch pinned upstream assets ONCE; browsers only load local copies.
Supply --from-directory for air-gapped deployment. Integrity checks are identical.
No npm, Node, CDN requests from the browser, or mutable 'latest' URLs.
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import os
from pathlib import Path
import tempfile
from urllib.request import urlopen
ASSETS = {
'bootstrap.min.css': (
'https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css',
'sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB'),
'htmx.min.js': (
'https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js',
'H5SrcfygHmAuTDZphMHqBJLc3FhssKjG7w/CeCpFReSfwBWDTKpkzPP8c+cLsK+V'),
}
def valid(data: bytes, expected: str) -> bool:
return base64.b64encode(hashlib.sha384(data).digest()).decode('ascii') == expected
def prepare(destination: Path, offline: Path | None = None, *, reuse: Path | None = None) -> None:
destination.mkdir(parents=True, exist_ok=True)
for name, (url, integrity) in ASSETS.items():
target = destination / name
if target.is_file() and valid(target.read_bytes(), integrity):
print('Verified local asset:', name)
continue
if offline:
data = (offline / name).read_bytes()
elif reuse and (reuse / name).is_file() and not (reuse / name).is_symlink():
candidate = (reuse / name).read_bytes()
if valid(candidate, integrity):
data = candidate
print('Reusing verified installed asset:', name)
else:
with urlopen(url, timeout=30) as response:
data = response.read(2_000_001)
else:
with urlopen(url, timeout=30) as response:
data = response.read(2_000_001)
if len(data) > 2_000_000 or not valid(data, integrity):
raise ValueError(f'Upstream integrity mismatch: {name}. No asset was installed.')
fd, filename = tempfile.mkstemp(prefix='.asset-', dir=destination)
temporary = Path(filename)
try:
with os.fdopen(fd, 'wb') as stream:
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
temporary.chmod(0o644)
os.replace(temporary, target)
finally:
temporary.unlink(missing_ok=True)
print('Installed verified local asset:', name)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--from-directory', type=Path)
parser.add_argument('--destination', type=Path, default=Path(__file__).resolve().parents[1] / 'src/aim_webgui/static/vendor')
args = parser.parse_args()
try:
prepare(args.destination, args.from_directory)
except Exception as exc:
parser.exit(1, f'Assets not prepared: {exc}\nUse --from-directory with the exact pinned upstream files for offline installation.\n')
if __name__ == '__main__':
main()
@@ -0,0 +1,12 @@
# NPM header reference only; not installed automatically.
# Default 2.0 path: HTTPS 192.168.20.46:8443 -> host nginx -> loopback 8080.
# See docs/EXECUTION.md for separate upstream CA verification requirements.
# Reference only. Nginx Proxy Manager normally generates the server block.
# AIM WebGUI does not require WebSockets.
# Configure webgui.toml public_url to the exact HTTPS browser origin.
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
@@ -0,0 +1,65 @@
# AIM WebGUI 2.1.0rc9 release-managed configuration
# Replaced on every managed install/update/rollback.
# AIM core configuration is never modified.
[server]
host = "0.0.0.0"
port = 8080
public_url = "https://aim.desq-gaming.de"
[proxy]
proxy_headers = true
forwarded_allow_ips = ["192.168.20.3"]
[session]
session_hours = 8
idle_minutes = 30
[aim]
scripts_path = "/etc/ansible/scripts"
# Public core protocol, not an Ansible executable or private source import.
[core]
transport = "unix"
command = ["/usr/local/bin/aimctl"]
config = "/etc/ansible/scripts/aim.yml"
socket = "/run/aim-web-executor/core.sock"
executor_user = "svc_bf-ansible"
client_user = "aim-web"
# Dedicated writable process HOME for native caches; SSH trust uses effective SSH configuration.
home = "/var/lib/aim-web-executor"
[state]
state_dir = "/var/lib/aim/webgui"
# Alternative browsing-only direct NPM -> application profile; do not use it to carry infrastructure credentials.
# This release does not create, replace or renew the separately installed TLS certificates.
[execution]
enabled = false
playbooks = [
"checkmk_install_agent",
"checkmk_update_scripts_config",
"checkmk_read_windows_config",
"checkmk_cleanup_scripts",
"debug_test_connection",
"debug_show_disk_usage",
"debug_detect_host_roles",
"maintenance_export_event_logs",
"maintenance_start_stopped_services",
"maintenance_patch_os",
"maintenance_reboot_hosts",
"sophos_apply_baseline",
"sophos_apply_customer",
"pfsense_apply_baseline",
]
max_hosts = 25
timeout_seconds = 1800
require_approval = false
# WebGUI policy only; core addons.execution_enabled is a separate operator opt-in.
# Direct NPM -> application HTTP is browsing-only until authenticated transport is separately designed.
transport_verified = false
window_start_hour = 0
window_end_hour = 24
[credentials]
enabled = false
@@ -0,0 +1,75 @@
# AIM WebGUI 2.1.0rc9 release-managed configuration
# Replaced on every managed install/update/rollback.
# AIM core configuration is never modified.
[server]
host = "127.0.0.1"
port = 8080
public_url = "https://aim.desq-gaming.de"
[proxy]
proxy_headers = true
forwarded_allow_ips = ["127.0.0.1"]
[session]
session_hours = 8
idle_minutes = 30
[aim]
scripts_path = "/etc/ansible/scripts"
# Public core protocol, not an Ansible executable or private source import.
[core]
transport = "unix"
command = ["/usr/local/bin/aimctl"]
config = "/etc/ansible/scripts/aim.yml"
socket = "/run/aim-web-executor/core.sock"
executor_user = "svc_bf-ansible"
client_user = "aim-web"
# Dedicated writable process HOME for native caches; SSH trust uses effective SSH configuration.
home = "/var/lib/aim-web-executor"
[state]
state_dir = "/var/lib/aim/webgui"
# Browser -> NPM 192.168.20.3 -> HTTPS 192.168.20.46:8443 -> local nginx -> 127.0.0.1:8080.
# This release does not create, replace or renew the separately installed TLS certificates.
[execution]
enabled = true
playbooks = [
"checkmk_install_agent",
"checkmk_update_scripts_config",
"checkmk_read_windows_config",
"checkmk_cleanup_scripts",
"debug_test_connection",
"debug_show_disk_usage",
"debug_detect_host_roles",
"maintenance_export_event_logs",
"maintenance_start_stopped_services",
"maintenance_patch_os",
"maintenance_reboot_hosts",
"sophos_apply_baseline",
"sophos_apply_customer",
"pfsense_apply_baseline",
]
max_hosts = 25
timeout_seconds = 1800
require_approval = false
# WebGUI policy only; core addons.execution_enabled is a separate operator opt-in.
# Operator attestation for this deployment profile: backend CA trust has been established.
transport_verified = true
window_start_hour = 0
window_end_hour = 24
[credentials]
enabled = true
# Retained public metadata, not raw Ansible stdout/stderr. Deleted with the job.
[journal]
max_events = 20000
max_bytes = 8388608
[reports]
max_bytes = 16777216
# Expanded configuration content retention requires explicit operator opt-in.
retain_configuration = false
+31
View File
@@ -0,0 +1,31 @@
# WebGUI HTTP APIv2 - Core3.3 / schema5 candidate
Core service/wire/event remains1.0 and is reached through fixed aimctl transport. WebGUI HTTPv2 is a separate authenticated API. All existing review, one-run, credential, grant, plan/retry/delete routes remain; unsafe requests require existing CSRF and same-origin policy. No arbitrary core-command/actor/path endpoint.
## Existing workflows
POST /api/v2/preflight takes customer,playbook,explicit targets,overrides,check,key_mode and returns private expiring review_id and normalized plan. result_contract is now preserved. POST /api/v2/runs takes review_id,confirm plus Idempotency-Key; no saved plan is required. Optional one-shot UTC scheduled_at remains unchanged. POST /api/v2/plans has an optional unique account-local name, never an upsert. POST /api/v2/runs/{id}/retry is a new requester-owned reviewed attempt; running jobs cannot be deleted.
Credential POST /api/v2/runs/{id}/credentials and existing jobs alias remain8KiB, requester-only reserved-window input; private FD downstream; no credential response echo. GET credential-status does not extend reservations or prove correctness. Modal and full-page fallback remain unchanged.
## New retained-evidence reads
All routes use owner-or-admin job authorization. Missing/inaccessible jobs are404, not aggregate leaks.
| GET | Meaning |
|---|---|
| /api/v2/runs/{id}/progress | Tail snapshot/checkpoint, default200 records. after/before mutually exclusive, nonnegative committed local cursors; limit1..500. |
| /api/v2/runs/{id}/progress/stream?after=N | Replay then follow committed public metadata through SSE; Last-Event-ID overrides initial after on reconnect. |
| /api/v2/runs/{id}/console | Compatibility alias to the durable progress SSE, not raw console output. |
| /jobs/{id}/progress?before=N | Authorized server-rendered pagination/no-JavaScript timeline. |
| /api/v2/runs/{id}/reports | Report header/availability/retention index; no report bodies. |
| /api/v2/runs/{id}/report?host=HOST | One retained slot, schema/availability/mode/time/retention and bounded data. Empty host selects global scope where applicable. |
| /jobs/{id}/reports?host=HOST&field=FIELD&page=N | Schema-aware HTML summary,50-row collection paging and lazy JSON. |
A journal response includes job/status/terminal, available,cursor,next_cursor,first_cursor,events,checkpoint,has_more,has_older,omitted_events,dropped_events,capture_state and capture_interrupted. Each event record has local cursor,receipt time,allowlisted Core event and derived display text. Text is rendered on reads, never stored as a console transcript.
SSE events: snapshot (committed metadata), line (record and legacy text), gap (retention omission), end (terminal or revoked access). Only durable records carry data cursors. Transport keepalive comments are not task activity. Final event and final response are one run, not duplicate reports. Final authoritative target facts remain in /api/v2/runs/{id}; operation_result there is a compact availability/retention projection, with bodies only in the separate report route.
Report states: available,missing,withheld,invalid,not_started,indeterminate. Local retention: retained,metadata_only,unavailable,not_retained_limit. Do not conflate these fields with execution success. Checkmk configuration defaults to metadata_only even with Core status available. Reports are finalization-only.
No browser read calls prepare/execute, opens the credential channel, probes hosts or imports terminal Core history. Deletion cascades journal/reports while audit keeps its compact event. Old jobs without evidence show unavailable; cursor replay cannot recover nonretained/uncaptured data. All evidence uses Cache-Control:no-store and is escaped for display.
@@ -0,0 +1,17 @@
# Architecture - 2.1.0rc9
Core3.3.0rc8 owns current inventory, normalized requests/revisions, native execution, credential handling, safe progress, per-target outcomes and declared final report validation. Its API remains1.0. WebGUI owns authentication/authorization, reviewed workflows, UI and its own retained history.
Browser -> HTTP/queue (aim-web) -> fixed private executor socket -> executor account -> aimctl under same UID -> native Ansible. No new daemon, privilege or Core import.
The reviewed plan now includes a validated result_contract. Public response parsing has separate large-result limits and strict JSON parsing; secret transport stays unchanged. Core's final result event is progress only. The final response is projected against the reviewed schema/scope/targets/mode and is the sole report-persistence input.
During execution Capture.submit projects metadata into a bounded queue. A writer thread batches durable journal rows and a latest-observation checkpoint into SQLite transactions. HTTP reads/replay query committed rows and never connect to the process console. Browser latency cannot fill the Core credential pipe or control capture. Capture loss/limits are recorded distinctly from execution outcomes.
Schema5 adds job_progress_events,job_progress_state,job_operation_results and job_operation_reports; all reference jobs with ON DELETE CASCADE. Core_result keeps its small authoritative status/targets and a compact report availability summary. Analytics need not decode report bodies. Report reads lazily select one slot. Histories still use only retained WebGUI jobs, with owner/admin authorization before queries and aggregation.
Core reports are independent of progress. Schema-aware views render typed structured facts and a generic safe JSON alternative. Unknown supported schemas have no dynamic code/$ref/network loading. Report availability, retention and execution verdict remain separate. No global reporting operation ships with Core; generic global support is fixture-qualified only.
Journal/report storage uses the existing private web DB, rollback journaling and synchronous FULL. It is not an immutable or tamper-proof audit store. Bounded metadata queues plus250ms transaction busy limits separate capture pressure from task execution; persistent DB failure remains a service failure and cannot be hidden as success. Job-linked deletion is not backup/physical erasure.
The existing read-only Explorer/Activity/Insights remain public Core reads or authorized SQL reads. They do not modify current inventory, contact hosts or include terminal history. Host report references explicitly label date/mode; successful Ansible outcomes are not live health/compliance.
@@ -0,0 +1,37 @@
# Controller acceptance - WebGUI2.1.0rc9 / Core3.3.0rc8
**Controller prerequisite:** Core rc8 requires `ansible.windows >=3.8.0,<4.0.0` for Windows playbooks. Confirm it in the canonical collection path under the executor identity; WebGUI does not install collections.
Not a record of already performed managed-host tests. Use Core's SANITY.md and approved disposable targets. Preserve the actual execution UID/groups/HOME/sandbox and both staging exceptions; do not use root-shell success to certify the service.
1. Quiesce/backup, deploy Core independently, deploy new WebGUI preserving schema5 (or migrate older schema4), verify versions/permissions and installed executor preflight. Check terminal AIM remains independent. Confirm old prepared jobs did not replay and old history remains.
2. Run role detection and disk usage on a small approved scope. Verify prepared report declaration/schema, existing Unlock modal, final native facts and report payloads against authorized native observation. Verify unavailable/null/false values are distinct.
3. During a longer safe run, close the page, reopen/reload, use another authorized device and inspect the same committed timeline. Check interleaved host/task IDs, last activity and bounded internal scrolling. Manual upward scrolling stops following. No invented ETA/completion percentage.
4. Check mixed targets (one unreachable) and separate report availability. Test a controlled required-report fixture failing validation after native exit0: job stays failed/result_validation, native targets remain visible and no automatic retry occurs. Fixture/source changes require fresh review.
5. Verify all nine report types in separately approved scope. Patching/reboot/service-start/export/delete operations require dedicated test conditions, not a casual UI smoke. Reports change task totals; compare intended effects, outcomes and schemas, not old exact task counts.
6. Verify default parsed Checkmk config retention excludes sections while showing file/redaction metadata. Full content opt-in is deliberate. Check report copying/rendering and narrow mobile/short landscape; values never become HTML or automatic links.
7. Revoke session/access while streaming, check another owner's job is unavailable, and confirm admin access does not mean submitting someone else's credentials. Verify journal/report deletion cascades and Host Activity contributions disappear without recreating data from audit.
8. In a disposable run, restart a worker/executor and confirm prior recorded metadata survives but remote execution is not automatically resumed/replayed; uncommitted metadata or missing final outcome is disclosed.
9. Test retention with synthetic high-volume data, not unbounded production playbooks. Omissions must be visible; final Core facts remain independent. Monitor DB growth/backups and storage pressure.
10. Test matched schema/Core rollback with historical DB warning and sessions revoked. An incompatible restored adapter must remain stopped/disabled until independent Core rollback and explicit service enablement.
Record actual versions, UID/group context, platform, report/mode and final result. Source/browser fixtures or success on one host do not certify all9 operations, other operating systems, physical keyboards, global reports or a different security profile.
## Core rc8 patch-wave and native-baseline acceptance
After the low-risk reporting checks above, use Core3.3.0rc8's current SANITY.md on
separately approved disposable Windows update/reboot targets. Confirm the new catalog
controls, false continuation hint, omitted inherited values and explicit true/false
review. A reboot flag must not auto-enable continuation.
Verify a successful patch-triggered reboot can end with continuation_required=true,
remaining_updates_known=false and no next-wave list or auto-submitted job. Verify an
explicit continuation run uses the documented Core cycle bound. Test the final
read-only pending discovery path, deferred reboot, pre-existing-reboot block and a
bounded per-update failure. Distinguish unsigned/hex HRESULT plus reason/message from
the independent pending-reboot observation. Old reports without wave fields should
remain viewable and labeled as historical; no unknown value becomes false/zero.
Do not equate a Windows test with Linux or every supported Windows release. Preserve
native result-validation failures, partial host outcomes, journal/report retention,
modal deadlines and global SSH trust. This release does not change their policies.
@@ -0,0 +1,90 @@
> Historical reference retained from the preceding release. Current deployment/contracts and evidence are in DEPLOYMENT.md, REPORTS.md, JOURNAL.md and VERIFICATION.md. Do not treat old limitations or tests as current qualification.
> Historical core review. Current2.1 behavior is documented in README, API and READ-ONLY-EXPERIENCE; older capability limits below are not current release claims.
# Review of the supplied AIM 3.1.0 contract
Reviewed archive: AIM-Ansible-3.1.0.zip
SHA-256: `6005cab58875cfd6eabfd18e9b388c219a02d5b0472ba50a94c78abafb637ae9` (matched the supplied checksum).
This is source-based analysis, not live-controller verification. The core archive
and extracted baseline are not changed by WebGUI development.
## Sources reviewed
Paths below are relative to aim-core-3.1.0 in the uploaded ZIP:
| Source | Load-bearing contract/findings |
|---|---|
| ADDON_AGENTS.md | Authoritative same-UID, independent client boundary; no private imports, argument interception or key export |
| scripts/docs/ADDON_API.md | Public operations, RunRequest, credentials-fd framing, event/result schema, limits |
| scripts/docs/ADDON_SUPPORT.md and addon-support-v1.json | Implemented scope versus unsupported Custom, raw output and mutable APIs |
| scripts/docs/RELEASE_HANDOFF.md | Core 3.1/API 1.0 stability; controller results do NOT establish noninteractive execution or non-root SSH qualification |
| scripts/docs/RC19_HANDOFF.md | Historical design intent only; newer support contract takes precedence |
| scripts/docs/LOCAL_VALIDATION.md and CONTROLLER_ACCEPTANCE.md | Core acceptance procedure and evidence boundaries |
| deploy/README.md and scripts/aim.yml | Independently installed aim/aimctl; operator-owned addon execution opt-in and runtime settings |
| scripts/src/aim/services/v1/models.py | Strict RunRequest; unknown fields rejected; explicit hosts; no credential or arbitrary path/actor fields |
| scripts/src/aim/services/v1/service.py | prepare/readiness/execute, key 0600 calling-UID policy, authoritative revisions, native inventory precedence, safe events |
| scripts/src/aim/ctl.py | One request JSONL line; separate inherited pipe/socket secret channel; final response required |
| scripts/src/aim/runtime/ansible.py and process.py | Native CLI discovery/execution, core-owned environment and cancellation |
| scripts/src/aim/locking.py | Stable customer .aim.lock, shared terminal/service advisory locking |
| scripts/src/aim/inventory and playbooks modules | Checked only to understand documented output; never imported by the add-on |
## Decisions derived from these sources
1. Replace RC18Adapter, external_presentation interception and the private Ansible
credential strategy entirely with a bounded aimctl client. Core's private signatures
are not the supported extension interface.
2. `service_user` remains the remote account/key basename. `runtime.private_key_owner`
controls new-key ownership, not automatic local UID switching. An explicitly
pre-started add-on executor runs as the actual authorized key-owning UID. This is
add-on infrastructure, not a claimed native core broker.
3. prepare returns a core-owned revision and credential requirements. Save/queue/
dispatch revalidate through prepare, not local inventory scans/hashes. No stat-only
shortcut for protected sources. Key mode customer needs key access during prepare.
4. Core 1.0 supports customer/catalog reads but no inventory mutations, Custom forced
authentication or password-only verification. Native password defaults retain
inventory precedence. The old Custom UI is removed rather than silently reinterpreted.
5. list_hosts exposes name/address/platforms, not recursive inventory group paths.
Bulk selection is retained for available platform groups only. This is a temporary
feature-parity loss, not a reason to import inventory internals.
6. Global catalog is a union of customer-scoped available catalogs; unavailable
customer-specific playbooks are not offered. Catalog inputs remain core-typed.
7. Events contain structured fixed statuses/counters, not raw task names/messages.
The old raw live console becomes Execution progress. RunResult controls success;
zero exit without final counters is not treated as successful.
8. Optional core fields are ignored safely. The tested product is 3.1.0, service/
wire/event1.0. Future core products need qualification even if API 1.x is stable.
9. Core's public `readiness` performs local runtime/collection checks. It is not a
remote connection test or guarantee that native task execution will succeed.
10. There is no public per-browser-user actor authorization. WebGUI maintains its
own account/grant/approval checks, but the executor UID is a trusted controller actor
with the core permissions of that OS identity, not an isolated tenant.
## Explicit changes from old WebGUI
- Removed imports of aim.config, managers, inventory readers and old command hooks.
- Removed TextVaultSecret/VaultSecret API adaptation, custom Ansible strategies,
canonical-key-export sudo bridge, job-private canonical-key copies and elevated
CAP_SETUID/CAP_SETGID worker design.
- Ansible/version/collection interpretation now belongs to core. `/usr/bin` is no
longer an add-on execution.ansible_bin setting.
- Added a reviewed one-run path; saving is optional. Name collisions return 409.
- New HTTP v2 and schema 4, independently of core API 1.0. Old APIs are not silently
accepted with changed semantics.
## Useful requests for a future core release (NOT implemented here)
Additive HostSummary group paths would restore subgroup selection. Safe per-host
result identifiers and explicitly bounded diagnostic categories could improve
progress without raw logs. A separately designed forced Custom authentication mode
would need to define native precedence, key/agent/control-socket fallback and
approval semantics. None should be recreated in the add-on using private APIs.
## Qualification status
The tests use the real uploaded-core machine interface. Native Ansible is absent
in this development container: controlled fake native commands verify protocol,
result and worker integration only. Real SSH/WinRM, encrypted-key behavior, actual
systemd sandbox deployment and reverse-proxy streaming require controller acceptance.
See VERIFICATION.md for exact tests and limitations; no old rc9 qualification is
claimed as qualification of this new core boundary.
@@ -0,0 +1,20 @@
> Historical reference retained from the preceding release. Current deployment/contracts and evidence are in DEPLOYMENT.md, REPORTS.md, JOURNAL.md and VERIFICATION.md. Do not treat old limitations or tests as current qualification.
> Historical core review. Current2.1 behavior is documented in README, API and READ-ONLY-EXPERIENCE; older capability limits below are not current release claims.
# Review of supplied AIM 3.2.1rc1
Reviewed archive checksum: `AIM-Ansible-3.2.1rc1.zip.sha256` matched the uploaded ZIP. Core is separately managed and was not modified.
## Contract adopted
- Product: AIM 3.2.1rc1; service/wire/event API remains 1.0 / stable_1.x.
- Canonical Ansible Core remains 2.19.11.
- `capabilities.execution_progress` advertises `summary` and `detail`; WebGUI opts reviewed runs into `progress_mode: detail`.
- Detail schema `play_task_host_v1` exposes safety-filtered static play/task labels, host outcomes, fixed failure hints, retries/async polls and per-host recap. Raw stdout/stderr, module arguments/results, rendered labels, paths and variables remain unavailable.
- `staging_check` is now a public operation and staging preflight runs before credentials and again before launch. WebGUI treats staging errors as executor deployment/access failures rather than credential failures.
- Core 3.2.1rc1 requires a private writable controller staging directory in the executor sandbox. The release-managed executor profile provisions `/var/lib/aim-web-executor/.ansible/tmp`, adds only that path to the writable sandbox set, sets the matching executor HOME, and runs `core-staging-check` as `ExecStartPre`.
## UI mapping
WebGUI renders the detail stream into an Ansible-like live view (`PLAY`, `TASK`, host status, fixed hints, recap) but does not claim it is raw Ansible output. The stream remains memory-only and disappears after completion/navigation. Authoritative job outcome remains the Core final result/counters.
@@ -0,0 +1,16 @@
> Historical reference retained from the preceding release. Current deployment/contracts and evidence are in DEPLOYMENT.md, REPORTS.md, JOURNAL.md and VERIFICATION.md. Do not treat old limitations or tests as current qualification.
# Review of supplied AIM 3.2.1rc2
The uploaded SHA-256 sidecar matched the supplied Core archive. AIM Core remains separately installed and managed; this add-on does not modify it.
## Public additions consumed by WebGUI rc7
- `inventory_hierarchy_v1`: read-only customer/group/subgroup tree with direct hosts only per node. WebGUI uses Core paths for presentation and expands parent selection from Core-declared descendants, then submits explicit hosts through normal prepare/review.
- `target_outcome_summary_v1`: authoritative final requested-target accounting from native Ansible host stats. WebGUI does not infer final host success from task events.
- `native_defaults_preflight_v2`: separately covers process/config-home staging and passwd/NSS-home local-connection staging. WebGUI rc7 retains the rc6 release-managed writable paths and hardened systemd sandbox.
- Service/wire/event API remains 1.0; detailed progress remains `play_task_host_v1`; raw module output remains unavailable.
## Presentation boundary
Core overall execution status remains authoritative. For a mixed result such as 24 successful targets and one unreachable target, Core may correctly return `failed`; WebGUI presents the job as `Partially succeeded` while showing the original Core status/exit code and the per-target facts.
@@ -0,0 +1,15 @@
> Historical integration basis for WebGUI 2.1.0rc3. For this candidate see [Core rc3 review](CORE-3.3.0RC3-REVIEW.md); historical test counts below are not new acceptance.
# Core 3.3.0rc1 integration basis
The separately supplied archive and checksum were verified. The197 source/documentation files are unchanged. Authoritative handoff material is Core ADDON_AGENTS.md and scripts/docs/{ADDON_API,ADDON_SUPPORT,OPERATION_RESULTS,DETAILED_PROGRESS,TARGET_OUTCOMES,INVENTORY_HIERARCHY,EXECUTOR_STAGING,VALIDATION,SANITY,RELEASE_HANDOFF}. The supplied ten standalone documents matched their archived versions during review.
Core adds operation_results capability, null or schema-resolved catalog result declarations, PreparedRun.result_contract and final RunResult.operation_result. The publisher is aim_output_v1; public wrapper aim_operation_result_v1. The final event and final response are one result. Data arrives at finalization only. Existing API/event1.0, target accounting, hierarchy and dual-home preflight remain.
WebGUI consumes only the public process boundary. Its consumer independently validates the published schema subset, declaration/review association, report data and response budgets. It never loads playbook schemas from the controller filesystem. Core source fixtures and schema files are used only in disposable release tests. All9 bundled schemas have registered presentation metadata and generic bounded data rendering. No Core source/config/Ansible runtime change is made by the add-on.
A native exit0 with required output absent/invalid is a result_validation failure. Per-target native success remains distinct, and a native failed run can have useful available reports. Missing response never becomes success from an earlier event. Payload data.complete can have a separate meaning from report-slot complete. Config sections remain an explicit retention opt-in because safe filtering is not a universal secret scanner.
The operation payload can total16MiB, with1MiB slot bounds. Core private native callback chunks do not imply chunked public JSONL. WebGUI therefore increased only public response frame/line/stream budgets and kept request/private-secret limits unchanged. Reports are stored separately from compact final job facts. Progress journaling is add-on-owned and not a new Core history collector.
Core's188 recorded upstream local tests are not WebGUI test results. Native Ansible2.19.11, Windows/Linux publishers, global publishers and hardened controller behavior remain acceptance gates. See this release's VERIFICATION.md for observed tests and limitations.
@@ -0,0 +1,84 @@
# Core 3.3.0rc3 integration review
This is a source-based review of the user-supplied Core archive, not a native Windows
Update qualification. AIM Core remains separately deployed and unmodified.
## Source basis
Authoritative Core topics: `ADDON_AGENTS.md`, `AGENTS.md`,
`scripts/docs/RELEASE_NOTES.md`, `RELEASE_HANDOFF.md`, `ADDON_SUPPORT.md`, `ADDON_API.md`,
`OPERATION_RESULTS.md`, `PLAYBOOKS.md`, `VALIDATION.md`, `SANITY.md`,
`scripts/docs/INSTALLATION.md`, and `deploy/README.md`.
The supplied Core ZIP contains 200 files under `aim-core-3.3.0rc3/`. Its sidecar and
ZIP integrity were checked before extraction. Archive hashes and unchanged-source
comparison are in this release's verification record.
Compared with the supplied Core 3.3.0rc1 (the actual previous WebGUI integration),
there are three added files: two Windows patch-cycle tasks and the Core installation
guide. No files were removed in that comparison. The source-version/metadata,
patch runbook/filter/catalog/schema and documentation changed. The semantic catalog
difference is confined to `maintenance_patch_os`; eight other reporting schemas are
byte-identical. Core's runtime, credentials, service transport, target-outcome,
hierarchy and staging implementations are unchanged in this comparison. This is not
an assertion that the new patch runbook was executed successfully here.
## Existing public boundaries retained
Service/wire/event API remains 1.0. Reports still use `aim_output_v1` publication and
`aim_operation_result_v1` final results, with `result_contract` captured at review.
Reports arrive at finalization, not as raw debug/stdout. Detailed progress remains
`play_task_host_v1`; native target outcomes and the two-home staging preflight remain.
No new execute-request field, credential channel, service or filesystem access is needed.
## New rc2/rc3 patch metadata consumed
Core's catalog provides reboot delay (minutes, 0..1440), reboot message and the
Windows-only `os_patching_rescan_after_reboot` boolean. Its catalog hint is `false`.
The WebGUI obtains these options and constraints from public discovery, not a
parallel defaults table. Unchanged controls are omitted to preserve inventory/role
precedence; a catalog hint is not a resolved effective inventory setting.
Windows now discovers a deterministic queue, installs one discovered update per
native invocation and stops a wave at a reboot boundary. With continuation disabled,
an AIM-performed reboot can end a successful run with `continuation_required: true`
and `remaining_updates_known: false`. An explicitly reviewed true continuation value
allows Core to rediscover after reboot, bounded to 12 cycles. This is within one Core
operation, not permission for the add-on to create more jobs.
A wave finishing without reboot can perform a final read-only search; this does not
extend the approved installation queue. When `remaining_updates_known` is true,
`pending` is the recorded final discovery only. When false, the WebGUI must not show
a pre-reboot queue or zero-length list as the established next-wave state.
The patch report adds pre/post reboot observations, delay, deferred state and a stop
reason (introduced in Core rc2). Windows adds optional continuation, cycle and
remaining-update-knowledge fields in rc3. Individual failed updates carry Core's
unsigned/hex HRESULT, reason and bounded safe message. `install_not_allowed` is not
proof of a pending reboot; `preexisting_reboot_required` is an independent Core
preflight observation. The UI displays provided codes; it does not parse fatal text,
Windows logs or native error messages.
## Consumer changes made
- Qualify exact Core 3.3.0rc3 in adapter/executor/CLI/deployment metadata, while keeping
live capability negotiation and version rejection intact.
- Surface all catalog-driven patch controls and platform hints, with a review note
separating reboot from continuation and preserving explicit true/false vs inherited.
- Add a report presentation model for continuation, deferred reboot, pre-existing
reboot and bounded-cycle stop conditions. This never changes the job/Core verdict.
- Present remaining updates according to the new knowledge flag; keep raw *structured
report JSON* available separately, not unrestricted process output.
- Preserve historical schemas and data. The same `patch_summary_v1` identifier has a
larger closed shape now: newly added reboot fields and failure `message`/hex fields
are required where declared. Old jobs render with their recorded schema, never
revalidated against today's catalog or backfilled with invented false values.
- Retain journal, reports, modal, owner/admin visibility and existing retention limits.
## Qualification boundary
Core's current VALIDATION.md records static/filter/schema and disposable deployment
checks, not native Ansible 2.19.11/Windows Update/service-sandbox acceptance of rc3.
WebGUI test outcomes are recorded independently in VERIFICATION.md. Source tests and
synthetic report fixtures do not certify Windows servicing ordering, reboots, pending
update state, Server 2012 R2, or every delegated service environment.
@@ -0,0 +1,50 @@
# Core 3.3.0rc8 integration review
This is a source/package compatibility review of the user-supplied AIM Core 3.3.0rc8
archive against AIM WebGUI 2.1.0rc9. Core remains separately deployed and unmodified.
## Public contract
Core remains service/wire/event API 1.0 with the existing detail-progress,
inventory-hierarchy, target-outcome and `aim_operation_result_v1` contracts. The nine
shipped report schemas remain catalog-driven. WebGUI therefore does not add a private
adapter or parse playbook output.
The release changes the required native Windows collection baseline. Live Core
capabilities now advertise:
```text
ansible.windows >=3.8.0,<4.0.0
```
WebGUI 2.1.0rc9 requires that capability declaration. The actual collection remains
Core/operator managed and must be visible to the execution identity in the canonical
Ansible collection path. Core readiness remains authoritative for the installed runtime.
## Report deltas
`patch_summary_v1` is additive: rc8 adds optional `reboot_reasons_before`, a bounded
array of `{source, description}` observations from `ansible.windows.win_reboot_info`.
Existing required fields and the established patch continuation/reboot semantics remain.
Historical reports continue to validate against their recorded result contracts.
`filesystem_usage_v1` remains schema-compatible, but Windows semantics now describe
attached local storage volumes through `community.windows.win_disk_facts`; mapped/network
drives are intentionally excluded. WebGUI updates its explanatory note accordingly.
Core's Checkmk script placement and Windows ACL hardening are runbook behavior, not a new
add-on API. WebGUI does not reconstruct those paths, permissions or deployment rules from
private source; it renders final structured reports supplied by Core.
## Patch behavior retained
The rc4 patch-wave model remains: one native `win_updates` wave per selected categories,
AIM-owned reviewed reboot message/delay, explicit opt-in for post-reboot continuation,
no automatic replay, and `remaining_updates_known` governing whether a final pending list
is authoritative. `install_not_allowed` alone is not treated as proof of a reboot need.
## Qualification boundary
This review does not certify native Windows Update, Checkmk ACL changes, collection
installation, WinRM/SSH, or the production systemd sandbox. Controller acceptance must
use Core rc8's current SANITY/VALIDATION guidance under the actual executor identity.
+66
View File
@@ -0,0 +1,66 @@
> The behaviors below are retained from2.1.0rc2. Current candidate2.1.0rc9 targets Core3.3.0rc8/schema5 and adds reports/journal described in REPORTS.md and JOURNAL.md. Credential, read-only inventory and OS-permission boundaries here remain unchanged; old version/no-migration statements describe the earlier slice.
# One-run credentials through core API 1.0
Core 3.3.0rc8 is authoritative. WebGUI 2.1 does not decrypt Vault or read/export/copy keys.
Use native inventory or explicit customer-key loading in New run. Existing usernames,
Vault variables and host/group precedence are core/Ansible behavior.
Custom forced username/password override is NOT exposed by core and was removed
from this adapter. A supplied connection_password, when the catalog requests it, is
a native default and may be overridden by inventory. It is not password-only testing.
Become-password UI, key uploads, cross-job password caching and raw task output are
not implemented. Terminal features are not automatically API features.
## Where to enter a password
Submit the reviewed job first. With approval disabled it queues directly; otherwise
an independent enabled administrator approves it. The running queue worker reserves
a slot after local core readiness. Job detail then offers Unlock this run. With JavaScript and native dialog support this opens an in-page modal; its link remains an authenticated full-page fallback. Overview and Jobs also surface your eligible reservations in Needs your attention.
Only the requesting account may use this form. All requested fields derive from
PreparedRun. A present customer Vault is conservatively required even when the
catalog's require_vault flag is false. An encrypted customer key can use an explicit
key passphrase or Core's literal vault_linux_ssh_key_passphrase lookup. The grouped Use customer Vault / Enter separately choice appears only for that alternative requirement with a Vault source. A separately required key passphrase is never made optional.
A reservation without secrets lasts5minutes. After submission, hand-off/preparation/
start must fit60seconds; execution has the separate bounded job timeout. An expired
or failed attempt does not retain credentials for the next attempt. Scheduled jobs
collect secrets only when their time/window/approval and worker are ready.
## Transport and processing
Existing verified HTTPS to NPM/backend and loopback final hop are assumed configured
by the operator. Dedicated POST bodies, CSRF/origin/session authorization and8KiB
body limit remain. Passwords are literal, max2048 UTF8 bytes each, no CR/LF/NUL. No
client-side hash substitution. Browser fields clear on navigation and never enter
HTMX history/localStorage. Raw body/error logging remains prohibited.
The web identity sends private bounded local frames to its worker, then to the
executor. The executor starts aimctl with a separate inherited secret pipe FD, not
stdin request JSON, argv, environment or a password file. Core owns its provider,
key agent and native credential helpers. WebGUI does not reinterpret Vault values.
Python release of references is not guaranteed RAM erasure. Review swap/dumps,
proxy request buffering and access to the service accounts. A compromised authorized
web process may exercise the local core client authority; this is not tenant isolation.
No automatic data/file-permission repair or credential fallback is performed.
## Qualification
`aim-web credential-check` now means public capability/authorized metadata checking.
It does not test decryption or remote auth and requires no pytest in production.
`core-check --customer ... --playbook ... --host ... --key-mode customer` additionally
runs documented local readiness. Real SSH/WinRM acceptance remains an operator gate.
Use the included disposable test instructions for API integration; fake native
fixtures do not certify real Ansible or controller systemd permissions.
## Dialog lifecycle and recovery
The dialog loads only after an explicit user action. Its form lives outside status/attention polling so typing is not discarded by an HTMX replacement. It shows the frozen customer/playbook/mode and target count. Show/Hide never changes what is submitted, and pasting is not blocked. Escape/Close/Not now clear fields, including visible-password inputs, and return focus without canceling the job. Backdrop taps do not dismiss it. A small visual viewport constrains the dialog's internal scroll area rather than growing the page.
Only required/supported Core fields are rendered. For the Vault/key alternative, the default sends just the Vault password; Enter separately adds a required key-passphrase field. Switching back clears that field. No source-radio name or extra scope/identity field is sent to the API. A plain HTML form with JavaScript unavailable exposes the optional separate field with its explanation and does not submit disabled source controls.
The countdown uses the server deadline, not a new five-minute timer on each opening. Polling does not renew it. Submission clears live form values and disables repeat clicks. A 202 accepted response means only that the existing worker claimed the handoff. Core validation and remote authentication happen later. A lost acknowledgement must not cause automatic POST retry: the client locks the form and polls owner-only status. Worker single-claim/session/scope checks remain authoritative across tabs.
The canonical POST route is `/api/v2/runs/{id}/credentials`; the existing `/api/v2/jobs/{id}/credentials` is kept as an alias. Both use the same five-attempts-per-minute requester throttle and existing private worker socket. `GET /api/v2/runs/{id}/credential-status` is non-secret status only. It never reports a password as correct, requests new work or extends the reservation.
+131
View File
@@ -0,0 +1,131 @@
# Deployment and recovery - AIM WebGUI 2.1.0rc9
Target: separately installed AIM Core **3.3.0rc8**, service/wire/event API **1.0**.
WebGUI HTTP **v2**, SQLite **schema 5**. From WebGUI 2.1.0rc3 there is no database
schema change or new permission/service requirement. This candidate qualifies the
new public patch catalog/report shape; it does not upgrade Core itself.
## Fresh installation
Use [ADDON-INSTALLATION.md](../ADDON-INSTALLATION.md) after the independently supplied
Core `scripts/docs/INSTALLATION.md`. `install` is for a new add-on; `update` is for an
existing managed installation. The Core and WebGUI deployers have different flags.
## Coordinate Core and WebGUI versions
The previous WebGUI 2.1.0rc4 targets Core3.3.0rc3 exactly; it does not accept Core rc8.
Finish active jobs, prevent new submissions and quiesce terminal writers. Cancellation
is not rollback. Keep matching Core and add-on backups, including the workflow DB.
```bash
sudo systemctl stop aim-web-worker.service aim-web.service aim-web-executor.service
```
Deploy Core3.3.0rc8 independently with its verified source archive and its own
**Controller prerequisite:** Core rc8 requires `ansible.windows >=3.8.0,<4.0.0` for Windows playbooks. Confirm it in the canonical collection path under the executor identity; WebGUI does not install collections.
preview/apply/quiesced procedure. Do not overwrite Core settings, environments,
inventory/Vault/keys or global SSH trust. Review patch-wave changes in Core's release
notes and complete separately approved terminal acceptance. A UI release does not
certify the new native Windows Update flow.
Then extract this add-on into a fresh directory, not over the active source:
```bash
cd /var/tmp
sha256sum -c AIM-WebGUI-2.1.0rc9.zip.sha256
unzip AIM-WebGUI-2.1.0rc9.zip
cd aim-web-2.1.0rc9
sudo python3 deploy/deploy.py update
```
Ordinary2.x updates do not need --migrate-core. Same-version reinstall is rejected.
The installer validates the actual manifest/required files, prepares dependencies and
assets, checks public Core access, backs up and activates the release, writes known
managed units and waits boundedly for socket readiness. Unknown unit drop-ins require
operator review. Core remains unchanged by this updater.
## State, review and retention
Accounts, grants, plans, journals, reports and audit remain in schema5. Its five
released SQL migrations are unchanged. An older schema4 install follows the existing
schema5 migration (pending/queued work blocked, running work interrupted, old reviews
removed). A schema5-to5 update does not rewrite job states: old prepared work is still
subject to the existing source/revision revalidation and cannot silently execute a new
Core revision. Quiesce first and use a fresh review after Core source/schema changes.
Saved plans remain presets; selecting one requires a fresh review. Historical reports
use their stored schema and are not revalidated with the new enlarged patch schema.
There is no backfill, terminal-history collection or automatic retry/continuation.
The full webgui.toml remains release-managed and overwritten with the approved site
profile (execution enabled, existing14-playbook allowlist, no independent approval,
credential transport attestation). Keep the independently configured Core opt-in.
Retention defaults remain:
```toml
[journal]
max_events = 20000
max_bytes = 8388608
[reports]
max_bytes = 16777216
retain_configuration = false
```
Custom local TOML edits must be incorporated into the reviewed release profile to
survive later replacements. Job deletion removes its linked evidence; protected
backups and physical-storage erasure have separate operator policies.
## Verify in the real service context
```bash
aim-web --version
aim-web config-check
sudo systemctl status aim-web-executor.service aim-web.service aim-web-worker.service --no-pager
sudo -u aim-web aim-web core-check
sudo journalctl -u aim-web-executor.service -n 60 --no-pager
```
Expected: AIM WebGUI2.1.0rc9 / Core3.3.0rc8. ExecStartPre remains the authoritative
staging probe inside the executor's actual sandbox. A plain sudo -u shell lacks its
unit-scoped aim-web group and is not an equivalent preflight. Do not loosen config or
staging permissions to make the interactive wrapper pass.
The generated unit contract is unchanged: normal executor primary group plus aim-web
supplementary group; web state0700; config root:aim-web0640; executor state and both
staging paths0700; runtime directory executor0711; socket executor:aim-web0660;
NoNewPrivileges, empty capabilities, ProtectSystem=strict, ProtectHome=read-only with
only exact staging write exceptions. No new ports/services/writable paths are needed.
Start with a safe read-only reporting job and reload its recorded evidence. New native
patching/reboot behavior needs Core's disposable-target acceptance, not a production
installation used as a browser smoke test. See [PATCH-WAVES.md](PATCH-WAVES.md).
## Recovery and rollback
Record the printed /var/backups/aim-web checkpoint. After an interrupted deployment,
investigate pending.json and use the same release deployer's recover command; do not
manually retarget current symlinks.
```bash
sudo python3 deploy/deploy.py recover
sudo python3 deploy/deploy.py rollback --backup CHECKPOINT
```
The commands above are alternative recovery actions, not a sequence to run blindly.
An rc5->rc4 add-on rollback stays on schema5, but the older adapter still requires its
matched Core version. The deployer tests restored compatibility; an incompatible
restoration remains stopped/disabled until the operator separately restores the
matching Core and deliberately enables the units.
Crossing schema5->4 requires --restore-auth-db and an explicit historical database
restore. That can reinstate older password hashes and discard later users/jobs/plans/
journals/reports; restored sessions are revoked. Back up current data first. Neither
product rollback reverses remote installations/reboots or unrelated operator changes.
## Offline assets
This is not a full offline dependency bundle. Python and production browser pins are
unchanged (Bootstrap5.3.8 and HTMX2.0.10 with SHA384 checks). Existing matching assets
can be reused. --wheelhouse and --assets-dir accept independently prepared exact
artifacts. No development/test dependency is installed into either product environment.
+21
View File
@@ -0,0 +1,21 @@
# Reviewed execution - 2.1.0rc9
AIM Core3.3.0rc8 builds native commands and owns validation, credential preparation, runtime discovery, customer locking, cancellation and authoritative results. WebGUI uses only its public aimctl API1.0 and the existing non-root executor.
A new run needs review, not a saved plan. Select exact customer/playbook/hosts, declared typed options, check/apply and key mode. Review normalized scope, warnings, credentials and the **declared result contract**. Core source/schema changes invalidate earlier reviews; the worker never silently rebuilds and executes stale scope. Saving remains optional with collision-safe default titles.
WebGUI execution policy, allowlist, grants, host limits and optional approval apply independently of Core's add-on opt-in. The executor's OS access is not proof of the browser requester's authority. Native inventory precedence is unchanged. Key mode customer requires the canonical owner-only key; key mode none is not forced password authentication.
The worker performs local readiness before offering the owner-only credential modal. Its five-minute empty reservation and sixty-second post-handoff preparation/start window remain. Supplied values use the existing private channel, never job records, argv, environment or journals. The modal preserves deliberate opening, grouped alternative key source, paste/show controls, field clearing and lost-response reconciliation without an automatic second POST.
## Progress and results are separate
1. A bounded structured journal records approved Core metadata independently of viewers. Reopening a running or ended job replays committed events and continues after its durable cursor. IDs correlate tasks/hosts; there is no guessed task total/ETA or future task plan. Raw terminal text and module dictionaries are not retained. See JOURNAL.md.
2. Native target outcomes and final Core status/exit remain authoritative. A final event without a final response cannot establish successful completion. A partially successful host population does not rewrite Core's overall failure.
3. Purposeful operation reports arrive only at finalization and are separately validated against the reviewed contract. They are retained subject to explicit local limits/policy and fetched lazily. See REPORTS.md. A report can be available for a failed run. Missing/invalid required reports after native exit0 yield Core failed/result_validation while native target stats remain unchanged; this is not a password error and never triggers replay.
A closed browser is not cancellation. User cancellation stops owned local process groups through Core, not already completed remote changes or independently running asynchronous work. Check mode is not an unconditional no-side-effect guarantee. Trusted playbooks can run controller-local/delegated tasks; both narrow staging exceptions stay enabled.
Manual retry creates a new reviewed job and fresh credentials. Changes to mode, key handling, hosts or options are not edits to queued work. No automatic retry or recurring schedule is introduced; the existing explicitly UTC one-shot schedule remains.
Only terminal jobs can be deleted. Deletion removes their lifecycle/idempotency/progress/report records and future history contributions, while keeping a compact audit deletion fact. Saved-plan deletion does not remove existing copied job intent. Protected backups are independently retained; deletion is not a promise of physical erasure. Old jobs are not rerun or reconstructed to populate missing evidence.
+43
View File
@@ -0,0 +1,43 @@
# Retained execution journal - 2.1.0rc9
## What is recorded
The worker captures only projected public Core metadata: job/run/sequence, source UTC time and receipt time, stage, opaque play/task IDs, approved/withheld labels, reviewed logical hosts, result/changed/ignored flags, supported retry/poll information, fixed diagnostic hints and recap counters. Unknown additive payloads, legacy duplicate progress in detail mode, raw stdout/stderr, debug bodies, credential frames and final report data are not journal entries.
Core labels remain withheld when Core withholds them. A missing host stays null; no inference from a nearby event. A task result can arrive interleaved with another host/task: use IDs, not last-arriving names. The result event records only that it was observed; **the final response** establishes the authoritative result and retained report.
The normal final Core result, targets and existing lifecycle/audit metadata remain separate. A journal does not become a second execution engine or replay operation.
## Browser-independent capture
Capture starts in the execution child after review/claim checks. An open page is not required. A 2,048-entry nonblocking queue feeds batches of up to 128 events with a normal 250 ms flush target. SQLite synchronous FULL transactions publish the event rows, bounded checkpoint and durable cursor together. The writer's busy timeout is 250 ms. Closing tries to drain within 2.5 seconds and waits up to 3 seconds for its thread.
Those are bounded scheduling/lock budgets, not a guarantee against slow storage or physical failure. A crash can lose queued/uncommitted events. Queue or write failures increment a loss counter on the next possible commit. Persistent storage failure or a killed writer leaves an unclosed capture, shown as interrupted when the job ends. Metadata loss does not establish a task failure or success and never triggers re-execution. If the database cannot persist the final result, the normal workflow cannot certify success from a prior event.
## Retention and display windows
```toml
[journal]
max_events = 20000
max_bytes = 8388608
```
The newest tail is retained. Older event rows are removed in the same commit when either limit is exceeded; a visible omission count/range remains. A bounded checkpoint retains last stage/play/task, observed task-start count, up to64 recent task contexts, and up to500 latest logical-host observations. Final Core facts do not depend on journal coverage. Limits are validated in Settings; the byte budget may be64KiB..64MiB and the event budget100..200000.
The initial page loads200 tail events; API pages allow1..500. The browser keeps at most2,000 displayed records, with a link to the paged timeline. Display pagination never changes retained counts. Large streams are rendered at most once per animation frame. Only the console's scrollTop changes; manual upward scrolling pauses Follow output.
Job deletion cascades through the journal. No terminal/Core-wide history collection and no shadow archive. Database/backup files stay in existing private storage; deletion is not secure physical erasure or deletion of prior backups. There is no silent age-pruning policy.
## Reopen, reconnect and authorization
GET the snapshot/tail, then connect SSE strictly after the committed high-water cursor. Last-Event-ID takes precedence over the initial after query on reconnection. A per-job local cursor and unique(job, Core run, Core sequence) suppress duplicates. Filtered legacy events produce ordinary Core-sequence gaps, not automatic loss claims. Retention gaps are explicit.
Every read uses existing owner-or-admin job authorization. Every streaming iteration rechecks live session, password-change state and job access. A cursor is not permission. Deleted/inaccessible jobs end the feed and request clearing the view. Two devices read the same committed rows and never directly attach to the executor process.
When the job ends, SSE drains through the available committed cursor and ends; history remains readable. Worker/service interruption does not resume Ansible. Closing a page does not cancel a job or resend a credential.
Progress means **last observed activity**, not a total task graph. There is no invented percentage, ETA, list of future tasks or proof of stall from silence. Host observations are not final per-host outcomes. The displayed time is localized while stored values remain UTC.
## Historical jobs
Schema migration does not fabricate progress for old jobs. They retain lifecycle and final facts but show detailed history unavailable. No terminal import or automatic rerun fills gaps. A new queued job may show waiting for capture until the execution child starts.
+105
View File
@@ -0,0 +1,105 @@
> Historical reference retained from the preceding release. Current deployment/contracts and evidence are in DEPLOYMENT.md, REPORTS.md, JOURNAL.md and VERIFICATION.md. Do not treat old limitations or tests as current qualification.
> Historical major-migration guide for the 1.x-to-2.x architecture. An existing 2.0.0rc8 deployment uses the ordinary 2.1 update in DEPLOYMENT.md; no schema change or migration acknowledgement is needed. Current capabilities are in API.md.
# Major migration: WebGUI 1.x / rc18 -> WebGUI 2.0 / AIM 3.1
Read CORE-3.1-REVIEW.md first. This is an explicit major migration. Core and add-on
are separate releases with separate backups and rollback decisions.
## Before downtime
1. Let active jobs finish; cancel deliberately only if their remote effects are understood.
2. Back up the old core using its OWN deployment procedure. Back up the WebGUI's
source/config/units/database with its managed deployment checkpoint. Do not reset
users or remove /var/lib/aim/webgui.
3. Deploy AIM 3.2.1rc2 independently using its included guide. Check terminal AIM first.
Do not copy core into the old WebGUI tree or install WebGUI into core's interpreter.
4. Choose an existing non-root execution account, normally svc_bf-ansible. It must
already satisfy AIM required_group, read its config/inventory/Vault, own each selected
customer key 0600, and see native collections. The add-on does not provision these
core permissions. `runtime.private_key_owner` does not migrate old keys or switch UID.
5. Check aimctl as that account, not merely from a root shell:
```bash
sudo -u svc_bf-ansible /usr/local/bin/aimctl --config /etc/ansible/scripts/aim.yml capabilities
printf '%s\n' '{"api_version":"1.0","operation":"list_customers"}' | sudo -u svc_bf-ansible /usr/local/bin/aimctl --config /etc/ansible/scripts/aim.yml request
```
The core config remains operator-owned. Its actual 3.1 settings include
`addons.execution_enabled`, `runtime.ansible_playbook` and
`runtime.private_key_owner` (NOT the earlier proposed execution_user/runtime_group
fields). Configure/qualify those using the core guide. The add-on never edits them.
The core acquires a stable customer `.aim.lock` during execution. The executor needs
access to that lock, or creation permission if it does not exist; a read-only customer
directory without an accessible lock is not sufficient. Use the core/operator's
permission procedure rather than widening the whole tree from WebGUI.
## Activation
Unpack the new ZIP into a fresh staging path and run:
```bash
sudo python3 deploy/deploy.py update --migrate-core
```
The explicit flag acknowledges: schema 4, stopped legacy pending/running work,
retirement of old key-export sudo bridge and its known worker capability drop-in,
HTTP API v2, a new executor service and release-config replacement.
The installer stages an isolated WebGUI venv/assets, probes the real public core
metadata as the selected executor account, checkpoints old files, stops add-on
services, migrates state, replaces source/config/units, then starts executor, web
and the opt-in queue worker. It never stops terminal AIM or writes core files.
Unknown systemd drop-ins block installation for deliberate operator review.
## What is preserved
Users/password hashes/sessions, named admin onboarding, grants, audit, selections,
all old job records/events, and all saved plan payloads. Pending/queued old-core jobs
become blocked; running ones become interrupted. Their state is not replayed.
Historical duplicates in saved-plan names remain separate records.
Legacy saved plans are reusable INPUTS only: open one, use its New run link and
review mode/key handling again. Old Custom credentials cannot silently become native
inventory credentials. A new valid review is required; no credentials are migrated.
## What is retired
Private core imports, rc18 adapter, command rewriting, private Ansible credential
resolver/strategy, sudo key export, CAP_SETUID/CAP_SETGID worker capability grant and
raw console interception. Known old helper/sudoers/drop-in files are backed up and
removed. There is no need to give aim-web canonical key read access.
## Rollback
Schema4 cannot be opened by old schema 3 WebGUI. Downgrading requires the explicit
--restore-auth-db flag, with loss of changes since that snapshot and possible
restoration of older passwords. Services and config/code/venv must match the snapshot.
Restoring a 1.x WebGUI while AIM 3.1 remains installed is NOT a working rollback: the
installer restores the files/database but leaves legacy services stopped/disabled.
Coordinate an independent core rollback before enabling them. Core is never rolled
back by this add-on. Interrupted remote operations are never undone automatically.
## Unsupported old behavior
There is no Custom forced credential override or password-only verification in
core API 1.0. No raw task/host output. Platform-group selection remains; recursive
subgroup paths are unavailable. These limitations are shown in the interface and
are recorded for an additive core extension, not bypassed through private access.
## Executor HOME and host trust
The release profile sets `[core] home = "/var/lib/aim-web-executor"`. aimctl and
native commands use that separate writable HOME, not /root or WebGUI's private
SQLite home. The executor remains the configured existing UID; no identity is
changed by HOME. Install independently verified SSH host records under
the effective SSH UserKnownHostsFile, owned by the execution account, or the already-reviewed system /etc/ssh/ssh_known_hosts policy. Process HOME alone does not select OpenSSH's known_hosts path. Do not blindly
trust ssh-keyscan output. Existing per-user collections under the old home are not
automatically copied: use the core's documented runtime.ansible_collections_path
configuration if required. System collections remain native-core-discovered.
The shipped systemd profile targets the documented /etc/ansible inventory layout.
Nonstandard inventory roots require a separately reviewed ReadWritePaths service
profile for core locks; this installer does not inspect private config to infer it.
+72
View File
@@ -0,0 +1,72 @@
# Patch-wave options and reports - WebGUI 2.1.0rc9 / Core 3.3.0rc8
This guide describes add-on presentation of the Core-provided patch contract. It does
not grant approval to install updates, reboot targets or perform repeated runs.
## Prepare and review
New run uses the public catalog for optional inputs. The Windows-only **Continue
patching after reboot** control advertises the live catalog hint (false in this Core
release). It starts as **Inherit**, not an explicit true override. Blank inputs stay
omitted; inventory/role defaults are authoritative. To explicitly forbid continuation
in a particular run, choose false and review the normalized override.
**Reboot when required**, **Reboot delay (minutes)** and **Reboot message** are separate
catalog controls. Enabling reboot must not implicitly enable continuation. Delay and
message apply only to an AIM-initiated reboot; the message is not part of the report.
The review highlights explicit vs inherited reboot, delay and continuation values.
Core still revalidates scope/options/schema revision before execution.
On Windows, each Core patch wave delegates the reviewed update categories to one native
`ansible.windows.win_updates` install invocation with module reboot disabled. Windows
Update and the collection own ordering inside that wave; AIM evaluates its reviewed reboot
policy only after the native wave returns. With post-reboot continuation disabled, the run
stops after an approved AIM reboot and another discovery requires a new reviewed run.
Enabling continuation explicitly permits Core to rediscover and start another native wave
after reboot within the same run, up to its 12-wave safety limit. There is no WebGUI
follow-up scheduler or automatic replay.
Linux keeps Core's native package-manager behavior and shared reboot-message/delay
semantics; Windows cycle fields are not fabricated on Linux reports.
## Reading a report
| Field or condition | Meaning in the UI |
|---|---|
| Core succeeded + continuation_required true | Successful wave, but further patching needs review. The job is not relabeled failed. |
| remaining_updates_known false | Remaining updates are not established. Do not show an empty pending list as zero remaining updates or reuse a pre-reboot queue. |
| remaining_updates_known true | Pending is the final read-only discovery for the selected scope and recorded time, not current compliance or an expanded install queue. |
| No remaining_updates_known field | Historical shape. Missing wave fields stay unestablished; no inferred post-reboot queue. |
| reboot_deferred true | A reboot remains required with automatic reboot disabled. Installation success and reboot status remain separate. |
| reboot_reasons_before | Bounded native reboot sources reported by `ansible.windows.win_reboot_info` before patching. These are observations from that run, not a live probe. |
| blocked_reason preexisting_reboot_required | Core's independent preflight observed a prerequisite reboot; new patch work did not start on that path. |
| blocked_reason cycle_limit_reached | Core stopped bounded continuation; a new decision is needed, never an automatic replay. |
| failed_updates | Per-update title/ID, unsigned and hex HRESULT, fixed reason and safe message supplied by Core. |
| install_not_allowed / 0x80240016 | May indicate an active installer or mandatory reboot; not proof of a pre-existing reboot by itself. |
| Check mode | Observations/predictions, never proof of installation or an actual reboot. |
The normal report summary keeps the pre/post reboot observations, performed/deferred
flags, reviewed delay, cycle count and continuation policy visible when supplied.
Pending-list display suppression is a presentation decision when Core says the list
is not authoritative. It does not modify stored data: the retained structured JSON
still provides the exact approved report body for inspection.
Report-slot `complete` is availability, while payload `data.complete` describes update
evidence. Neither independently proves the host is fully patched. Native target
outcomes, Core result-validation failures and per-host reports remain separate.
## Historical data and permissions
Old reports retain their reviewed schema. No migration rewrites old patch outcomes or
adds missing fields. Only retained WebGUI jobs contribute to host history; no terminal
run tracking, inventory writes, raw logs or new credential storage are introduced.
Report reads remain owner-or-admin and job deletion removes linked evidence. The
Checkmk configuration-content retention opt-in is unchanged.
## Controller acceptance
Use Core's current SANITY.md under the actual executor context on a disposable,
approved target. Confirm native `win_updates` wave behavior, the default stop after an AIM-performed
reboot, explicit post-reboot continuation behavior, read-only final discovery,
reboot-disabled/pre-existing-reboot cases and a bounded failure record. Compare actual
effects and reported fields, not old task counts. Do not deliberately patch or reboot
production systems merely to exercise a user-interface feature.
@@ -0,0 +1,42 @@
> The behaviors below are retained from2.1.0rc2. Current candidate2.1.0rc9 targets Core3.3.0rc8/schema5 and adds reports/journal described in REPORTS.md and JOURNAL.md. Credential, read-only inventory and OS-permission boundaries here remain unchanged; old version/no-migration statements describe the earlier slice.
# Managed permissions - WebGUI 2.1.0rc1
No permission change from2.0.0rc8. This document describes the existing add-on-owned contract, not instructions to recursively chown AIM.
| Resource | Owner/group | Mode |
|---|---|---|
| WebGUI source, virtualenv, units | root-owned | Release-managed |
| webgui.toml | root:aim-web |0640|
| /var/lib/aim/webgui |aim-web:aim-web|0700|
| SQLite database |aim-web:aim-web|0600|
| /var/lib/aim-web-executor and .ansible/tmp chain |executor:native primary group|0700|
| passwd-home .ansible and .ansible/tmp |executor:native primary group|0700|
| /run/aim-web-executor |executor:native primary group|0711|
| /run/aim-web-executor/core.sock |executor:aim-web|0660|
Site executor is svc_bf-ansible. Systemd preserves its native primary group and grants aim-web as a unit-scoped SupplementaryGroups entry; numeric group IDs may appear in systemctl output. Other account memberships are resolved normally, so an empty explicit supplementary setting is not proof of an empty actual group list. The installer does not silently rewrite OS account memberships.
The0711 runtime directory permits traversal to the known socket path, not directory listing for unrelated users. Socket DAC and peer checks govern access. NoNewPrivileges and empty capability sets remain; no sudo or root worker. ProtectHome remains read-only with a narrow writable passwd-home .ansible/tmp exception for delegated local tasks, plus the separate process-home staging path. The installer waits for socket readiness and checks exact managed owner/mode before starting dependent services.
## Verification, not manual repair
```bash
systemctl show aim-web-executor.service -p User -p Group -p SupplementaryGroups
stat -c '%U:%G %a %n' \
/var/lib/aim-web-executor \
/var/lib/aim-web-executor/.ansible/tmp \
/home/svc_bf-ansible/.ansible/tmp \
/run/aim-web-executor \
/run/aim-web-executor/core.sock \
/etc/ansible/scripts/config/webgui.toml
sudo journalctl -u aim-web-executor.service -n 60 --no-pager
```
The release-managed ExecStartPre runs staging checks inside the real executor unit. Interactive sudo -u svc_bf-ansible alone does not reproduce unit-scoped aim-web group access to webgui.toml. Do not make that config world-readable to hide the distinction.
## Outside add-on ownership
AIM source/configuration, authorization groups, inventory/Vaults, canonical owner-only0600 private keys, /etc/ssh/ssh_known_hosts, certificates and Nginx remain Core/operator managed. The add-on does not enroll host keys or infer their trust from HOME. Keep independently verified effective SSH trust. Unknown unit drop-ins stop deployment for review; do not silently restore obsolete privilege/capability workarounds.
The new explorer, activity and insights pages require no new write path, group, service, port or secret permission.
@@ -0,0 +1,96 @@
> Historical reference retained from the preceding release. Current deployment/contracts and evidence are in DEPLOYMENT.md, REPORTS.md, JOURNAL.md and VERIFICATION.md. Do not treat old limitations or tests as current qualification.
# AIM WebGUI 2.1.0rc2 compared with Jenkins, Semaphore UI and Foreman
## Basis and limits
This is a documentation-based capability and product-direction comparison, not a hands-on usability benchmark, security audit, licensing quotation or performance comparison. AIM statements refer to the inspected 2.1.0rc2 source and its test evidence. Other product statements refer to the official documentation listed below, consulted for this release. Features can depend on installed plugins, edition and configuration; Foreman examples use the published 3.18 host-management guide without claiming that version is the newest stable release. Recommendations below are design judgments, not measured rankings.
## The products solve different problems
**AIM Web** is a focused operator interface over a separately managed AIM Core. It discovers Core-owned customers, hosts, groups and catalog playbooks; prepares explicit reviewed runs; requests transient credentials only when the worker is ready; and shows Core-owned results. It does not own inventory/Vault editing, arbitrary automation code, server provisioning or a general plugin execution ecosystem. Its history covers retained WebGUI jobs only. [A1]
**Jenkins** is primarily a Pipeline/CI/CD automation platform. Pipeline models multistage work and supports extensible execution through steps and plugins. Its Ansible plugin accepts playbooks, inventories and credential IDs. Treating Jenkins as a direct equivalent of a host inventory system would obscure that pipeline-centric design. [J1, J2]
**Semaphore UI** is the closest operational comparator: projects combine repositories, inventories, reusable credentials, variables and task templates, with each execution recorded as a task. Its documented scope also includes tools other than Ansible. The user guide documents schedules and workflow-related capabilities, with some capabilities edition-dependent. [S1, S2]
**Foreman** is broader host lifecycle management, not merely a Puppet runner. It maintains host inventory/group settings and supports provisioning and infrastructure integrations. Its documented remote-execution and Ansible workflows can operate against selected hosts through Smart Proxies; Puppet is one part of that ecosystem. [F1, F2]
## Functional comparison
| Area | AIM Web 2.1.0rc2 | Established-product reference |
|---|---|---|
| Inventory | Current read-only Core hierarchy; linked SVG/outline explorer; customer-scoped host pages | Semaphore manages inventory resources used by templates. Foreman manages hosts, host groups and inherited settings. Jenkins' Ansible plugin consumes inventory files/inline inventory for pipeline execution. [S3, F1, J2] |
| Starting work | One-run review without mandatory plan; optional collision-safe saved title; existing independent approval when enabled | Semaphore starts tasks from templates and exposes user prompts. Jenkins offers Pipeline parameters/input steps. Foreman provides host selection and job-template workflows. [S2, J3, F2] |
| Credential experience | Owner-initiated modal, Core-required fields, one-run lifetime, no add-on reusable secret store | Jenkins supports stored credentials referenced by IDs. Semaphore has a Key Store for reusable credentials. Foreman job settings include authentication/password and key-passphrase inputs where applicable. These are different operating models, not a security ranking. [J4, S4, F2] |
| Output | Core-filtered static play/task/host labels and hints, bounded ephemeral stream; authoritative final per-target outcomes retained with the job | Jenkins' Ansible plugin supports console output; Semaphore documents live/completed logs and a raw-log view. Broader log access is useful for diagnosis but creates a different retention/exposure decision. [J2, S5] |
| History | Host Activity and Playbook Insights over authorized retained WebGUI jobs, separating Check/Apply and host/whole-job outcomes | Semaphore exposes task/template history; Foreman is natively host-oriented. Do not equate a job log with an authoritative machine-wide history or claim a feature is absent merely because its documentation was not inspected. [S2, S5, F1] |
| Orchestration | Existing queue, bounded targets, optional independent review and one-off UTC scheduling; no workflow DAG or recurring scheduler | Jenkins supports pipeline composition. Semaphore documents cron schedules; its documentation identifies workflows as a Pro feature. Foreman offers remote-job controls through its host-management workflow. [J1, S6, S7, F2] |
| Access | Local accounts, customer/playbook execution grants; owner-or-admin job/history access and owner-only plans; not hostile-tenant isolation | Semaphore has project teams and built-in roles, with Enterprise extended permissions. Jenkins credential use is scoped and depends on authorization/plugin configuration. Wider identity deployments need product-specific evaluation. [S8, J4] |
| Extensibility | Fixed public Core contract; add-on cannot import private Core managers or rewrite Ansible arguments | Jenkins has a broad Pipeline/plugin model; Foreman integrates host/provisioning components; Semaphore supports several automation applications. Flexibility also increases the scope an administrator must configure and govern. [J1, F1, S2] |
## What the new modal improves
The implemented path is now: open an existing reservation, recognize the reviewed customer/targets/mode, enter only the required credentials, submit once, then follow the job. Core-permitted key-passphrase choices use native radio buttons styled as a segmented control. Changing that presentation choice does not change reviewed scope, native inventory precedence or execution identity. [A2]
This intentionally avoids asking an operator to configure a reusable credential resource just to perform one reviewed run. It also preserves a cost: repetitive or unattended operations are less convenient when credentials must be resupplied. That tradeoff is part of the current requested product boundary, not evidence that all stored-credential products are unsafe. [A2, J4, S4]
Jenkins' input step demonstrates the value of making pending human input an explicit workflow state. Semaphore's template prompts demonstrate the value of exposing only the options a particular task needs. Our application of those lessons is the new Needs your attention section and Core-driven fields, not importing their wider parameter or credential models. [J3, S2]
## Where AIM Web is already well aligned with this controller
The strongest fit is the combination of explicit target review, customer-aware discovery, optional saved plans, per-target final results and linked host activity. Operators can inspect an inventory branch, open a host, find its last retained Checkmk result and return to the source run without moving inventory ownership out of AIM Core. The mobile header and native modal now make that narrower workflow easier to navigate. These are implemented behaviors, not evidence that our UI is universally faster or more accessible than the other products. [A1, A2]
The history design is unusually explicit about what it does not know: no terminal AIM runs, deleted-job reconstruction, current-health score, inferred installed version or invented success for legacy data. A partially successful parent job does not erase each target's own final outcome. Preserve that clarity as the UI grows. [A1]
## Where established platforms set a higher bar
**Operational breadth:** reusable template catalogs, external integration, distributed execution, scheduled orchestration and richer organization models are documented strengths across these platforms. AIM Web does not yet provide comparable breadth, and turning a UI preference into a rushed workflow engine would undo the modularity gained from Core. [J1, S1, S6, S7, F1]
**Investigative detail:** a retained full log can answer questions our ephemeral, filtered stream cannot. Semaphore explicitly exposes task logs after completion. AIM currently retains final facts, not a full transcript. A future Core-approved retention contract should be considered separately rather than quietly capturing raw stdout because it is convenient. [S5, A1]
**Release and deployment assurance:** our recent manifest, startup and permission failures remain evidence that packaging and controller acceptance need attention. A passing synthetic suite does not establish production-browser, systemd, SSH/WinRM, upgrade or security qualification. This report does not claim comparable maturity or measure other products' defect rates. [A3]
**Access administration:** project-scoped teams, federation and larger administrative structures deserve a deliberate design if the audience expands beyond the current controller. Semaphore documents built-in project roles and Enterprise extensions; those are not equivalent to our existing local execution grants. [S8, A1]
## Security is not a badge comparison
Jenkins documents encrypted stored credentials and ID-based use; its binding documentation also explains masking limitations and risks from processes sharing execution accounts. This is useful context, not a reason to claim that masking or a modal prevents all disclosure. [J4, J5]
AIM's transient credential path reduces intentional add-on secret retention, but authorized controller code and service accounts remain trusted. DOM clearing cannot prove physical memory erasure. Core owns execution semantics; an accepted credential handoff does not prove authentication. Foreman/Semaphore/Jenkins have different persistence, identity and deployment choices that require their own configured-environment review. [A2]
## Recommended direction after this RC
My recommendation is to borrow interaction patterns, not product scope.
1. **From Semaphore:** make supported playbook inputs feel like a small, well-labeled task form. Keep preflight and execution semantics in Core. Improve template/saved-plan discovery before adding arbitrary parameters.
2. **From Foreman:** strengthen host-centric navigation and contextual actions. Add dated last-result overlays and comparisons of recorded runs, not invented live health or inventory mutation.
3. **From Jenkins:** make stage transitions and pending human action unmistakable. Keep cancellation, acknowledgement uncertainty and dependency failures visible without turning every event into a wall of log text.
None of those follow-on features is claimed shipped in this RC. The immediate release contains the modal and attention surface; targeted retry preparation, history overlays, run comparisons, federation and workflow orchestration remain separate proposals. Reliability and installed-controller acceptance should remain the next gate.
## Official references and inspected AIM files
Sources are provided as exact locations so the comparison can be rechecked as products evolve.
- **A1** - This release: `AGENTS.md`, `docs/READ-ONLY-EXPERIENCE.md`, `docs/API.md`, `src/aim_webgui/activity.py`, `explorer.py`, `workflows.py` and `worker.py`.
- **A2** - This release: `docs/RUN-COMFORT.md`, `docs/CREDENTIALS.md`, `credentials/presentation.py`, `credentials/service.py`, `routes/workflows.py`, credential templates and `static/js/credentials.js`.
- **A3** - This release: `docs/VERIFICATION.md`, `docs/verification-results.json` and preserved `CHANGELOG.md` provenance.
- **J1** - Jenkins Pipeline: `https://www.jenkins.io/doc/book/pipeline/`
- **J2** - Official Jenkins Ansible plugin: `https://plugins.jenkins.io/ansible/`
- **J3** - Jenkins Pipeline Input Step: `https://www.jenkins.io/doc/pipeline/steps/pipeline-input-step/`
- **J4** - Jenkins Using credentials: `https://www.jenkins.io/doc/book/using/using-credentials/`
- **J5** - Jenkins Credentials Binding: `https://www.jenkins.io/doc/pipeline/steps/credentials-binding/`
- **S1** - Semaphore UI user guide: `https://semaphoreui.com/docs/user-guide`
- **S2** - Semaphore task templates: `https://semaphoreui.com/docs/user-guide/task-templates/views` and `https://semaphoreui.com/docs/user-guide/task-templates`
- **S3** - Semaphore inventory: `https://semaphoreui.com/docs/user-guide/inventory`
- **S4** - Semaphore Key Store: `https://semaphoreui.com/docs/user-guide/key-store`
- **S5** - Semaphore Tasks and log retention: `https://semaphoreui.com/docs/user-guide/tasks`
- **S6** - Semaphore Schedules: `https://semaphoreui.com/docs/user-guide/schedules`
- **S7** - Semaphore documentation index (Workflows Pro): `https://semaphoreui.com/docs`
- **S8** - Semaphore Teams and Enterprise RBAC: `https://semaphoreui.com/docs/user-guide/team`
- **F1** - Foreman introduction: `https://www.theforeman.org/introduction.html`
- **F2** - Foreman 3.18 Managing hosts: `https://docs.theforeman.org/3.18/Managing_Hosts/index-foreman-el.html`
- **U1** - User-provided Bootstrap4 button pattern: `https://getbootstrap.com/docs/4.0/components/buttons/#checkbox-and-radio-buttons`
- **U2** - Bootstrap5 native check/radio toggle buttons used by this release: `https://getbootstrap.com/docs/5.3/forms/checks-radios/`
- **U3** - WAI modal dialog interaction guidance: `https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/`
@@ -0,0 +1,46 @@
> The behaviors below are retained from2.1.0rc2. Current candidate2.1.0rc9 targets Core3.3.0rc8/schema5 and adds reports/journal described in REPORTS.md and JOURNAL.md. Credential, read-only inventory and OS-permission boundaries here remain unchanged; old version/no-migration statements describe the earlier slice.
# Read-only experience - 2.1.0rc1
## Mobile header
At <=760px the top bar is one row: AIM logo/home shortcut left, light/dark sun/moon segments to the left of the hamburger at the right edge. Navigation and account actions open in a native details dropdown beneath it. Escape returns focus to the hamburger; outside clicks, navigation and desktop resize close it. No-JavaScript details navigation remains available. There are no swipe rails or arrow instructions. Above760px the desktop sidebar remains.
## Inventory Explorer
Open Inventory explorer from navigation, then select a customer. Core's current hierarchy and flat host metadata are fetched for that request only. Desktop starts with a deterministic linked SVG map; phones start with the equivalent outline. Map/Outline links switch representation. The map shows the current branch, at most12 immediate subgroups and up to3 direct host links per subgroup. Open a group to drill down. Up to24 direct hosts are listed per page. Search pages have50 results. These display limits never change execution targets or summary totals.
Each group uses its full Core path, not just the leaf label. Direct customer-root hosts are shown. Repeated membership is valid; total group/root host counts use distinct logical names. A host links to one customer-scoped activity page regardless of how many branches contain it. Map lines are inventory memberships, not network links or dependencies. Empty groups and no direct members are explicit.
Zoom buttons and internal scrolling are optional conveniences; all nodes also have ordinary links and the outline view. A graph click never prepares or executes a job. Current inventory failures show an error, not an invented empty or offline graph. There is no persisted secondary inventory, force simulation or remote discovery. Historical outcome overlays on graph nodes are deferred; the activity link supplies dated results.
## Host Activity
Identity is `(customer, exact logical hostname)`, not a machine UUID. Current membership/platform/address is read from Core separately from history. A removed/renamed host can retain history without implying it is still targetable; no automatic merging occurs. Core outages show a current-metadata-unavailable banner while authorized retained history remains usable.
The page offers mode, rolling time range, playbook and target-outcome filters; playbook summaries, a25-record timeline, expandable numeric target counters and the viewer's own saved plans containing the exact hostname. A successful target remains successful even if the whole job partially succeeded. Link to the source job for overall status, Core exit and reviewed scope.
## Playbook Insights
The matrix shows the latest matching record per customer/host/playbook, with timestamp, mode and source-job link. Mobile renders host cards instead of compressing the matrix. Matrix pages have20 hosts and up to12 playbook columns; larger catalogs require filtering and disclose omitted columns. Statistics cover all matching retained records, not only the visible rows or the100-job overview. Outcome filtering means latest *matching* outcome, not necessarily latest run; the page labels that distinction.
## Exact data and metric scope
- Only jobs executed/tracked by this add-on and still retained in its workflow database contribute. No terminal AIM history, Core-wide history, scan, agent or second event collector is added.
- Each distinct requested host contributes at most one sample per job. Group appearances, SSE reconnects and task counts do not create extra samples. Retries are separate jobs/attempts.
- Owner-or-admin job visibility is applied in SQL before data is read/aggregated. Saved plans stay owner-only even for administrators. Filters, cells and totals obey the same scope.
- Apply mode is the default. Check is separate; All explicitly combines labeled records. Check results are never evidence of installed changes. Rolling7/30/90/365days and All retained are available. The time axis uses finished_at or, when absent, created_at; all timestamps stay UTC internally.
- Success rate = successful / (successful + failed + unreachable). The denominator is displayed. Not started, indeterminate, missing/legacy detail and unfinished jobs are counted separately. A zero denominator displays no rate.
- Final outcomes come from valid Core target summaries whose host set matches the stored reviewed targets. Invalid/missing legacy facts become Detail unavailable. A nonterminal database job is Not finished even if a final response is in flight.
- Changed values are task counts. The derived metric says host/run samples reporting changed tasks, not changed hosts. No per-host duration is inferred from job duration. No live-health, compliance or installed-version score.
- Deleting a job removes its contribution immediately on the next query. Audit deletion events are not enough to recreate per-host outcomes and are not used to do so. No shadow results archive or materialized analytics table exists.
## Read services and performance
`activity.py` performs a consistent SQLite read snapshot, streams all matching job rows and builds only safe presentation records. Output records and matrix are paginated; all-history aggregation cost remains linear in matching retained job data. Customer/playbook/mode/time/owner predicates run in SQL. Distinct host/playbook cells are retained in memory for latest-result projection. This is suitable for the measured candidate scale, not an unbounded analytics claim. The optional synthetic benchmark is in `tests/benchmark_read_history.py`; consider indexed/materialized projections only after measured need, with deletion and authorization parity.
`explorer.py` performs read-only public hierarchy/host requests and computes presentation geometry; `read_views.py` registers GET routes. There is no credential/prepare/execute call or extra executor operation for these pages. UI state is request-local except non-sensitive existing theme preference. No browser inventory/history storage is added.
## Deliberately not in this slice
No saved-plan edits or inventory/Vault management, host probes, terminal-history tracking, raw output retention, automated retry or scheduling changes. The graph is branch-focused, not a full unlimited topology canvas. Account-synced density/view preferences and graph last-result overlays can follow later after this candidate is qualified.
+62
View File
@@ -0,0 +1,62 @@
# Operation reports - 2.1.0rc9 / Core3.3.0rc8
Core's live `operation_results` capability, catalog result declaration and prepared result_contract are validated and preserved with review. Core's public protocol is `aim_operation_result_v1`; its publisher convention is `aim_output_v1`. The API remains1.0 and no new execute flag/schema path/report body is supplied by the browser.
The recorded contract, not a newly fetched catalog schema, governs historical rendering. Schema changes stale preparation. No private Core imports, local inventory parsing or external schema/$ref execution is added. An independently implemented consumer validates the documented bounded JSON-schema subset. Unknown additive wrapper fields are discarded rather than stored. Unknown report identifiers using the supported schema language get a generic renderer; unsupported protocols/languages fail explicitly.
## Three independent facts
1. Core status/stage/native exit and remote_work_may_have_started.
2. Native per-target outcomes from the final Core stats.
3. Report-slot availability and local retention.
Native exit0 plus a missing required report remains **failed/result_validation/exit0**, even when all native targets are successful. An existing failure can contain useful available reports. Complete report slots do not prove execution success. No automatic retries occur; already applied changes are not rolled back. Reports require the final response; an observed final event alone is not enough.
Only available slots render data. Missing, withheld, invalid, not_started and indeterminate have data:null and never become zero/false/empty reports. Null versions remain unknown. Slot errors are fixed nullable codes, not arbitrary exception messages. Native target metrics in Host Activity keep their existing denominator and meaning.
## Views
| Schema | Presentation and meaning |
|---|---|
| host_capabilities_v1 | Eight Yes/No facts, not current inventory membership or live health. Missing is not No. |
| filesystem_usage_v1 | Mount/drive table, observed byte counts and utilization, explicit unavailable/null. Mapped drives remain WinRM-session scoped. |
| event_log_export_v1 | Channels, time window and target file paths; no browser download/file-read capability. Check mode reports no completed export. |
| service_start_summary_v1 | Before/eligible/attempted/excluded/after facts and fixed failure reasons. Excluded is not a failed start; observed running does not prove sole causation. |
| patch_summary_v1 | Linux net package/version-set changes or Windows update IDs/KBs, installed/pending/failed and reboot evidence. data.complete is separate from report complete; not an exhaustive transaction log. |
| managed_cleanup_preview_v1 | Candidate versus actual removed managed files, with mode prominent. No unknown-file purge. |
| checkmk_user_config_v1 | Parsed/redacted configuration or default metadata subset, not raw YAML/comments. Redacted markers are not absent settings. |
| checkmk_agent_state_v1 | Observed installation/version/source, services and managed changes. Do not infer version from an MSI filename. |
| checkmk_agent_config_v1 | Named section/file/check changes; not raw field diffs or a count of untouched unknown files. |
All nine have schema-keyed titles/semantic notes, scalar fact cards, bounded array/object sections and an escaped JSON alternative. Collection sections paginate at50 entries. Nested long values are visibly shortened in tables; the separately loaded retained JSON is not truncated. Unknown supported schemas/global scope have generic rendering. Core ships no global operation; its native qualification is separate.
Reports are available **when Core finalizes**, not while a publisher task is merely running. Job sections link Summary/Progress/Targets/Reports. The report page shows host, mode, recorded time, source job, execution verdict, availability and retention. Host Activity links the latest20 authorized report references for its exact customer/host across all dates and modes, separately from history filters. No current-state overlay or inventory update is implied.
## Persistence and confidentiality
```toml
[reports]
max_bytes = 16777216
retain_configuration = false
```
Report bodies live in job-linked tables, not the ordinary jobs.core_result payload used by status polls and analytics. The worker atomically records the compact final result and report metadata/data once. Each report read is authorized owner-or-admin, with lazy one-slot JSON retrieval. Paths/URLs remain text and never become arbitrary downloads or commands. HTML/Rich-looking strings are escaped. No browser data storage or raw-body logging.
The default retains validated ordinary reports, bounded by the reviewed slot cap (up to1MiB) and16MiB Core/add-on per-run caps. Lower local caps skip whole bodies with not_retained_limit rather than cutting JSON while claiming validity. Original Core availability remains separate from local retention.
For checkmk_user_config_v1, **full sections are NOT retained by default**. The metadata_only subset contains path, exists, size_bytes, last_write_time_utc, redacted_paths and comment_preservation. That subset is explicitly labeled, not claimed as a full schema payload. Set retain_configuration=true only after approving the extra data-retention exposure and restart/reload the relevant services; release-managed config replacements require re-review of that preference. It applies to newly finalized runs and cannot restore prior omitted sections.
Core filtering is not a universal secret scanner. Operational reports can disclose configuration choices. Keep private database and backup permissions, HTTPS, session/RBAC and operational source trust. Supplied secrets are checked again during public-report validation. No arbitrary debug/module output, invocation, environment or passwords are added.
Delete a job -> its report and journal rows disappear. Audit holds only the deletion fact. No separate archive; protected backups have their own operator retention policy. No backfill from terminal history.
## Transport budget
Large **public responses** use128MiB JSONL-line,512MiB total-process-output and32MiB canonical UTF-8 executor-frame bounds; non-execute metadata lines are bounded at32MiB. These finite transport ceilings accommodate encoding/wrappers and duplicated final event/response, not a license for report payloads beyond Core's1MiB/slot and16MiB/run limits. Structural decoding rejects excessive nesting, duplicate keys and non-finite numbers. The consumer revalidates data and reviewed target/mode/schema association. Torn/oversized responses never become partial valid JSON or success.
Request/secret boundaries are unchanged: normal browser bodies64KiB, credential bodies8KiB, private secret frames bounded, per-value WebGUI2048UTF-8 bytes and the existing Core credential FD. Increasing response capacity did not increase secret lifetimes, password sizes or inbound command authority.
## Core rc8 patch-wave extensions
See [PATCH-WAVES.md](PATCH-WAVES.md) for reboot-before/after/deferred/delay fields, Windows continuation/cycle reporting, remaining-update knowledge, and supplied unsigned/hex HRESULT/reason/message fields. The patch view explains these facts without changing the Core/job verdict. Pending is not shown as a final remaining-update list when Core explicitly reports remaining_updates_known=false. Structured JSON remains available; no report data is rewritten. Old reports retain their recorded contract and missing optional values are never backfilled.
+7
View File
@@ -0,0 +1,7 @@
# Roadmap after2.1.0rc9
Implemented candidate: Core3.3 reports/contract transport, schema5 retained metadata journal and job-bound report storage, reconnect/reload/multi-view replay, dated Host Activity report references and conservative configuration retention. All existing read-only/mobile/modal/workflow features remain.
Next gate is controller acceptance and bug fixes, not expanded execution authority. Qualify real Ansible2.19.11 publishers, report semantics and both staging locations inside installed services; browser pinned assets/HTMX/SSE/mobile keyboard; recover/rollback; load/backups and deletion policy. See VERIFICATION and CONTROLLER-PILOT.
Later independently approved UI work: problem-target filters, comparison of two retained host reports, optional dated map overlays, density/view preferences, and richer presentation driven by future supported schemas. Current reports are finalization-only. Do not invent live reports, monitoring/current compliance, terminal-wide history, private Core reads, arbitrary Ansible args, automated replay or a second credential store.
+43
View File
@@ -0,0 +1,43 @@
> The behaviors below are retained from2.1.0rc2. Current candidate2.1.0rc9 targets Core3.3.0rc8/schema5 and adds reports/journal described in REPORTS.md and JOURNAL.md. Credential, read-only inventory and OS-permission boundaries here remain unchanged; old version/no-migration statements describe the earlier slice.
# Run comfort - AIM WebGUI 2.1.0rc2
## Scope
Based on the working 2.1.0rc1 read-only release, targeting separately managed AIM Core3.3.0rc3/API1.0. This is a user-interface enhancement to the existing one-run credential path, not a password store, new authentication mode or execution engine. SQLite4, WebGUI HTTPv2, Core detail/outcome/hierarchy/staging contracts and release-managed permissions remain unchanged.
## Open without leaving the job
When the worker is ready, the job owner selects **Unlock this run** on Job detail or **Unlock run** in Needs your attention. A native modal opens over the current page, with the reviewed playbook/customer, target count and Apply/Check mode visible. It does not open automatically, move the user into a new window, or focus a password field without a deliberate action. The title gets initial focus so opening on a phone does not immediately summon the keyboard.
The form is fetched lazily and is not part of the HTMX-polled job or attention region. Refreshing those regions does not erase typing. Tab/Shift+Tab stay inside the dialog; Escape or the explicit Close button dismisses it and returns focus, even when polling replaced the original trigger. A backdrop tap is ignored to avoid accidental loss of input. The viewport-bounded body scrolls internally and responds to visual-viewport resize. The full-page link still works without JS or dialog support, and also when deliberately opened in another tab.
## Use only the fields Core requires
Vault-only jobs show one password field. Each input has a persistent label, Required/Optional text, Show/Hide button and Caps Lock hint. Paste is supported; the app neither stores values in browser storage nor disables a user's deliberate password-manager use. These are infrastructure passwords, not MFA/one-time-code inputs.
When the requirements contain both `vault_password` and `ssh_key_passphrase_or_customer_vault_value`, a native radio-button group styled as buttons offers **Use customer Vault** (default) and **Enter separately**. Choosing the latter reveals a required key-passphrase field; returning to Vault clears/disables it. When a key is explicitly required by Core, the field stays required and the alternate-source toggle is not offered. A requested connection password is labeled a default, since native inventory precedence still applies. No Vault/Custom credentials switch, forced account override, become field or private-key upload is added.
The user's Bootstrap4 grouped-radio example was used as visual inspiration. Implementation uses existing Bootstrap5 `btn-check` inputs with associated labels and native radio semantics, not Bootstrap4's button plugin or jQuery. Theme tokens and focus contrast remain in the established design system.
## Submission and deadlines
The timer is synchronized to the server's existing reservation deadline and advances from a monotonic browser clock. Opening, polling and typing do not extend the five-minute window. An amber near-expiry notice is displayed once; the timer is not announced every second. POST validation, not the client timer, controls eligibility. Handoff/start keeps its existing 60-second bound.
Submit clears the live DOM values (including revealed text inputs), disables repeat interaction and sends only supported credential fields in the authenticated/CSRF-protected JSON POST. The confirmation says **handoff accepted**, not password verified. Core validates Vault/key/connection details later, and the user can close the dialog to follow the existing job output. Closing is not a job cancellation.
If the HTTP response is lost or uncertain, credentials are cleared and the form locks. It reads owner-only job status rather than resending the password automatically. Reopening that job on the same page preserves only the uncertainty marker/job ID, never secrets. The worker's existing atomic claim prevents reuse; refreshing a page is not a way to replay a claimed handoff. A 400 field-validation failure can allow another explicit corrected submission; throttles, revoked permission, expiry and ended jobs remove the input opportunity. No error echoes credential values or raw Core exceptions.
Clearing references is not physical memory erasure. The browser, network stack and operating system may have other transient copies. Infrastructure administrators must still manage TLS, proxy buffering/logging, dumps, swap and the trust of service identities.
## Needs your attention
Overview shows up to eight actionable jobs; Jobs shows up to100 and discloses any remaining total. The query scans current actionable jobs rather than only the newest100 history entries. Your own live credential reservations come first. Administrators see other owners' jobs needing independent approval as review links, not automatically approved jobs and not another requester's credential form. Ended/expired/canceled jobs leave the action list. Ordinary failed jobs stay in history rather than masquerading as pending input.
## Retained functionality
Inventory map/outline, Host Activity, Playbook Insights, mobile hamburger/theme controls, customer-scoped histories, one-run review, unique saved-plan names, partial-result presentation, manual fresh retry and terminal-only deletion remain. History continues to include only retained WebGUI jobs. No terminal Core history, inventory writes, secrets cache or live host probing is introduced.
## Operator acceptance
Use a disposable authorized job and test both Vault-only and a customer-key requirement. Verify fields against Core, focus/keyboard/mobile viewport behavior, a live HTMX refresh while typing, close/expiry/revocation clearing and an accepted handoff followed by the actual run. Test an ambiguous response only on a safe disposable operation and confirm no automatic POST repetition. Verify fallback form and owner/admin policy. Source/browser fixture evidence and untested production boundaries are in VERIFICATION.md.
+23
View File
@@ -0,0 +1,23 @@
# Security boundaries - 2.1.0rc9
Preserve the established web/auth/queue/executor separation: non-root web and existing non-root executor, fixed Core executable/config, Unix peer checks, normal service groups, both narrow staging exceptions and no new sudo/capability/key-export path. This is a trusted-controller execution service, not a hostile-tenant or malicious-playbook sandbox.
Argon2id, opaque server sessions, Secure/HttpOnly/SameSite cookies under HTTPS, CSRF/origin/trusted-proxy policy, throttles, current grants, explicit scope/revision review and last-admin checks remain. One-run credentials are collected only for the owning reservation and travel via private frames/FD. No password in job data, reports, journal, browser storage, logs, argv, environment or files. Keep existing deadlines, no automatic retry/POST replay and honest handoff-accepted wording. Reference cleanup is not physical memory erasure.
## Approved persistence boundary
This release intentionally replaces ephemeral-only progress with a **bounded structured journal**. It does not store raw Ansible stdout/stderr, debug values, rendered terminal transcripts, invocation/module dictionaries or decrypted Vault data. Only validated public detail metadata/fixed hints is retained; unknown additional event fields are dropped. Core's withheld labels/hosts remain withheld. A rejected/missing final response cannot be promoted from an earlier event.
Operation reports are explicitly declared Core data, not automatically harmless content. Validate the recorded public schema, request host/mode association, scope, limits and availability. Supplied-secret checks are defense in depth. Static labels and unknown configuration values can still disclose information; Core filtering is not universal secret detection. Full parsed Checkmk sections therefore require explicit retention opt-in, defaulting to availability/file/redaction metadata only.
All replay/report reads and statistics enforce the same owner/admin access as jobs; plan references remain owner-only. Session/job access is rechecked during streams. Cursors are not bearer authorization. Auth revocation ends live views; already displayed/downloaded/copied information cannot be recalled from a user.
Only textContent/escaped templates render report strings. No arbitrary URLs, file downloads, source paths, HTML/Rich markup, schema references or executable validators are followed. JSON is bounded and fetched one slot on demand. No report/graph/history data is placed in browser localStorage. Copy JSON is a deliberate user clipboard action, not an automatic export.
Delete a job -> remove journal/report rows and its statistical contribution; leave only compact audit deletion metadata. No shadow archive. Protected backups, browser transient memory, SQLite free pages and storage remnants require independent operator policy; deletion is not certified erasure. Audit is local, not tamper-proof.
## Failures and qualification
Core verdict, native target stats, report availability and journal coverage remain distinct. Native exit0/result_validation is not success and must not auto-replay. Queue overflow or journal write loss does not independently prove task failure. An interrupted stream remains unknown where Core cannot establish a final result.
No native/systemd/proxy/browser-security qualification is claimed from source fixtures. See VERIFICATION.md and run CONTROLLER-PILOT.md with approved disposable scope. Maintain native Ansible2.19.11, Core's declared dependency range, validated TLS/global SSH trust and current operational-source review.
@@ -0,0 +1,57 @@
# Verification - AIM WebGUI 2.1.0rc9
Date: 2026-09-22. Target: independently managed AIM Core **3.3.0rc8**, public
service/wire/event **1.0**; WebGUI HTTP **v2**; SQLite **schema 5**.
## Scope
This candidate reconciles the add-on release surface with the supplied Core 3.3.0rc8
archive and packages the result as 2.1.0rc9. It does not claim native Windows/Linux or
production-systemd acceptance merely because local source/contract checks pass.
The supplied Core ZIP SHA-256 observed during this build is:
```text
76733be206b14c8a822126dfdd67ee3ad3667cdb88d3db925dd57859dd322ea0
```
No independent publisher sidecar was supplied, so this is an observed digest rather
than a publisher-authentication claim. ZIP extraction/integrity succeeded.
## Contract findings checked
- Core rc8 reports product version `3.3.0rc8` and service/wire/event API `1.0`.
- Live capabilities advertise `collection_baselines.ansible.windows` as
`>=3.8.0,<4.0.0`; WebGUI requires that declaration and does not manage collections.
- Existing public detail-progress, inventory-hierarchy, target-outcome, staging and
structured operation-result contracts remain the integration boundary.
- `patch_summary_v1` accepts rc8's additive optional `reboot_reasons_before` data while
historical report contracts remain independently validated.
- No WebGUI database migration, Core mutation, new privilege bridge or private Core
import is introduced by rc9.
## Automated evidence observed for rc9
- Python compilation of `src`, `tests` and `deploy`: **passed**.
- `tests/test_deployment_v2.py`: **10 passed**.
- `test_core_contract.py::test_real_core_capabilities_and_customers` against the supplied
rc8 tree: **1 passed**.
- Targeted rc8 patch checks completed before the bounded native-finalization fixture:
public catalog/options and new-report-schema/old-Core-gate checks **passed**.
- The full `test_core_rc8_patch.py` file and the release-wide bounded runner did not
complete inside this execution environment's command window. Their interrupted runs
are **not** counted as passes and no rc4/rc5 test totals are carried forward.
The final extracted rc9 ZIP is verified with the release's own `deploy.verify_release`
manifest checker before delivery.
## Not qualified here
This build does **not** establish production installation of
`ansible.windows >=3.8.0,<4.0.0`, native `win_reboot_info`, Windows Update, Checkmk ACL
normalization/script placement, real WinRM/SSH, reboot behavior, production proxy/CSP/
HTMX/EventSource traffic, or the actual systemd sandbox.
Core rc8's current SANITY/VALIDATION guidance remains the operator acceptance source.
Use a low-risk reporting operation first, then qualify Windows patching and changed
Checkmk workflows separately on approved disposable targets.
@@ -0,0 +1,64 @@
{
"release": "2.1.0rc9",
"core": "3.3.0rc8",
"core_service_api": "1.0",
"http_api": 2,
"database_schema": 5,
"core_archive_sha256": "76733be206b14c8a822126dfdd67ee3ad3667cdb88d3db925dd57859dd322ea0",
"automated_tests": {
"python_compilation": {
"status": "passed",
"paths": [
"src",
"tests",
"deploy"
]
},
"completed": [
{
"test": "tests/test_deployment_v2.py",
"passed": 10,
"failed": 0
},
{
"test": "tests/test_core_contract.py::test_real_core_capabilities_and_customers",
"passed": 1,
"failed": 0
},
{
"test": "tests/test_core_rc8_patch.py::test_rc8_public_catalog_options_not_hardcoded_defaults",
"status": "passed"
},
{
"test": "tests/test_core_rc8_patch.py::test_new_report_schema_and_old_core_gate",
"status": "passed"
}
],
"interrupted_not_counted": [
"full tests/test_core_rc8_patch.py run",
"release-wide tests/run_release_tests.py run",
"test_real_core_rc8_patch_finalization_through_fake_native in combined targeted run"
],
"native_managed_hosts_tested": false
},
"contract": {
"core_version": "3.3.0rc8",
"api_version": "1.0",
"collection_baseline_ansible_windows": ">=3.8.0,<4.0.0",
"detail_progress_schema": "play_task_host_v1",
"inventory_hierarchy_schema": "inventory_hierarchy_v1",
"target_outcome_schema": "target_outcome_summary_v1",
"staging_profile": "native_defaults_preflight_v2",
"operation_result_protocol": "aim_operation_result_v1"
},
"final_archive": {
"manifest_verified": true,
"archive_sha256": null
},
"limitations": [
"No native managed-host qualification",
"No real systemd installation or recovery",
"No production browser HTTPS/CSP/cookies/proxy/HTMX/SSE acceptance",
"Full release suite did not complete within the execution environment command window"
]
}
+40
View File
@@ -0,0 +1,40 @@
[build-system]
requires = ["setuptools==82.0.1"]
build-backend = "setuptools.build_meta"
[project]
name = "aim-webgui"
version = "2.1.0rc9"
description = "Independent Python operations WebGUI add-on for AIM 3.3.0rc8 service API 1.0"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"fastapi==0.128.2", "starlette==0.50.0", "uvicorn==0.48.0",
"Jinja2==3.1.6", "argon2-cffi==25.1.0", "ruamel.yaml==0.18.17"
]
[project.scripts]
aim-web = "aim_webgui.cli:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
aim_webgui = ["templates/**/*.html", "static/**/*", "db/migrations/*.sql"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
[tool.aim-web]
compatible-aim = ["3.3.0rc8"]
http-api = 2
database-schema = 5
core-service-api = "1.0"
core-event-api = "1.0"
# Native Ansible compatibility is owned/checked by core, not this package.
release-status = "candidate; controller qualification required"
# AIM is an externally managed runtime dependency, deliberately NOT pip-installed.
[project.optional-dependencies]
test = ["pytest>=8,<10", "httpx>=0.27,<1"]
@@ -0,0 +1,3 @@
# Test tooling only; do not install in the AIM environment.
pytest==9.0.2
httpx==0.28.1
@@ -0,0 +1,4 @@
__version__ = "2.1.0rc9"
COMPATIBLE_AIM = ("3.3.0rc8",)
SCHEMA_VERSION = 5
HTTP_API_VERSION = 2
@@ -0,0 +1,2 @@
from aim_webgui.cli import main
main()
@@ -0,0 +1,193 @@
"""Read-only projections of retained WebGUI history, never terminal/Core history.
Queries are scoped to the same owner/admin policy as Workflows.job. They stream
all matching retained rows rather than using the latest-100 Jobs overview. No
projection table, background collection, extra inventory database or secret access.
"""
from __future__ import annotations
from dataclasses import dataclass
import json
import time
from urllib.parse import urlencode, quote
from aim_webgui.core.protocol import _target_outcomes
from aim_webgui.db.store import Store
from aim_webgui.errors import WebError
from aim_webgui.workflows import actor, presentation_status, TERMINAL
OUTCOMES = ('successful', 'failed', 'unreachable', 'not_started', 'indeterminate', 'unavailable', 'outstanding')
LABELS = {'successful': 'Succeeded', 'failed': 'Failed', 'unreachable': 'Unreachable',
'not_started': 'Not started', 'indeterminate': 'Indeterminate',
'unavailable': 'Detail unavailable', 'outstanding': 'Not finished'}
def text(value: str, limit: int = 200) -> str:
if not isinstance(value, str) or len(value) > limit or any(ord(c) < 32 or ord(c) == 127 for c in value):
raise WebError('invalid_filter', 'Use bounded printable filter values.', 400)
return value
def host_url(customer: str, host: str, **filters) -> str:
return '/inventory/' + quote(customer, safe='') + '/activity?' + urlencode({'host': host, **filters})
def explorer_url(customer: str, **filters) -> str:
return '/inventory/' + quote(customer, safe='') + '/explore' + ('?' + urlencode(filters) if filters else '')
@dataclass(frozen=True)
class HistoryFilter:
customer: str = ''
host: str = ''
playbook: str = ''
mode: str = 'apply'
days: str = '30'
outcome: str = ''
q: str = ''
page: int = 1
def validate(self) -> 'HistoryFilter':
for val in (self.customer, self.host, self.playbook, self.q): text(val, 255)
if self.mode not in {'apply', 'check', 'all'} or self.days not in {'7', '30', '90', '365', 'all'}:
raise WebError('invalid_filter', 'Choose an available mode and retained-history time range.')
if self.outcome and self.outcome not in OUTCOMES:
raise WebError('invalid_filter', 'Choose a supported target outcome.')
if type(self.page) is not int or not 1 <= self.page <= 100000:
raise WebError('invalid_filter', 'Page is outside the supported range.')
return self
def query(self, **updates) -> dict:
values = dict(customer=self.customer, host=self.host, playbook=self.playbook, mode=self.mode,
days=self.days, outcome=self.outcome, q=self.q, page=self.page)
values.update(updates)
return {key: val for key, val in values.items() if val != ''}
def _dict(raw):
try:
result = json.loads(raw) if raw else {}
return result if isinstance(result, dict) else {}
except (ValueError, TypeError):
return {}
def _facts(raw, requested):
"""Use only Core-owned final facts; missing legacy records are not successes."""
value = _dict(raw)
try:
_, targets = _target_outcomes(value)
if {t['host'] for t in targets} != set(requested):
return value, {}
return value, {target['host']: target for target in targets}
except (WebError, TypeError, ValueError, KeyError):
return value, {}
def _counts():
return dict.fromkeys(OUTCOMES, 0)
def _metric(counts):
eligible = sum(counts[x] for x in ('successful', 'failed', 'unreachable'))
return {'counts': counts, 'samples': sum(counts.values()), 'eligible': eligible,
'success_percent': round(counts['successful'] * 100 / eligible, 1) if eligible else None}
class Activity:
def __init__(self, settings):
self.store = Store(settings.database)
def report(self, user_id: int, filters: HistoryFilter, *, now: int | None = None) -> dict:
"""Read one consistent DB snapshot. Output is paginated, aggregates are not.
Customer/time/mode/owner predicates run in SQL. Each job JSON is read once.
No persistent rollup means deleting a job immediately removes its samples.
Counters are target/run participations, never task count or group count.
"""
f = filters.validate()
now = int(time.time()) if now is None else now
since = None if f.days == 'all' else now - int(f.days) * 86400
totals = _counts(); rows = []; matched = 0; job_count = 0; changed_runs = 0
by_book = {}; latest = {}; distinct = set(); customers = set(); books = set()
coverage_start = None; coverage_end = None
start, end = (f.page - 1) * 25, f.page * 25
with self.store.read() as db:
db.execute('BEGIN')
who = actor(db, user_id)
# Never inspect another viewer's rows even while collecting filter options.
clauses = ['(? = \'admin\' OR j.owner_id = ?)']
args = [who['role'], user_id]
if f.mode != 'all': clauses.append('j.mode = ?'); args.append(f.mode)
if since is not None:
clauses.append('COALESCE(j.finished_at,j.created_at) >= ?'); args.append(since)
clauses.append('COALESCE(j.finished_at,j.created_at) <= ?'); args.append(now)
# json_valid guards historical malformed JSON without turning it into a query error.
if f.customer:
clauses.append("CASE WHEN json_valid(j.plan) THEN json_extract(j.plan,'$.customer') END = ?")
args.append(f.customer)
if f.playbook:
clauses.append("CASE WHEN json_valid(j.plan) THEN json_extract(j.plan,'$.playbook') END = ?")
args.append(f.playbook)
sql = '''SELECT j.id,j.owner_id,u.username,j.plan,j.core_result,j.mode,j.status,
j.created_at,j.finished_at,COALESCE(j.finished_at,j.created_at) AS recorded_at
FROM jobs j JOIN users u ON u.id=j.owner_id WHERE ''' + ' AND '.join(clauses) + ' ORDER BY recorded_at DESC,j.id DESC'
for row in db.execute(sql, args):
plan = _dict(row['plan']); customer = plan.get('customer'); book = plan.get('playbook')
targets = plan.get('targets')
if not isinstance(customer, str) or not isinstance(book, str) or not isinstance(targets, list):
continue
requested = list(dict.fromkeys(t for t in targets if isinstance(t, str)))
if f.host and f.host not in requested: continue
result, facts = _facts(row['core_result'], requested)
display = presentation_status(row['status'], result)
job_matched = False
for host in requested:
if f.host and f.host != host: continue
if f.q and f.q.casefold() not in host.casefold(): continue
fact = facts.get(host)
# The database lifecycle must be terminal before presenting terminal facts.
outcome = ('outstanding' if row['status'] not in TERMINAL else
fact['outcome'] if fact else 'unavailable')
if f.outcome and f.outcome != outcome: continue
counts = fact['counts'] if fact and outcome != 'outstanding' else None
sample = dict(job_id=row['id'], username=row['username'], customer=customer, playbook=book,
host=host, mode=row['mode'], outcome=outcome, counts=counts,
job_status=row['status'], job_display_status=display,
created_at=row['created_at'], recorded_at=row['recorded_at'],
finished_at=row['finished_at'], host_url=host_url(customer, host))
if start <= matched < end: rows.append(sample)
matched += 1; job_matched = True; totals[outcome] += 1
identity = (customer, host); distinct.add(identity); customers.add(customer); books.add(book)
stat = by_book.setdefault(book, {'playbook': book, 'counts': _counts(), 'latest': sample, 'jobs': 0, 'last_job': None})
stat['counts'][outcome] += 1
if stat['last_job'] != row['id']: stat['jobs'] += 1; stat['last_job'] = row['id']
latest.setdefault((customer, host, book), sample)
if counts and counts['changed'] > 0: changed_runs += 1
coverage_start = row['recorded_at'] if coverage_start is None else min(coverage_start, row['recorded_at'])
coverage_end = row['recorded_at'] if coverage_end is None else max(coverage_end, row['recorded_at'])
if job_matched: job_count += 1
# Saved plans retain their stricter owner-only visibility, including for admins.
plans = []
if f.customer and f.host:
for row in db.execute('SELECT id,name,customer,playbook,payload,created_at FROM plans WHERE owner_id=? AND customer=? ORDER BY created_at DESC,id', (user_id, f.customer)):
payload = _dict(row['payload'])
if f.host in payload.get('targets', []):
plans.append({k: row[k] for k in ('id', 'name', 'customer', 'playbook', 'created_at')})
book_stats = []
for val in by_book.values():
book_stats.append({'playbook': val['playbook'], 'jobs': val['jobs'], 'latest': val['latest'], **_metric(val['counts'])})
book_stats.sort(key=lambda v: v['playbook'].casefold())
# Bounded matrix cells; pages do not truncate the aggregated statistics.
host_keys = sorted(distinct, key=lambda v: (v[0].casefold(), v[1].casefold(), v))
matrix_hosts = host_keys[(f.page-1)*20:f.page*20]
columns = sorted(books, key=str.casefold)[:12]
matrix = [{'customer': c, 'host': h, 'url': host_url(c, h),
'cells': [latest.get((c, h, b)) for b in columns]} for c, h in matrix_hosts]
return dict(source='retained_webgui_jobs', filters=f, **_metric(totals), records=rows,
matched=matched, jobs=job_count, host_count=len(distinct), book_stats=book_stats,
changed_participations=changed_runs, page=f.page, pages=max(1, (matched+24)//25),
matrix=matrix, columns=columns, matrix_pages=max(1, (len(host_keys)+19)//20),
omitted_columns=max(0,len(books)-len(columns)), customers=sorted(customers),
playbooks=sorted(books), plans=plans, since=since, as_of=now,
coverage_start=coverage_start, coverage_end=coverage_end)
@@ -0,0 +1,160 @@
"""UI presentation adapter for the public AIM 3.3.0rc8 service/wire/event API only.
Form conversion is syntax/type marshalling; AIM prepare owns all target and option
validation. No private imports, file parsing, command building or key access.
"""
from __future__ import annotations
import json
import re
from types import SimpleNamespace
from aim_webgui.core.client import CoreClient
from aim_webgui.errors import WebError
from aim_webgui.core.reports import contract
class CoreAdapter:
def __init__(self,settings,client=None):
self.settings=settings;self.client=client or CoreClient(settings);self._caps=None
@property
def capabilities(self):
if self._caps is None:
caps=self.client.request('capabilities')
if (not isinstance(caps,dict) or caps.get('core_version')!='3.3.0rc8' or caps.get('api_version')!='1.0'
or caps.get('event_version')!='1.0' or not {'list_customers','list_hosts','inventory_hierarchy','list_playbooks','prepare','readiness','execute','staging_check'}<=set(caps.get('operations',[]))):
raise WebError('core_incompatible','This release requires AIM 3.3.0rc8 with documented service/wire/event API 1.0.',503)
progress=caps.get('execution_progress',{})
if ('detail' not in progress.get('modes',[]) or progress.get('request_field')!='progress_mode'
or progress.get('detail_schema')!='play_task_host_v1'):
raise WebError('core_incompatible','This release requires AIM 3.3.0rc8 detailed progress (play_task_host_v1).',503)
hierarchy=caps.get('inventory_hierarchy',{})
outcomes=caps.get('target_outcomes',{})
staging=caps.get('controller_staging',{})
if hierarchy.get('schema')!='inventory_hierarchy_v1' or not hierarchy.get('nested_groups'):
raise WebError('core_incompatible','This release requires AIM inventory_hierarchy_v1 discovery.',503)
if outcomes.get('schema')!='target_outcome_summary_v1' or outcomes.get('source')!='native_final_host_stats':
raise WebError('core_incompatible','This release requires AIM target_outcome_summary_v1 final host accounting.',503)
if staging.get('profile')!='native_defaults_preflight_v2':
raise WebError('core_incompatible','This release requires AIM native_defaults_preflight_v2 controller staging.',503)
reports=caps.get('operation_results',{})
if (reports.get('protocol')!='aim_output_v1' or reports.get('result_protocol')!='aim_operation_result_v1'
or not {'per_host','global'}<=set(reports.get('scopes',[])) or reports.get('raw_output')is not False):
raise WebError('core_incompatible','This release requires the Core aim_operation_result_v1 contract.',503)
baselines=caps.get('collection_baselines',{})
if baselines.get('ansible.windows')!='>=3.8.0,<4.0.0':
raise WebError('core_incompatible','This release requires Core to advertise ansible.windows >=3.8.0,<4.0.0.',503)
self._caps=caps
return self._caps
@property
def version(self):return self.capabilities['core_version']
def customers(self):
self.capabilities
return [{'name':x,'host_count':None,'status':'Available via core'} for x in self.client.request('list_customers')]
def customer_path(self,customer):
# Historical UI caller name; this validates an ID, never returns an OS path.
if customer not in {x['name'] for x in self.customers()}:
raise WebError('customer_unavailable','Customer unavailable.',404)
return customer
def inventory_hierarchy(self,customer):
self.capabilities
result=self.client.request('inventory_hierarchy',customer=customer)
if not isinstance(result,dict) or result.get('schema')!='inventory_hierarchy_v1' or result.get('customer')!=customer:
raise WebError('core_protocol','AIM returned an invalid inventory hierarchy response.',502)
return result
def hierarchy_groups(self,customer):
hierarchy=self.inventory_hierarchy(customer); groups=[]
def walk(nodes):
for node in nodes:
children=walk(node.get('children',[]))
direct=set(node.get('hosts',[])); recursive=set(direct)
for child in children:recursive.update(child['hosts'])
item={'name':'/'.join(node['path']),'label':node['name'],'path':list(node['path']),
'direct_hosts':sorted(direct,key=str.casefold),'hosts':sorted(recursive,key=str.casefold),'children':children}
groups.append(item)
return [g for g in groups if len(g['path']) and g['path'][-1] in {n.get('name') for n in nodes}]
# Build recursively without relying on a competing parser; paths/hosts are Core facts.
groups=[]
def build(node):
kids=[build(child) for child in node.get('children',[])]
recursive=set(node.get('hosts',[]))
for child in kids:recursive.update(child['hosts'])
return {'name':'/'.join(node['path']),'label':node['name'],'path':list(node['path']),
'direct_hosts':list(node.get('hosts',[])),'hosts':sorted(recursive,key=str.casefold),'children':kids}
roots=[build(node) for node in hierarchy.get('groups',[])]
def flatten(nodes):
out=[]
for node in nodes:out.append(node);out.extend(flatten(node['children']))
return out
return {'direct_hosts':list(hierarchy.get('hosts',[])),'roots':roots,'groups':flatten(roots)}
def hosts(self,customer):
self.capabilities
items=[{'name':h['name'],'address':h.get('address'),'groups':list(h.get('platforms',[])),
'platforms':list(h.get('platforms',[]))} for h in self.client.request('list_hosts',customer=customer)]
by_name={h['name']:h for h in items}
hierarchy=self.hierarchy_groups(customer)
for group in hierarchy['groups']:
label=group['name']
for host in group['hosts']:
if host in by_name and label not in by_name[host]['groups']:by_name[host]['groups'].append(label)
for host in items:host['groups'].sort(key=str.casefold)
return items
def playbooks(self,customer=None):
self.capabilities
if customer is not None:
return [dict(x,available=True) for x in self.client.request('list_playbooks',customer=customer)]
# The contract exposes a customer-scoped catalog only. Union available entries;
# do not manufacture unavailable entries from old private catalog files.
result={}
for item in self.customers():
for pb in self.playbooks(item['name']):result.setdefault(pb['key'],pb)
return list(result.values())
def spec(self,key,customer=None):
for p in self.playbooks(customer):
if p['key']==key:
p=dict(p);p['inputs']=tuple(SimpleNamespace(**x) for x in p.get('inputs',[]))
return SimpleNamespace(**p)
raise WebError('unknown_playbook','Playbook is not available for the selected customer.',404)
def typed_options(self,spec,values):
fields={x.name:x for x in spec.inputs};result={}
if not isinstance(values,dict) or set(values)-set(fields):raise WebError('invalid_options','Use declared catalog options only.')
for name,value in values.items():
if not isinstance(value,str):raise WebError('invalid_options','Form inputs must be text.')
if value=='':continue
kind=fields[name].type
try:
if kind=='bool':
if value.lower() not in {'true','false'}:raise ValueError()
result[name]=value.lower()=='true'
elif kind=='int':result[name]=int(value)
elif kind=='serial':result[name]=int(value) if value.strip().isdigit() else value.strip()
elif kind in {'list','sequence'}:
# JSON is the explicit interoperable wire type, not arbitrary YAML tags.
result[name]=json.loads(value) if value.strip().startswith('[') else [x.strip() for x in value.split(',')]
elif kind=='secret_ref':
if re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*',value.strip()):result[name]='{{ '+value.strip()+' }}'
else:result[name]=value # core validates the canonical reference, never a password
else:result[name]=value
except (ValueError,TypeError):raise WebError('invalid_options','An input could not be converted to its declared type.') from None
return result
def preflight(self,customer,playbook,targets,overrides,*,text_inputs=False,check=True,key_mode='none'):
self.capabilities
spec=self.spec(playbook,customer)
if text_inputs:overrides=self.typed_options(spec,overrides)
request={'customer':customer,'playbook':playbook,'hosts':targets,'overrides':overrides,
'check':check,'key_mode':key_mode,'become_password':False,'timeout_seconds':self.settings.execution_timeout_seconds,
'progress_mode':'detail'}
prepared=self.client.request('prepare',request=request)
req=prepared['request']; requirements=prepared['credential_requirements']
return {'customer':req['customer'],'playbook':req['playbook'],'targets':req['hosts'],
'overrides':req['overrides'],'target_limit':','.join(req['hosts']),
'required_collections':list(spec.requirements),'requires_vault_prompt':'vault_password' in requirements,
'requires_connection_password':'connection_password' in requirements,
'core_version':self.version,'core_api':'1.0','core_request':req,'core_revision':prepared['revision'],
'result_contract':contract(prepared.get('result_contract')),
'inventory_revision':prepared['revision'],'credential_requirements':requirements,
'credential_requirement_reasons':prepared.get('credential_requirement_reasons',{}),
'warnings':prepared.get('warnings',[]),'revision_coverage':prepared.get('revision_coverage',''),
'authentication':{'mode':'inventory','key_mode':req['key_mode']}}
def readiness(self,plan):return self.client.request('readiness',request=plan['core_request'])
def reprepare(self,plan):
req=plan.get('core_request')
if not isinstance(req,dict):raise WebError('legacy_plan','This plan predates the core API. Reuse its targets in New run and review again.',409)
return self.preflight(req['customer'],req['playbook'],req['hosts'],req['overrides'],check=req['check'],key_mode=req['key_mode'])
+406
View File
@@ -0,0 +1,406 @@
from __future__ import annotations
from dataclasses import asdict
from pathlib import Path
from urllib.parse import parse_qs, urlsplit, urlencode
import json
import logging
from datetime import datetime, timezone
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.concurrency import run_in_threadpool
from starlette.middleware.trustedhost import TrustedHostMiddleware
from aim_webgui import __version__, SCHEMA_VERSION
from aim_webgui.adapters.core_v1 import CoreAdapter
from aim_webgui.auth.service import Auth
from aim_webgui.workflows import Workflows
from aim_webgui.credentials.presentation import attention
from aim_webgui.config import Settings
from aim_webgui.errors import WebError
from aim_webgui.security import RequestPolicy, same_origin
ROOT = Path(__file__).parent
TEMPLATES = Jinja2Templates(directory=str(ROOT / 'templates'))
LOG = logging.getLogger('aim_webgui')
from aim_webgui.activity import host_url, explorer_url
from aim_webgui.read_views import install_read_views
from aim_webgui.evidence_views import install_evidence_views
from aim_webgui.reports import cell as report_cell, label as report_label
TEMPLATES.env.globals.update(host_url=host_url, explorer_url=explorer_url)
TEMPLATES.env.filters['report_cell']=report_cell
TEMPLATES.env.filters['report_label']=report_label
TEMPLATES.env.filters['utc'] = lambda value: datetime.fromtimestamp(value, timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC') if value is not None else '-'
TEMPLATES.env.filters['utc_iso'] = lambda value: datetime.fromtimestamp(value, timezone.utc).isoformat().replace('+00:00', 'Z') if value is not None else ''
def create_app(settings: Settings) -> RequestPolicy:
settings.validate()
auth = Auth(settings)
workflows = Workflows(settings)
auth.store.check()
# Login/local recovery remain available if the independent executor is offline.
# All core operations and execution still fail closed through CoreAdapter.
app = FastAPI(title='AIM WebGUI', version=__version__, docs_url=None, redoc_url=None, openapi_url=None)
app.state.auth, app.state.settings = auth, settings
app.add_middleware(TrustedHostMiddleware, allowed_hosts=list({urlsplit(settings.public_url).hostname,
'127.0.0.1', 'localhost', '[::1]'}))
app.mount('/static', StaticFiles(directory=ROOT / 'static', follow_symlink=False), name='static')
def render(request, template, *, status=200, **context):
session = getattr(request.state, 'session', None)
values = dict(request=request, session=session, version=__version__,
csrf=session['csrf'] if session else '', title='Controller overview',
nav='overview', error=None, execution_enabled=settings.execution_enabled, require_approval=settings.execution_require_approval)
values.update(context)
return TEMPLATES.TemplateResponse(request=request, name=template, context=values, status_code=status)
def error_response(request, exc: WebError):
if request.url.path.startswith('/api/'):
return JSONResponse({'error': {'code': exc.code, 'message': exc.message}}, status_code=exc.status)
template = 'partials/error.html' if request.headers.get('HX-Request') == 'true' else 'pages/error.html'
return render(request, template, status=exc.status, error=exc.message, title='Request could not be completed')
def redirect(request, path):
if request.headers.get('HX-Request') == 'true':
return HTMLResponse('', status_code=200, headers={'HX-Redirect': path})
return RedirectResponse(path, status_code=303)
def session_cookie(response, token):
response.set_cookie(settings.cookie_name, token, httponly=True, secure=settings.secure_cookie,
samesite='lax', path='/', max_age=settings.session_hours * 3600)
return response
async def form(request: Request) -> dict[str, list[str]]:
if request.headers.get('content-type', '').split(';')[0] != 'application/x-www-form-urlencoded':
raise WebError('unsupported_content_type', 'Use URL-encoded form data.', 415)
try:
return parse_qs((await request.body()).decode('utf-8'), keep_blank_values=True, max_num_fields=1500)
except (ValueError, UnicodeError):
raise WebError('invalid_form', 'Invalid form data.') from None
def one(values, key, default=''):
items = values.get(key, [default])
if len(items) != 1:
raise WebError('duplicate_field', 'Duplicate form field.')
return items[0]
def administrator(request):
session = request.state.session
if session['role'] != 'admin':
raise WebError('admin_required', 'Administrator access is required.', 403)
return session['user_id']
async def adapter_call(method, *args, **kwargs):
def call():
return getattr(CoreAdapter(settings), method)(*args, **kwargs)
return await run_in_threadpool(call)
@app.exception_handler(WebError)
async def web_error(request, exc):
return error_response(request, exc)
@app.exception_handler(RequestValidationError)
async def validation_error(request, exc):
return error_response(request, WebError('invalid_request', 'Request parameters are invalid.', 422))
@app.middleware('http')
async def gate(request, call_next):
path = request.url.path
try:
exempt = path in {'/healthz', '/readyz'} or path.startswith('/static/')
session = None if exempt else await run_in_threadpool(auth.session, request.cookies.get(settings.cookie_name))
request.state.session = session
if not exempt and path != '/login':
if not session or not session['user_id']:
if path.startswith('/api/'):
raise WebError('login_required', 'Sign in to access the API.', 401)
return redirect(request, '/login')
if session['must_change_password'] and path not in {'/password', '/logout', '/api/v2/session'}:
if path.startswith('/api/'):
raise WebError('password_change_required', 'Change the initial password before continuing.', 403)
return redirect(request, '/password')
if request.method not in {'GET', 'HEAD', 'OPTIONS'}:
fetch_site = request.headers.get('sec-fetch-site', '').strip().lower()
if fetch_site == 'cross-site':
raise WebError('origin_rejected', 'Cross-site request rejected.', 403)
# Origin is authoritative when the browser supplies it. Some browser/privacy
# combinations omit Origin for same-origin form POSTs, so accept a verified
# same-origin Referer as the fallback. If both are absent, only modern browser
# requests explicitly marked same-origin by Fetch Metadata may continue. CSRF
# token validation remains mandatory in all cases below.
origin = request.headers.get('origin', '').strip()
referer = request.headers.get('referer', '').strip()
if origin:
if origin == 'null' or not same_origin(origin, settings.public_url):
raise WebError('origin_rejected', 'Request Origin header does not match WebGUI public_url.', 403)
elif referer:
if not same_origin(referer, settings.public_url):
raise WebError('origin_rejected', 'Request Referer header does not match WebGUI public_url.', 403)
elif fetch_site != 'same-origin':
raise WebError('origin_rejected', 'Request is missing same-origin browser metadata.', 403)
token = request.headers.get('X-CSRF-Token', '')
if not token and request.headers.get('content-type', '').startswith('application/x-www-form-urlencoded'):
token = one(await form(request), '_csrf')
auth.csrf(session, token)
return await call_next(request)
except WebError as exc:
return error_response(request, exc)
except Exception as exc:
# Do not log exception text/tracebacks containing inventories or form values.
LOG.error('Request failed (%s); inspect configuration and permissions locally.', type(exc).__name__)
return error_response(request, WebError('internal_error', 'The request failed. Check the service logs and local configuration.', 500))
install_read_views(app, settings, render)
install_evidence_views(app, settings, auth, render)
@app.get('/healthz')
async def health():
return {'status': 'ok'}
@app.get('/readyz')
async def ready():
try:
await run_in_threadpool(auth.store.check)
await run_in_threadpool(lambda: CoreAdapter(settings).customers())
return {'status': 'ready'}
except Exception:
return JSONResponse({'status': 'not_ready'}, status_code=503)
@app.get('/login')
async def login_page(request: Request):
if request.state.session and request.state.session['user_id']:
return redirect(request, '/password' if request.state.session['must_change_password'] else '/')
peer = request.client.host if request.client else 'unknown'
if not request.state.session:
await run_in_threadpool(auth.throttle, [('login-page:' + peer, 120)], window=60)
token, session = await run_in_threadpool(auth.new_session)
request.state.session = session
return session_cookie(render(request, 'pages/login.html', title='Sign in'), token)
return render(request, 'pages/login.html', title='Sign in')
@app.post('/login')
async def login_submit(request: Request):
values = await form(request)
try:
token, session = await run_in_threadpool(auth.login, one(values, 'username'), one(values, 'password'),
request.cookies.get(settings.cookie_name, ''),
request.client.host if request.client else 'unknown')
except WebError as exc:
return render(request, 'pages/login.html', title='Sign in', error=exc.message, status=exc.status)
return session_cookie(redirect(request, '/password' if session['must_change_password'] else '/'), token)
@app.get('/password')
async def password_page(request: Request):
return render(request, 'pages/password.html', title='Change password', nav='account')
@app.post('/password')
async def password_submit(request: Request):
values = await form(request)
if one(values, 'password') != one(values, 'confirm'):
return render(request, 'pages/password.html', title='Change password', error='New passwords do not match.', status=400)
try:
await run_in_threadpool(auth.change_password, request.state.session['user_id'],
one(values, 'current_password'), one(values, 'password'))
except WebError as exc:
return render(request, 'pages/password.html', title='Change password', error=exc.message, status=exc.status)
response = redirect(request, '/login') # All sessions revoked; authenticate using the new password.
response.delete_cookie(settings.cookie_name, path='/', secure=settings.secure_cookie, httponly=True, samesite='lax')
return response
@app.post('/logout')
async def logout(request: Request):
await run_in_threadpool(auth.logout, request.cookies.get(settings.cookie_name, ''))
response = redirect(request, '/login')
response.delete_cookie(settings.cookie_name, path='/', secure=settings.secure_cookie, httponly=True, samesite='lax')
return response
@app.get('/')
async def overview(request: Request):
customers = await adapter_call('customers')
playbooks = await adapter_call('playbooks')
return render(request, 'pages/overview.html', customers=customers, playbooks=playbooks,
host_count=None, core=await run_in_threadpool(lambda: CoreAdapter(settings).capabilities),
recent_jobs=(await run_in_threadpool(workflows.jobs,request.state.session['user_id']))[:5],
attention=await run_in_threadpool(attention,workflows,request.state.session['user_id']))
@app.get('/customers')
async def customers_page(request: Request):
return render(request, 'pages/customers.html', title='Customers', nav='customers', customers=await adapter_call('customers'))
async def host_view(customer, q, group, sort, page):
all_hosts = await adapter_call('hosts', customer)
groups = sorted({g for h in all_hosts for g in h['groups']}, key=str.casefold)
q = q[:200]
filtered = [h for h in all_hosts if q.casefold() in (h['name'] + ' ' + (h['address'] or '') + ' ' + ' '.join(h['groups'])).casefold()
and (not group or group in h['groups'])]
if sort not in {'name','address','group'}:
sort = 'name'
filtered.sort(key=lambda h: (' '.join(h['groups']) if sort == 'group' else (h[sort] or '')).casefold())
pages = max(1, (len(filtered)+99)//100)
page = max(1, min(page, pages))
def link(number):
return '/customers/' + customer + '/hosts?' + urlencode({'q':q,'group':group,'sort':sort,'page':number})
return dict(customer=customer, hosts=filtered[(page-1)*100:page*100], q=q, group=group, sort=sort,
groups=groups, matched=len(filtered), total=len(all_hosts), page=page, pages=pages,
previous_url=link(page-1) if page>1 else None, next_url=link(page+1) if page<pages else None)
@app.get('/customers/{customer}/hosts')
async def hosts_page(request: Request, customer: str, q: str = '', group: str = '', sort: str = 'name', page: int = 1):
return render(request, 'pages/hosts.html', title=customer, nav='customers',
**await host_view(customer, q, group, sort, page))
@app.get('/_partials/hosts')
async def host_partial(request: Request, customer: str, q: str = '', group: str = '', sort: str = 'name', page: int = 1):
return render(request, 'partials/hosts.html', **await host_view(customer, q, group, sort, page))
@app.get('/playbooks')
async def playbooks_page(request: Request, customer: str | None = None):
return render(request, 'pages/playbooks.html', title='Playbook catalog', nav='playbooks',
playbooks=await adapter_call('playbooks', customer), customer=customer)
@app.get('/plan')
async def plan(request: Request, customer: str = '', playbook: str = '', saved: str = ''):
preset = None
option_values = {}
if saved:
preset = (await run_in_threadpool(workflows.plan, request.state.session['user_id'], saved))['payload']
customer, playbook = preset['customer'], preset['playbook']
for key, value in preset['overrides'].items():
if isinstance(value, bool):
option_values[key] = 'true' if value else 'false'
elif isinstance(value, (list, dict)):
option_values[key] = json.dumps(value)
elif isinstance(value, str) and value.startswith('{{ ') and value.endswith(' }}'):
option_values[key] = value[3:-3]
else:
option_values[key] = str(value)
customers = await adapter_call('customers')
books = await adapter_call('playbooks', customer or None)
spec = next((b for b in books if b['key'] == playbook), None)
hosts = await adapter_call('hosts', customer) if customer else []
if spec:
hosts = [h for h in hosts if set(h['platforms']).intersection(spec['platforms'])]
group_counts = {}
for host in hosts:
for group in host['groups']:
group_counts[group] = group_counts.get(group, 0) + 1
groups = [{'name': name, 'count': group_counts[name], 'depth': name.count('/')}
for name in sorted(group_counts, key=lambda value: ([part.casefold() for part in value.split('/')], value.count('/')))]
selected = (preset['targets'] if preset else
await run_in_threadpool(workflows.selection, request.state.session['user_id'], customer, playbook)
if customer and playbook else [])
return render(request, 'pages/plan.html', title='New run', nav='plan', customers=customers,
playbooks=books, customer=customer, playbook=playbook, spec=spec, hosts=hosts, groups=groups,
selected=selected, option_values=option_values, reused=bool(preset),
run_mode='check' if not preset or preset.get('core_request',{}).get('check',True) else 'apply',
key_mode=preset.get('core_request',{}).get('key_mode','none') if preset else 'none')
async def form_preflight(request):
values = await form(request)
permitted = {'_csrf', 'customer', 'playbook', 'targets', 'mode', 'key_mode'}
if any(not key.startswith('option.') and key not in permitted for key in values):
raise WebError('invalid_field', 'Unknown preflight field.')
options = {key[7:]: one(values, key) for key in values if key.startswith('option.')}
mode=one(values,'mode','check')
if mode not in {'check','apply'}:raise WebError('invalid_mode','Choose check or apply.')
result = await run_in_threadpool(workflows.review, request.state.session['user_id'],
one(values,'customer'),one(values,'playbook'),values.get('targets',[]),options,
text_inputs=True,check=mode=='check',key_mode=one(values,'key_mode','none'))
return result
@app.post('/preflight')
async def preflight_page(request: Request):
return render(request, 'pages/preflight.html', title='Selection review', nav='plan', result=await form_preflight(request))
@app.post('/_partials/preflight')
async def preflight_partial(request: Request):
return render(request, 'partials/preflight.html', result=await form_preflight(request))
@app.get('/users')
async def users_page(request: Request):
administrator(request)
users = await run_in_threadpool(auth.users)
grants = await run_in_threadpool(workflows.grants, request.state.session['user_id'])
customers = await adapter_call('customers')
playbooks = await adapter_call('playbooks')
selected_user_id = users[0]['id'] if users else None
selected_customer = customers[0]['name'] if customers else ''
granted = {g['playbook'] for g in grants
if g['user_id'] == selected_user_id and g['customer'] == selected_customer}
available_playbooks = [p for p in playbooks if p['key'] not in granted]
return render(request, 'pages/users.html', title='User administration', nav='users', users=users, grants=grants,
customers=customers, playbooks=playbooks, selected_user_id=selected_user_id,
selected_customer=selected_customer, available_playbooks=available_playbooks)
@app.post('/users')
async def create_user(request: Request):
actor = administrator(request)
values = await form(request)
if one(values, 'password') != one(values, 'confirm'):
raise WebError('password_mismatch', 'Temporary passwords do not match.')
await run_in_threadpool(auth.create_user, one(values, 'username'), one(values, 'password'),
one(values, 'role', 'viewer'), actor_id=actor)
return redirect(request, '/users')
@app.post('/users/{name}')
async def manage_user(request: Request, name: str):
actor = administrator(request)
values = await form(request)
action = one(values, 'action')
if action == 'reset-password' and one(values, 'password') != one(values, 'confirm'):
raise WebError('password_mismatch', 'Temporary passwords do not match.')
await run_in_threadpool(auth.manage_user, name, action, password=one(values, 'password') or None, actor_id=actor)
return redirect(request, '/users')
@app.get('/api/v2/session')
async def api_session(request: Request):
s = request.state.session
return {'username': s['username'], 'role': s['role'], 'csrf_token': s['csrf'],
'must_change_password': bool(s['must_change_password'])}
@app.get('/api/v2/capabilities')
async def capabilities():
return {'addon_version': __version__, 'aim_version': '3.3.0rc8', 'core_api': '1.0', 'database_schema': SCHEMA_VERSION,
'inventory_read': True, 'catalog_read': True, 'selection_preflight': True,
'inventory_write': False, 'execution': settings.execution_enabled, 'credential_collection': settings.credentials_enabled, 'credential_release': 'candidate', 'credential_ansible_core': '2.19.11',
'saved_plans': True, 'one_run_without_saving':True,'custom_credential_override':False,'structured_progress':True,'persistent_progress':True,'operation_reports':True,'report_protocol':'aim_operation_result_v1','approval_queue': True, 'one_shot_scheduling': True, 'raw_output': False}
@app.get('/api/v2/customers')
async def api_customers():
return {'items': await adapter_call('customers')}
@app.get('/api/v2/customers/{customer}/hosts')
async def api_hosts(customer: str):
return {'items': await adapter_call('hosts', customer)}
@app.get('/api/v2/playbooks')
async def api_books(customer: str | None = None):
return {'items': await adapter_call('playbooks', customer)}
@app.post('/api/v2/preflight')
async def api_preflight(request: Request):
if request.headers.get('content-type', '').split(';')[0] != 'application/json':
raise WebError('unsupported_content_type', 'Use application/json.', 415)
try:
payload = await request.json()
except (ValueError, UnicodeError):
raise WebError('invalid_json', 'Invalid JSON.') from None
if (not isinstance(payload, dict) or set(payload) - {'customer', 'playbook', 'targets', 'overrides', 'check', 'key_mode'}
or not all(isinstance(payload.get(k), str) for k in ('customer', 'playbook'))):
raise WebError('invalid_request', 'Expected customer, playbook, targets and optional overrides.')
result = await run_in_threadpool(workflows.review,request.state.session['user_id'],payload['customer'],payload['playbook'],
payload.get('targets'),payload.get('overrides',{}),check=payload.get('check',True),key_mode=payload.get('key_mode','none'))
return result
from aim_webgui.routes.workflows import install as install_workflows
install_workflows(app, settings, auth, render, form, one, redirect, administrator)
# Wrap outside FastAPI's server-error middleware so security headers also cover errors.
return RequestPolicy(app, secure=settings.secure_cookie)
@@ -0,0 +1,19 @@
from pathlib import Path
import base64
import hashlib
HASHES = {
'bootstrap.min.css': 'sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB',
'htmx.min.js': 'H5SrcfygHmAuTDZphMHqBJLc3FhssKjG7w/CeCpFReSfwBWDTKpkzPP8c+cLsK+V',
}
def check_assets():
root = Path(__file__).parent / 'static/vendor'
for name, expected in HASHES.items():
path = root / name
if not path.is_file():
raise ValueError('Browser assets are missing. Run deploy/fetch_assets.py in the release, then reinstall the add-on.')
actual = base64.b64encode(hashlib.sha384(path.read_bytes()).digest()).decode('ascii')
if actual != expected:
raise ValueError(f'Browser asset integrity check failed: {name}. Reinstall the approved release.')
@@ -0,0 +1,277 @@
from __future__ import annotations
import fcntl
import hashlib
import hmac
import json
import os
import re
import secrets
import stat
import time
from datetime import datetime, timezone
from argon2 import PasswordHasher, Type
from argon2.exceptions import VerificationError, InvalidHashError
from aim_webgui.config import Settings
from aim_webgui.db.store import Store, audit, ensure_private_dir, private_file
from aim_webgui.errors import WebError
HASHER = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=1, type=Type.ID)
DUMMY_HASH = HASHER.hash(secrets.token_urlsafe(32))
USER_RE = re.compile(r'^[a-z][a-z0-9._-]{2,63}$')
def username(value: str) -> str:
normalized = value.strip().lower()
if not USER_RE.fullmatch(normalized):
raise WebError('invalid_username', 'Use 3-64 lowercase letters, numbers, dots, underscores or hyphens; start with a letter.')
return normalized
def password_policy(value: str) -> None:
if not isinstance(value, str) or not 15 <= len(value) <= 128 or '\x00' in value:
raise WebError('password_policy', 'Use a password or passphrase of 15-128 characters without NUL characters.')
def verify(encoded: str, supplied: str) -> bool:
if not isinstance(supplied, str) or len(supplied) > 128:
return False
try:
return HASHER.verify(encoded, supplied)
except (VerificationError, InvalidHashError):
return False
def digest(token: str) -> str:
return hashlib.sha256(token.encode('utf-8')).hexdigest()
class Auth:
def __init__(self, settings: Settings):
self.settings = settings
self.store = Store(settings.database)
def bootstrap(self) -> bool:
"""Explicit and restartable first initialization, never triggered by HTTP.
A pre-commit crash leaves the same protected credential file for retry.
A missing database after a completed install requires deliberate recovery.
"""
ensure_private_dir(self.settings.state_dir)
lock_fd = os.open(self.settings.state_dir / '.init.lock',
os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX)
marker = self.settings.state_dir / '.initialized'
if marker.exists() and not self.settings.database.exists():
raise ValueError('An initialized database is missing. Restore a backup; admin was not recreated.')
self.store.migrate(create=True)
with self.store.transaction() as db:
if db.execute("SELECT 1 FROM metadata WHERE key='initialized'").fetchone():
# Repair a marker missing after a post-commit crash, without resetting admin.
if not marker.exists():
private_file(marker, 'Initialized; restore database if it is missing.\n')
# Upgrade/reinstall does NOT regenerate credentials, even if deleted.
return False
if db.execute('SELECT COUNT(*) FROM users').fetchone()[0]:
raise ValueError('Accounts exist in an uninitialized database; refusing automatic bootstrap.')
credentials = self.settings.credentials
if credentials.exists() or credentials.is_symlink():
mode = credentials.lstat()
if (not stat.S_ISREG(mode.st_mode) or mode.st_uid != os.geteuid()
or stat.S_IMODE(mode.st_mode) & 0o077):
raise ValueError('Bootstrap credential file must be a service-owned regular 0600 file.')
record = json.loads(credentials.read_text(encoding='utf-8'))
if record.get('username') != 'admin' or record.get('purpose') != 'aim-web-first-install':
raise ValueError('Unrecognized existing bootstrap credential file.')
secret = record['password']
password_policy(secret)
else:
secret = secrets.token_urlsafe(24)
record = {'purpose': 'aim-web-first-install', 'username': 'admin', 'password': secret,
'created_at': datetime.now(timezone.utc).isoformat(),
'notice': 'Change at first login. This file is never served over HTTP.'}
private_file(credentials, json.dumps(record, indent=2) + '\n')
now = int(time.time())
db.execute('INSERT INTO users(username,password_hash,role,created_at,updated_at) VALUES(?,?,?,?,?)',
('admin', HASHER.hash(secret), 'admin', now, now))
db.execute("INSERT INTO metadata(key,value) VALUES('initialized',?)", (str(now),))
audit(db, 'local-installer', 'bootstrap', 'admin')
if not marker.exists():
private_file(marker, 'Initialized; restore database if it is missing.\n')
return True
finally:
os.close(lock_fd)
def new_session(self, user_id: int | None = None) -> tuple[str, dict]:
token, csrf, now = secrets.token_urlsafe(32), secrets.token_urlsafe(32), int(time.time())
ttl = self.settings.session_hours * 3600 if user_id else 600
with self.store.transaction() as db:
db.execute('DELETE FROM sessions WHERE expires_at < ? OR last_seen_at < ?',
(now, now - self.settings.idle_minutes * 60))
# Bound anonymous session accumulation (plus proxy-level request limits).
if db.execute('SELECT COUNT(*) FROM sessions').fetchone()[0] >= 10000:
raise WebError('session_capacity', 'Session capacity reached. Try later.', 503)
db.execute('INSERT INTO sessions VALUES(?,?,?,?,?,?)',
(digest(token), user_id, csrf, now, now + ttl, now))
return token, self.session(token)
def session(self, token: str | None) -> dict | None:
if not token or len(token) > 100:
return None
now = int(time.time())
with self.store.transaction() as db:
row = db.execute('''SELECT s.*, u.username, u.role, u.enabled, u.must_change_password
FROM sessions s LEFT JOIN users u ON u.id=s.user_id WHERE s.token_hash=?''',
(digest(token),)).fetchone()
if not row:
return None
idle = self.settings.idle_minutes * 60 if row['user_id'] else 600
if (row['expires_at'] <= now or row['last_seen_at'] + idle <= now
or (row['user_id'] is not None and not row['enabled'])):
db.execute('DELETE FROM sessions WHERE token_hash=?', (digest(token),))
return None
if now - row['last_seen_at'] >= 60:
db.execute('UPDATE sessions SET last_seen_at=? WHERE token_hash=?', (now, digest(token)))
return dict(row)
@staticmethod
def csrf(session: dict | None, supplied: str) -> None:
if not session or not supplied or len(supplied) > 100 or not hmac.compare_digest(session['csrf'], supplied):
raise WebError('csrf_failed', 'Security token expired or missing. Reload the page and retry.', 403)
def throttle(self, bucket_values: list[tuple[str, int]], *, window: int = 300) -> None:
now = int(time.time())
with self.store.transaction() as db:
# A short-window bucket must never erase a longer-window login bucket.
db.execute('DELETE FROM rate_limits WHERE started < ?', (now - 3600,))
reservations = []
for value, limit in bucket_values:
bucket = digest(value)
row = db.execute('SELECT started,attempts FROM rate_limits WHERE bucket=?', (bucket,)).fetchone()
started, count = (row['started'], row['attempts']) if row and now - row['started'] < window else (now, 0)
if count >= limit:
raise WebError('login_throttled', 'Too many authentication attempts. Try again in five minutes.', 429)
reservations.append((bucket, started, count + 1))
for reservation in reservations:
db.execute("""INSERT INTO rate_limits VALUES(?,?,?)
ON CONFLICT(bucket) DO UPDATE SET started=excluded.started,attempts=excluded.attempts""", reservation)
def login(self, name: str, password: str, old_token: str, peer: str) -> tuple[str, dict]:
canonical = name.strip().lower()[:64]
self.throttle([('user:' + canonical, 10), ('peer:' + peer, 60), ('login-global', 200)])
with self.store.read() as db:
user = db.execute('SELECT * FROM users WHERE username=?', (canonical,)).fetchone()
encoded = user['password_hash'] if user else DUMMY_HASH
correct = verify(encoded, password)
if not user or not user['enabled'] or not correct:
with self.store.transaction() as db:
audit(db, canonical if USER_RE.fullmatch(canonical) else 'invalid-identifier', 'login-failed', 'authentication')
raise WebError('invalid_login', 'Invalid username or password.', 401)
rehash = HASHER.hash(password) if HASHER.check_needs_rehash(encoded) else encoded
with self.store.transaction() as db:
# Recheck after expensive hash, so disable/reset races cannot authenticate stale credentials.
current = db.execute('SELECT * FROM users WHERE id=?', (user['id'],)).fetchone()
if not current['enabled'] or current['password_hash'] != encoded:
raise WebError('invalid_login', 'Invalid username or password.', 401)
db.execute('UPDATE users SET password_hash=?,last_login_at=? WHERE id=?',
(rehash, int(time.time()), user['id']))
db.execute('DELETE FROM sessions WHERE token_hash=?', (digest(old_token),))
audit(db, canonical, 'login', canonical)
return self.new_session(user['id'])
def logout(self, token: str) -> None:
with self.store.transaction() as db:
user = db.execute('SELECT users.username FROM sessions JOIN users ON users.id=sessions.user_id WHERE token_hash=?', (digest(token),)).fetchone()
db.execute('DELETE FROM sessions WHERE token_hash=?', (digest(token),))
if user:
audit(db, user['username'], 'logout', user['username'])
def change_password(self, user_id: int, old: str, new: str) -> None:
password_policy(new)
self.throttle([('change:' + str(user_id), 10)])
with self.store.read() as db:
user = db.execute('SELECT * FROM users WHERE id=?', (user_id,)).fetchone()
if not user or not user['enabled'] or not verify(user['password_hash'], old):
raise WebError('invalid_password', 'Current password was not accepted.', 403)
if verify(user['password_hash'], new):
raise WebError('password_unchanged', 'Choose a password different from the current password.')
encoded = HASHER.hash(new)
with self.store.transaction() as db:
current = db.execute('SELECT * FROM users WHERE id=?', (user_id,)).fetchone()
if not current['enabled'] or current['password_hash'] != user['password_hash']:
raise WebError('account_changed', 'Account changed; sign in again.', 409)
db.execute('UPDATE users SET password_hash=?,must_change_password=0,updated_at=? WHERE id=?',
(encoded, int(time.time()), user_id))
db.execute('DELETE FROM sessions WHERE user_id=?', (user_id,))
audit(db, user['username'], 'password-changed', user['username'])
if user['username'] == 'admin':
self.consume_bootstrap()
def consume_bootstrap(self) -> None:
# Password is already invalidated. Never rename a file still containing it.
self.settings.credentials.unlink(missing_ok=True)
used = self.settings.state_dir / '.credentials.used'
if not used.exists():
private_file(used, 'Bootstrap credentials invalidated at ' + datetime.now(timezone.utc).isoformat() + '\n')
def users(self) -> list[dict]:
with self.store.read() as db:
return [dict(r) for r in db.execute('''SELECT id,username,role,enabled,must_change_password,
created_at,last_login_at FROM users ORDER BY username''')]
@staticmethod
def require_admin(db, actor_id: int | None) -> str:
if actor_id is None: # Local CLI: enforced by private state ownership, never exposed as an HTTP argument.
return 'local-console'
actor = db.execute('SELECT * FROM users WHERE id=?', (actor_id,)).fetchone()
if not actor or not actor['enabled'] or actor['role'] != 'admin' or actor['must_change_password']:
raise WebError('admin_required', 'Administrator access is required.', 403)
return actor['username']
def create_user(self, name: str, password: str, role: str = 'viewer', *, actor_id: int | None = None) -> None:
name = username(name)
password_policy(password)
if role not in {'admin', 'viewer'}:
raise WebError('invalid_role', 'Unknown role.')
encoded, now = HASHER.hash(password), int(time.time())
with self.store.transaction() as db:
actor = self.require_admin(db, actor_id)
if db.execute('SELECT 1 FROM users WHERE username=?', (name,)).fetchone():
raise WebError('user_exists', 'That username already exists.', 409)
db.execute('INSERT INTO users(username,password_hash,role,created_at,updated_at) VALUES(?,?,?,?,?)',
(name, encoded, role, now, now))
audit(db, actor, 'user-created', name)
def manage_user(self, name: str, action: str, *, password: str | None = None,
actor_id: int | None = None) -> None:
name = username(name)
allowed = {'enable', 'disable', 'promote', 'demote', 'revoke', 'reset-password'}
if action not in allowed:
raise WebError('invalid_action', 'Unknown user action.')
encoded = None
if action == 'reset-password':
password_policy(password)
encoded = HASHER.hash(password)
with self.store.transaction() as db:
actor = self.require_admin(db, actor_id)
user = db.execute('SELECT * FROM users WHERE username=?', (name,)).fetchone()
if not user:
raise WebError('user_not_found', 'Account not found.', 404)
if action in {'disable', 'demote'} and user['enabled'] and user['role'] == 'admin':
count = db.execute("SELECT COUNT(*) FROM users WHERE enabled=1 AND role='admin'").fetchone()[0]
if count <= 1:
raise WebError('last_admin', 'The last enabled administrator cannot be disabled or demoted.', 409)
if action in {'enable', 'disable'}:
db.execute('UPDATE users SET enabled=? WHERE id=?', (int(action == 'enable'), user['id']))
elif action in {'promote', 'demote'}:
db.execute('UPDATE users SET role=? WHERE id=?', ('admin' if action == 'promote' else 'viewer', user['id']))
elif action == 'reset-password':
db.execute('UPDATE users SET password_hash=?,must_change_password=1 WHERE id=?', (encoded, user['id']))
db.execute('UPDATE users SET updated_at=? WHERE id=?', (int(time.time()), user['id']))
db.execute('DELETE FROM sessions WHERE user_id=?', (user['id'],))
audit(db, actor, 'user-' + action, name)
if name == 'admin' and action == 'reset-password':
self.consume_bootstrap()
+199
View File
@@ -0,0 +1,199 @@
from __future__ import annotations
import argparse
import getpass
import logging
import json
import subprocess
import os
import pwd
import stat
import tempfile
from pathlib import Path
import sys
from aim_webgui import __version__
from aim_webgui.config import DEFAULT_CONFIG, Settings
def read_password() -> str:
from aim_webgui.auth.service import password_policy
if not sys.stdin.isatty():
raise ValueError('Use an interactive terminal. Passwords are not accepted as command-line arguments.')
first = getpass.getpass('New password: ')
if first != getpass.getpass('Confirm password: '):
raise ValueError('Passwords do not match.')
password_policy(first)
return first
def main() -> None:
os.umask(0o077)
sys.dont_write_bytecode = True
parser = argparse.ArgumentParser(prog='aim-web', description='Independent WebGUI add-on; never updates AIM.')
parser.add_argument('--version', action='version', version=f'AIM WebGUI {__version__} (AIM compatibility: 3.3.0rc8 / service API 1.0)')
parser.add_argument('--config', type=Path, default=DEFAULT_CONFIG)
commands = parser.add_subparsers(dest='command', required=True)
commands.add_parser('serve', help='Serve on the configured interface; reverse-proxy settings are explicit.')
commands.add_parser('credential-check', help='Check core contract/credential fields; no passwords or remote contacts.')
commands.add_parser('executor', help='Pre-started local executor; run only as the configured execution identity.')
core_check=commands.add_parser('core-check', help='Core discovery, and optional prepared-run readiness, under the executor identity.')
core_check.add_argument('--customer')
core_check.add_argument('--playbook',default='debug_test_connection')
core_check.add_argument('--host',action='append',dest='hosts')
core_check.add_argument('--key-mode',choices=['none','customer'],default='none')
commands.add_parser('core-staging-check', help='Run Core 3.2 controller staging preflight under the executor identity.')
commands.add_parser('config-check', help='Validate TOML only; safe as root, no database access.')
status = commands.add_parser('status', help='Read systemd state and configured listener without opening SQLite.')
status.add_argument('--json', action='store_true')
doctor = commands.add_parser('doctor', help='Read-only diagnostics; run under the aim-web service identity.')
doctor.add_argument('--json', action='store_true')
worker = commands.add_parser('worker', help='Run the opt-in single-job worker, separate from HTTP.')
worker.add_argument('--once', action='store_true')
commands.add_parser('init', help='Explicit idempotent first initialization; never reset existing admin.')
check = commands.add_parser('check', help='Check core integration and optional database readiness.')
check.add_argument('--without-db', action='store_true')
check.add_argument('--without-core', action='store_true',help='Package/assets/state check only; deployment staging use.')
db = commands.add_parser('db').add_subparsers(dest='db_command', required=True)
db.add_parser('migrate', help='Apply forward migrations; stop service and back up first.')
backup = db.add_parser('backup', help='Use SQLite backup API, not a raw live copy.')
backup.add_argument('destination', type=Path)
users = commands.add_parser('user').add_subparsers(dest='user_command', required=True)
users.add_parser('list')
create = users.add_parser('create')
create.add_argument('username')
create.add_argument('--role', choices=['viewer', 'admin'], default='viewer')
for action in ('enable', 'disable', 'promote', 'demote', 'revoke', 'reset-password'):
users.add_parser(action).add_argument('username')
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format='%(levelname)s %(name)s: %(message)s')
try:
settings = Settings.load(args.config)
if args.command == 'executor':
from aim_webgui.core.executor import Executor
Executor(settings).run()
return
if args.command == 'core-staging-check':
# Core validates its controller-local staging path. Ansible's local
# connection plugin independently expands ~<executor>/.ansible/tmp
# for delegated localhost tasks, so validate that path from inside
# the same systemd sandbox before accepting executor readiness.
account = pwd.getpwuid(os.geteuid())
local_base = Path(account.pw_dir) / '.ansible'
local_tmp = local_base / 'tmp'
for path in (local_base, local_tmp):
st = path.lstat()
if stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode):
raise ValueError(f'Executor delegated-local staging path is unsafe: {path}')
if st.st_uid != account.pw_uid or st.st_gid != account.pw_gid or stat.S_IMODE(st.st_mode) != 0o700:
raise ValueError(f'Executor delegated-local staging ownership/mode is unsafe: {path}')
fd, probe = tempfile.mkstemp(prefix='.aim-web-probe-', dir=local_tmp)
os.close(fd)
Path(probe).unlink()
from dataclasses import replace
from aim_webgui.core.client import CoreClient
result = CoreClient(replace(settings, core_transport='stdio')).request('staging_check')
result['delegated_local_tmp'] = {'path': str(local_tmp), 'writable': True, 'private': True}
print(json.dumps(result, indent=2))
return
if args.command in {'credential-check','core-check'}:
from aim_webgui.adapters.core_v1 import CoreAdapter
adapter=CoreAdapter(settings)
result={'core':adapter.capabilities,'customers':adapter.customers()}
if args.command=='core-check' and (args.customer or args.hosts):
if not args.customer or not args.hosts:raise ValueError('Provide both --customer and at least one --host.')
plan=adapter.preflight(args.customer,args.playbook,args.hosts,{},check=True,key_mode=args.key_mode)
result['readiness']=adapter.readiness(plan)
print(json.dumps(result,indent=2))
print('No passwords read or hosts contacted. Readiness is local, not a live execution qualification.')
return
if args.command == 'config-check':
print(f'Configuration valid: {args.config}; public origin: {settings.public_url}; execution={settings.execution_enabled}')
return
if args.command == 'status':
result = {'version': __version__, 'config': str(args.config),
'listener': f'{settings.host}:{settings.port}', 'public_url': settings.public_url,
'execution_enabled': settings.execution_enabled}
for unit in ('aim-web.service', 'aim-web-worker.service','aim-web-executor.service'):
try:
proc = subprocess.run(['systemctl', 'show', unit, '-p', 'ActiveState', '-p', 'SubState', '-p', 'MainPID'],
capture_output=True, text=True, timeout=5, check=False)
result[unit] = dict(line.split('=', 1) for line in proc.stdout.splitlines() if '=' in line)
except (OSError, subprocess.TimeoutExpired):
result[unit] = {'status': 'systemd unavailable'}
print(json.dumps(result, indent=2) if args.json else '\n'.join(f'{k}: {v}' for k,v in result.items()))
return
if args.command == 'doctor':
from aim_webgui.diagnostics import report
result = report(settings, args.config)
if args.json:
print(json.dumps(result, indent=2))
else:
print(f'AIM WebGUI {__version__}; {settings.public_url}')
for check in result['checks']:
print(f"{'PASS' if check['ok'] else 'CHECK'} {check['name']}: {check['detail']}")
print(result['transport_note'])
if os.geteuid() == 0:
print('Run doctor as the service identity: sudo -u aim-web aim-web doctor')
if not result['ok']:
raise SystemExit(1)
return
if args.command == 'worker':
from aim_webgui.worker import Worker
Worker(settings, args.config).run(once=args.once)
return
from aim_webgui.auth.service import Auth
auth = Auth(settings)
if args.command == 'init':
created = auth.bootstrap()
print('Initialized administrator: admin' if created else 'Already initialized; users and passwords unchanged.')
if created:
print(f'Bootstrap credentials (local file only): {settings.credentials.as_uri()}')
print('A password change is required at first login. The password is not printed here.')
elif args.command == 'check':
from aim_webgui.adapters.core_v1 import CoreAdapter
adapter = None if args.without_core else CoreAdapter(settings)
from aim_webgui.assets import check_assets
check_assets()
if not args.without_db:
auth.store.check()
print(f'AIM WebGUI {__version__}: ' + ('package/state checked; core not contacted' if args.without_core else f'AIM {adapter.version} / service v1 compatible; core unchanged.'))
elif args.command == 'db':
if args.db_command == 'migrate':
auth.store.migrate()
print('Database schema is current. Accounts preserved.')
else:
auth.store.backup(args.destination.resolve())
print(f'Database backed up: {args.destination}')
elif args.command == 'user':
# Local database owner can repair an out-of-band disabled admin; HTTP still fails closed.
auth.store.check(require_admin=False)
if args.user_command == 'list':
for user in auth.users():
print(f"{user['username']}\t{user['role']}\tenabled={bool(user['enabled'])}\tchange_password={bool(user['must_change_password'])}")
elif args.user_command == 'create':
auth.create_user(args.username, read_password(), args.role)
print('Account created; password change required at first login.')
else:
new = read_password() if args.user_command == 'reset-password' else None
auth.manage_user(args.username, args.user_command, password=new)
print('Account updated; active sessions revoked.')
else:
from aim_webgui.app import create_app
import uvicorn
from aim_webgui.assets import check_assets
check_assets()
app = create_app(settings)
print(f'AIM WebGUI {__version__}; public origin: {settings.public_url}')
uvicorn.run(app, host=settings.host, port=settings.port, workers=1,
proxy_headers=settings.proxy_headers,
forwarded_allow_ips=settings.forwarded_allow_ips_value,
server_header=False, access_log=False,
timeout_keep_alive=5, limit_concurrency=32, ws='none')
except KeyboardInterrupt:
raise SystemExit(130) from None
except Exception as exc:
from aim_webgui.errors import WebError
message = exc.message if isinstance(exc, WebError) else (str(exc) if isinstance(exc, ValueError) else type(exc).__name__)
print(f'AIM WebGUI: {message}', file=sys.stderr)
raise SystemExit(1) from None
@@ -0,0 +1,221 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import ipaddress
import tomllib
from urllib.parse import urlsplit
DEFAULT_CONFIG = Path('/etc/ansible/scripts/config/webgui.toml')
@dataclass(frozen=True)
class Settings:
aim_scripts: Path = Path('/etc/ansible/scripts')
state_dir: Path = Path('/var/lib/aim/webgui')
host: str = '127.0.0.1'
port: int = 8080
public_url: str = 'http://127.0.0.1:8080'
proxy_headers: bool = False
forwarded_allow_ips: tuple[str, ...] = ()
session_hours: int = 8
idle_minutes: int = 30
credentials_enabled: bool = False
execution_enabled: bool = False
core_transport: str = 'unix'
core_command: tuple[str, ...] = ('/usr/local/bin/aimctl',)
core_config: Path = Path('/etc/ansible/scripts/aim.yml')
core_socket: Path = Path('/run/aim-web-executor/core.sock')
core_executor_user: str = 'svc_bf-ansible'
core_client_user: str = 'aim-web'
core_home: Path | None = None
execution_playbooks: tuple[str, ...] = ()
execution_max_hosts: int = 25
execution_timeout_seconds: int = 900
execution_require_approval: bool = True
execution_transport_verified: bool = False
execution_window_start_hour: int = 0
execution_window_end_hour: int = 24
journal_max_events: int = 20_000
journal_max_bytes: int = 8 * 1024 * 1024
reports_max_bytes: int = 16 * 1024 * 1024
reports_retain_configuration: bool = False
@property
def database(self) -> Path:
return self.state_dir / 'webgui.sqlite3'
@property
def credentials(self) -> Path:
return self.state_dir / '.credentials'
@property
def secure_cookie(self) -> bool:
return urlsplit(self.public_url).scheme == 'https'
@property
def cookie_name(self) -> str:
return '__Host-aim_web' if self.secure_cookie else 'aim_web_local'
@property
def forwarded_allow_ips_value(self) -> str:
return ','.join(self.forwarded_allow_ips)
def validate(self) -> 'Settings':
if not self.aim_scripts.is_absolute() or not self.state_dir.is_absolute():
raise ValueError('aim_scripts and state_dir must be absolute paths.')
if self.state_dir.resolve().is_relative_to(self.aim_scripts.resolve()):
raise ValueError('Mutable state must be outside the AIM source directory.')
if not isinstance(self.host, str) or not self.host.strip():
raise ValueError('host must be a non-empty listen address.')
if type(self.port) is not int or not 1024 <= self.port <= 65535:
raise ValueError('port must be an unprivileged TCP port (1024..65535).')
p = urlsplit(self.public_url)
if (p.scheme not in {'http', 'https'} or not p.hostname or p.username or p.password
or p.path or p.query or p.fragment):
raise ValueError('public_url must be an HTTP(S) origin without a trailing slash or path.')
_ = p.port
if p.scheme == 'http':
try:
local = ipaddress.ip_address(p.hostname).is_loopback
except ValueError:
local = p.hostname == 'localhost'
if not local:
raise ValueError('Non-local public_url requires HTTPS.')
try:
bind_loopback = ipaddress.ip_address(self.host).is_loopback
except ValueError:
bind_loopback = self.host == 'localhost'
if not bind_loopback:
if p.scheme != 'https':
raise ValueError('A non-loopback listener requires an HTTPS public_url behind a reverse proxy.')
if not self.proxy_headers:
raise ValueError('A non-loopback listener requires proxy_headers=true.')
if not self.forwarded_allow_ips:
raise ValueError('A non-loopback listener requires at least one trusted proxy IP.')
if type(self.proxy_headers) is not bool:
raise ValueError('proxy_headers must be true or false.')
if not isinstance(self.forwarded_allow_ips, tuple):
raise ValueError('forwarded_allow_ips must be a list of IP addresses or CIDR networks.')
for value in self.forwarded_allow_ips:
if value == '*':
continue
try:
ipaddress.ip_network(value, strict=False)
except ValueError:
raise ValueError(f'Invalid trusted proxy IP/network: {value}') from None
if not self.proxy_headers and self.forwarded_allow_ips:
raise ValueError('forwarded_allow_ips requires proxy_headers=true.')
if not (type(self.session_hours) is int and 1 <= self.session_hours <= 24):
raise ValueError('session_hours must be 1..24.')
if not (type(self.idle_minutes) is int and 1 <= self.idle_minutes <= 60):
raise ValueError('idle_minutes must be 1..60.')
if any(type(getattr(self, name)) is not bool for name in
('execution_enabled', 'execution_require_approval', 'execution_transport_verified')):
raise ValueError('Execution switches must be TOML booleans.')
if not isinstance(self.execution_playbooks, tuple) or any(
not isinstance(x, str) or not x or not all(c.isalnum() or c in '_-' for c in x)
for x in self.execution_playbooks):
raise ValueError('execution_playbooks must contain explicit catalog keys.')
if self.core_transport not in {'stdio', 'unix'}:
raise ValueError('core.transport must be stdio or unix.')
if (not isinstance(self.core_command, tuple) or not self.core_command
or not all(isinstance(x, str) and x and not any(ord(c)<32 for c in x) for x in self.core_command)
or not Path(self.core_command[0]).is_absolute()):
raise ValueError('core.command must be an explicit command array with an absolute executable.')
if not self.core_config.is_absolute() or not self.core_socket.is_absolute() or len(str(self.core_socket).encode())>100:
raise ValueError('Use absolute core config/socket paths; socket path must be at most 100 bytes.')
if self.core_home is not None and (not self.core_home.is_absolute() or self.core_home.resolve().is_relative_to(self.state_dir.resolve())):
raise ValueError('core.home must be an absolute separate executor home, never WebGUI private state.')
for name in (self.core_executor_user,self.core_client_user):
if not name or not all(c.isalnum() or c in '_-' for c in name):
raise ValueError('Use explicit local executor and client account names.')
if type(self.execution_max_hosts) is not int or not 1 <= self.execution_max_hosts <= 500:
raise ValueError('execution_max_hosts must be 1..500.')
if type(self.execution_timeout_seconds) is not int or not 30 <= self.execution_timeout_seconds <= 7200:
raise ValueError('execution_timeout_seconds must be 30..7200.')
if (type(self.execution_window_start_hour) is not int or type(self.execution_window_end_hour) is not int
or not 0 <= self.execution_window_start_hour < self.execution_window_end_hour <= 24):
raise ValueError('Execution start window must satisfy 0 <= start_hour < end_hour <= 24 (UTC).')
if self.execution_enabled and (not self.execution_playbooks or not self.secure_cookie
or not self.execution_transport_verified):
raise ValueError('Execution requires HTTPS, an explicit playbook allowlist, and transport_verified=true after operator verification of backend TLS.')
if type(self.credentials_enabled) is not bool:
raise ValueError('credentials_enabled must be a TOML boolean.')
if self.credentials_enabled and (not self.execution_enabled or not self.execution_transport_verified or not self.secure_cookie):
raise ValueError('Credential execution requires enabled execution and verified HTTPS transport.')
if self.execution_enabled and '*' in self.forwarded_allow_ips:
raise ValueError('Execution does not accept wildcard proxy trust.')
if type(self.journal_max_events) is not int or not 100 <= self.journal_max_events <= 200_000:
raise ValueError('journal.max_events must be 100..200000.')
if type(self.journal_max_bytes) is not int or not 65_536 <= self.journal_max_bytes <= 64 * 1024 * 1024:
raise ValueError('journal.max_bytes must be 65536..67108864.')
if type(self.reports_max_bytes) is not int or not 65_536 <= self.reports_max_bytes <= 16 * 1024 * 1024:
raise ValueError('reports.max_bytes must be 65536..16777216.')
if type(self.reports_retain_configuration) is not bool:
raise ValueError('reports.retain_configuration must be a TOML boolean.')
return self
@classmethod
def load(cls, path: Path = DEFAULT_CONFIG) -> 'Settings':
if not path.is_file():
raise ValueError(f'Configuration missing: {path}. Run deployment first.')
with path.open('rb') as stream:
raw = tomllib.load(stream)
# 0.1.0 used a flat TOML document. 0.1.1+ accepts it unchanged while
# introducing named sections for future add-on growth.
section_names = {'server', 'proxy', 'session', 'aim', 'state', 'execution', 'credentials', 'core', 'journal', 'reports'}
if section_names.intersection(raw):
unknown_sections = set(raw) - section_names
if unknown_sections:
raise ValueError('Unknown WebGUI configuration sections: ' + ', '.join(sorted(unknown_sections)))
data = {}
mapping = {
'journal': {'max_events','max_bytes'},
'reports': {'max_bytes','retain_configuration'},
'credentials': {'enabled'},
'core': {'transport','command','config','socket','executor_user','client_user','home'},
'server': {'host', 'port', 'public_url'},
'proxy': {'proxy_headers', 'forwarded_allow_ips'},
'session': {'session_hours', 'idle_minutes'},
'aim': {'scripts_path'},
'state': {'state_dir'},
'execution': {'enabled', 'playbooks', 'max_hosts', 'timeout_seconds',
'require_approval', 'transport_verified', 'window_start_hour', 'window_end_hour'},
}
for section, allowed in mapping.items():
values = raw.get(section, {})
if not isinstance(values, dict):
raise ValueError(f'[{section}] must be a TOML table.')
unknown = set(values) - allowed
if unknown:
raise ValueError(f'Unknown keys in [{section}]: ' + ', '.join(sorted(unknown)))
for key, value in values.items():
data[section + '_' + key if section in {'execution', 'credentials', 'core', 'journal', 'reports'} else ('aim_scripts' if key == 'scripts_path' else key)] = value
else:
data = dict(raw)
unknown = set(data) - cls.__dataclass_fields__.keys()
if unknown:
raise ValueError('Unknown WebGUI configuration keys: ' + ', '.join(sorted(unknown)))
for name in ('aim_scripts', 'state_dir', 'core_config', 'core_socket', 'core_home'):
if name in data:
if not isinstance(data[name], str):
raise ValueError(f'{name} must be a string path.')
data[name] = Path(data[name])
if 'forwarded_allow_ips' in data:
values = data['forwarded_allow_ips']
if not isinstance(values, list) or not all(isinstance(v, str) for v in values):
raise ValueError('forwarded_allow_ips must be an array of strings.')
data['forwarded_allow_ips'] = tuple(values)
if 'core_command' in data:
if not isinstance(data['core_command'], list):
raise ValueError('core.command must be a TOML array.')
data['core_command'] = tuple(data['core_command'])
if 'execution_playbooks' in data:
if not isinstance(data['execution_playbooks'], list):
raise ValueError('Execution playbooks must be an array.')
data['execution_playbooks'] = tuple(data['execution_playbooks'])
return cls(**data).validate()
@@ -0,0 +1,175 @@
"""Ephemeral, same-host live console transport.
Console text is sanitized in the execution child, kept only in a bounded memory
buffer, and relayed through an owner-only Unix socket. Nothing here writes
playbook output to SQLite, audit, logs, or regular files.
"""
from __future__ import annotations
from collections import deque
import json
import os
from pathlib import Path
import re
import socket
import struct
import threading
from urllib.parse import quote
ANSI = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))")
CONTROL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
SENSITIVE_ASSIGNMENT = re.compile(
r"(?i)(\b(?:password|passwd|passphrase|token|secret|api[_-]?key|private[_-]?key|vault_password)\b\s*[:=]\s*)"
r"(?:\"[^\"]*\"|'[^']*'|[^\s,}\]]+)"
)
MAX_LINE = 4096
MAX_LINES = 500
MAX_BYTES = 256 * 1024
def console_socket(settings, ident: str) -> Path:
# Keep Unix-domain paths comfortably below the platform limit, including
# long test/state roots. The peer credential check and owner-only socket
# permissions remain the authorization boundary.
return settings.state_dir / f'.console-{ident[:12]}.sock'
class Redactor:
def __init__(self, secrets=()):
self.secrets = []
self.add(secrets)
def add(self, secrets):
variants = set(self.secrets)
for value in secrets:
if not isinstance(value, str) or not value:
continue
variants.add(value)
variants.add(quote(value, safe=''))
try:
variants.add(json.dumps(value, ensure_ascii=False)[1:-1])
except (TypeError, ValueError):
pass
self.secrets = sorted((v for v in variants if v), key=len, reverse=True)
def clean(self, value: str) -> str:
text = ANSI.sub('', str(value)).replace('\r', '').rstrip('\n')
text = CONTROL.sub('', text)
for secret in self.secrets:
text = text.replace(secret, '*** REDACTED ***')
text = SENSITIVE_ASSIGNMENT.sub(r'\1*** REDACTED ***', text)
if len(text) > MAX_LINE:
text = text[:MAX_LINE] + ' …[truncated]'
return text
def classify_failure(lines) -> str:
text = '\n'.join(lines).lower()
if any(marker in text for marker in (
'unreachable!', 'failed to connect to the host via ssh', 'permission denied (publickey',
'connection timed out', 'connection refused', 'winrm', 'ntlm', 'kerberos unreachable',
)):
return 'Remote connection or authentication failed. Review the live console and target connectivity.'
if any(marker in text for marker in (
"couldn't resolve module/action", 'syntax error', 'the error appears to be in',
'[error]:', 'unexpected exception', 'non-empty plugin name is required',
)):
return 'Ansible configuration or playbook loading failed. Review the live console and controller prerequisites.'
if any(marker in text for marker in ('failed!', 'fatal:', 'failed=', 'rescue')):
return 'A playbook task failed. Review the live console; completed remote changes were not rolled back.'
return 'AIM/Ansible execution failed. Review the live console while the run is active or reproduce it in AIM terminal.'
class ConsoleServer:
"""One-job console server; snapshot and live data exist only in memory."""
def __init__(self, path: Path, secrets=()):
self.path = path
self.redactor = Redactor(secrets)
self.buffer = deque()
self.buffer_bytes = 0
self.clients = set()
self.lock = threading.Lock()
self.stop = threading.Event()
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(path.parent, 0o700)
path.unlink(missing_ok=True)
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.sock.bind(str(path))
os.chmod(path, 0o600)
self.sock.listen(8)
self.sock.settimeout(.5)
self.thread = threading.Thread(target=self._serve, name='aim-live-console', daemon=True)
self.thread.start()
def _serve(self):
while not self.stop.is_set():
try:
conn, _ = self.sock.accept()
except socket.timeout:
continue
except OSError:
break
try:
if hasattr(socket, 'SO_PEERCRED'):
_pid, uid, _gid = struct.unpack('3i', conn.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))
if uid != os.getuid():
conn.close(); continue
conn.settimeout(2)
with self.lock:
snapshot = list(self.buffer)
for payload in snapshot:
conn.sendall(payload)
self.clients.add(conn)
except OSError:
with self.lock:
self.clients.discard(conn)
try: conn.close()
except OSError: pass
def _publish(self, obj):
payload = (json.dumps(obj, ensure_ascii=False, separators=(',', ':')) + '\n').encode('utf-8')
with self.lock:
self.buffer.append(payload)
self.buffer_bytes += len(payload)
while len(self.buffer) > MAX_LINES or self.buffer_bytes > MAX_BYTES:
self.buffer_bytes -= len(self.buffer.popleft())
clients = list(self.clients)
dead = []
for conn in clients:
try:
conn.sendall(payload)
except OSError:
dead.append(conn)
if dead:
with self.lock:
for conn in dead:
self.clients.discard(conn)
try: conn.close()
except OSError: pass
def add_secrets(self, secrets):
self.redactor.add(secrets)
def line(self, text: str):
clean = self.redactor.clean(text)
if clean:
self._publish({'type': 'line', 'text': clean})
def notice(self, text: str):
self._publish({'type': 'notice', 'text': self.redactor.clean(text)})
def close(self):
try:
self._publish({'type': 'end', 'text': 'Execution ended. Live console output is not retained.'})
except Exception:
pass
self.stop.set()
try: self.sock.close()
except OSError: pass
self.thread.join(timeout=2)
with self.lock:
clients = list(self.clients); self.clients.clear()
for conn in clients:
try: conn.close()
except OSError: pass
self.path.unlink(missing_ok=True)
@@ -0,0 +1 @@
"""AIM service/wire v1 client. No AIM or Ansible private imports belong here."""
@@ -0,0 +1,66 @@
"""Transport selection for AIM's documented API, never private manager access."""
from __future__ import annotations
import os
import pwd
import socket
import stat
import struct
import threading
import time
from aim_webgui.errors import WebError
from aim_webgui.credentials import wire
from .protocol import request_message, response_result, safe_event, validate_secrets, TRANSPORT_ERRORS
from .process import invoke
from .reports import UNSPECIFIED
from .limits import PUBLIC_FRAME_BYTES
from .jsonio import loads
class CoreClient:
def __init__(self,settings): self.settings=settings
def request(self,operation,*,credentials=None,event_sink=None,cancel=None,deadline=None,result_contract=UNSPECIFIED,**fields):
message=request_message(operation,**fields)
def emit(frame):
if event_sink: event_sink(safe_event(frame['event']))
if self.settings.core_transport=='stdio':
frame=invoke(self.settings,message,credentials=credentials,emit=emit,cancel=cancel,deadline=deadline)
return response_result(frame,operation,declaration=result_contract,request=fields.get('request'),secrets=tuple((credentials or {}).values()))
path=self.settings.core_socket
try:
st=path.lstat();expected=pwd.getpwnam(self.settings.core_executor_user).pw_uid
if not stat.S_ISSOCK(st.st_mode) or st.st_uid!=expected or stat.S_IMODE(st.st_mode)&0o007:
raise WebError('executor_identity','The local executor socket has an unexpected owner or mode.',503)
with socket.socket(socket.AF_UNIX) as sock:
sock.settimeout(5);sock.connect(str(path))
pid,uid,gid=struct.unpack('3i',sock.getsockopt(socket.SOL_SOCKET,socket.SO_PEERCRED,12))
if uid!=expected: raise WebError('executor_identity','Unexpected local executor identity.',503)
# Secret data is an explicitly separate bounded frame, never part of core request JSON.
has_credentials=operation=='execute'
remaining=min(60.0,max(0.0,deadline-time.monotonic())) if deadline is not None else None
wire.send(sock,{'request':message,'credential_frame':has_credentials,'start_seconds':remaining},131200)
if has_credentials: wire.send(sock,{'credentials':validate_secrets(credentials or {})},wire.SECRET_LIMIT+512)
interrupted=threading.Event()
def watcher():
while not interrupted.wait(.1):
if cancel is not None and cancel.is_set():
try: sock.shutdown(socket.SHUT_RDWR)
except OSError: pass
return
watcher_thread=threading.Thread(target=watcher,daemon=True);watcher_thread.start()
timeout=(fields.get('request',{}).get('timeout_seconds',3600)+200 if operation=='execute' else 135)
sock.settimeout(timeout)
try:
while True:
frame=wire.receive(sock,PUBLIC_FRAME_BYTES,decoder=loads)
if frame.get('type')=='event': emit(frame)
elif frame.get('type')=='response': return response_result(frame,operation,declaration=result_contract,request=fields.get('request'),secrets=tuple((credentials or {}).values()))
elif frame.get('type')=='transport_error':
code=frame.get('code')
messages=TRANSPORT_ERRORS
raise WebError(code if code in messages else 'executor_error',messages.get(code,'The local executor could not complete the core request; no raw diagnostic was exposed.'),503)
else: raise WebError('core_protocol','Invalid local executor response.',502)
finally:
interrupted.set();watcher_thread.join(timeout=1)
except WebError: raise
except (OSError,ValueError,KeyError):
if cancel is not None and cancel.is_set(): raise WebError('core_cancelled','Cancellation requested; completed remote work is not rolled back.',409) from None
raise WebError('executor_unavailable','The local AIM executor is unavailable. Check aim-web-executor.service and the configured core endpoint.',503) from None
@@ -0,0 +1,119 @@
"""Add-on-owned pre-started executor. Core itself has no broker/daemon.
systemd starts this process under the already authorized, key-owning identity.
No sudo, setuid, key export, private API imports or raw output forwarding occurs.
The local web UID is a trusted controller client, NOT a multi-tenant boundary.
"""
from __future__ import annotations
import os
import pwd
import resource
import select
import signal
import socket
import stat
import struct
import threading
import time
from aim_webgui.credentials import wire
from aim_webgui.errors import WebError
from .process import invoke
from .limits import PUBLIC_FRAME_BYTES
from .protocol import validate_request, validate_secrets, TRANSPORT_ERRORS
class Executor:
def __init__(self,settings):
self.settings=settings;self.stopping=threading.Event()
self.slots=threading.BoundedSemaphore(8);self.run_slot=threading.Lock();self.threads=[]
def handle(self,conn):
acquired=False;running=False
try:
conn.settimeout(5)
_,uid,_=struct.unpack('3i',conn.getsockopt(socket.SOL_SOCKET,socket.SO_PEERCRED,12))
if uid not in {pwd.getpwnam(self.settings.core_client_user).pw_uid,0}: return
acquired=self.slots.acquire(blocking=False)
if not acquired: raise WebError('executor_busy','Concurrent executor limit.',503)
envelope=wire.receive(conn,131200)
if set(envelope)!={'request','credential_frame','start_seconds'} or type(envelope['credential_frame']) is not bool:
raise WebError('core_request','Invalid executor envelope.')
message=validate_request(envelope['request']); execute=message['operation']=='execute'
if envelope['credential_frame']!=execute: raise WebError('core_request','Invalid credential framing.')
supplied={}
if execute:
running=self.run_slot.acquire(blocking=False)
if not running: raise WebError('executor_busy','Another core run is active.',409)
frame=wire.receive(conn,wire.SECRET_LIMIT+512)
if set(frame)!={'credentials'}: raise WebError('invalid_credentials','Invalid credential frame.')
supplied=validate_secrets(frame['credentials']);frame.clear()
seconds=envelope['start_seconds']
if seconds is not None and (type(seconds) not in (int,float) or not 0 < seconds <= 60):
raise WebError('credential_expired','Credential start window expired.',409)
deadline=time.monotonic()+seconds if seconds is not None else None
def connected():
if self.stopping.is_set(): return False
ready,_,_=select.select([conn],[],[],0)
if ready:
# No more input is allowed; EOF means cancellation/disconnect.
return False
return True
def emit(frame): wire.send(conn,frame,PUBLIC_FRAME_BYTES)
final=invoke(self.settings,message,credentials=supplied,emit=emit,cancel=self.stopping,connected=connected,deadline=deadline)
wire.send(conn,final,PUBLIC_FRAME_BYTES)
except (WebError,OSError,ValueError,KeyError,TypeError) as exc:
# Deliberately do not log repr/body/traceback; submitted values may be secrets.
try: wire.send(conn,{'type':'transport_error','code':exc.code if isinstance(exc,WebError) and exc.code in TRANSPORT_ERRORS else 'executor_error'})
except (OSError,ValueError): pass
finally:
if 'supplied' in locals(): supplied.clear()
if running: self.run_slot.release()
if acquired: self.slots.release()
conn.close()
def run(self):
settings=self.settings
if os.geteuid()!=pwd.getpwnam(settings.core_executor_user).pw_uid or os.geteuid()==0:
raise ValueError('Executor must run as the configured non-root execution/key identity.')
resource.setrlimit(resource.RLIMIT_CORE,(0,0));os.umask(0o077)
if settings.core_home is not None:
home=settings.core_home.lstat()
if not stat.S_ISDIR(home.st_mode) or home.st_uid!=os.geteuid() or stat.S_IMODE(home.st_mode)&0o077:
raise ValueError('The configured executor home must be an executor-owned private 0700 directory.')
parent=settings.core_socket.parent
client_gid=pwd.getpwnam(settings.core_client_user).pw_gid
# systemd owns the runtime-directory contract. Keep the executor's normal
# primary group and permit pathname traversal only; authorization is enforced
# on the socket itself (executor:aim-web 0660). This avoids runtime chgrp and
# keeps the HTTP/worker identity unable to list the executor runtime directory.
st=parent.lstat()
if (not stat.S_ISDIR(st.st_mode) or st.st_uid!=os.geteuid()
or stat.S_IMODE(st.st_mode)!=0o711):
raise ValueError('Executor socket directory must be executor-owned 0711 and non-symlinked.')
# Verify the actual wire/API before accepting traffic; this needs no Ansible run.
from .protocol import response_result
result=response_result(invoke(settings,{'api_version':'1.0','operation':'capabilities'}),'capabilities')
if result.get('api_version')!='1.0' or result.get('core_version')!='3.3.0rc8':
raise ValueError('This executor release is qualified for AIM 3.3.0rc8 / service API 1.0 only.')
path=settings.core_socket
if path.exists():
item=path.lstat()
if not stat.S_ISSOCK(item.st_mode) or item.st_uid!=os.geteuid(): raise ValueError('Unexpected existing executor endpoint.')
# Refuse a second live executor; remove only a stale same-owner socket.
with socket.socket(socket.AF_UNIX) as probe:
try: probe.connect(str(path))
except ConnectionRefusedError: path.unlink()
else: raise ValueError('Another executor is already listening.')
listener=socket.socket(socket.AF_UNIX)
previous={}
try:
listener.bind(str(path));os.chown(path,-1,client_gid);os.chmod(path,0o660);listener.listen(8);listener.settimeout(.5)
for sig in (signal.SIGINT,signal.SIGTERM):
previous[sig]=signal.signal(sig,lambda *_:self.stopping.set())
while not self.stopping.is_set():
try: conn,_=listener.accept()
except socket.timeout: continue
t=threading.Thread(target=self.handle,args=(conn,),daemon=True);self.threads.append(t);t.start()
self.threads=[t for t in self.threads if t.is_alive()]
finally:
self.stopping.set();listener.close()
for t in self.threads:t.join(timeout=15)
path.unlink(missing_ok=True)
for sig,handler in previous.items():signal.signal(sig,handler)
@@ -0,0 +1,26 @@
"""Strict public JSON decoding; rejects ambiguous keys, NaN and excessive depth."""
import json
def loads(raw, max_depth=48):
# Scan brackets outside strings before allocating a recursive JSON tree.
text=raw.decode('utf-8') if isinstance(raw,(bytes,bytearray)) else raw
depth=0;quoted=False;escaped=False
for char in text:
if quoted:
if escaped:escaped=False
elif char=='\\':escaped=True
elif char=='"':quoted=False
elif char=='"':quoted=True
elif char in '[{':
depth+=1
if depth>max_depth:raise ValueError('Public JSON nesting exceeds its bound.')
elif char in ']}':depth-=1
def pairs(items):
result={}
for key,val in items:
if key in result:raise ValueError('Duplicate public JSON member.')
result[key]=val
return result
def constant(_):raise ValueError('Non-finite public JSON number.')
return json.loads(text,object_pairs_hook=pairs,parse_constant=constant)
@@ -0,0 +1,11 @@
"""Public response budgets, deliberately separate from request/credential limits.
Core's 16 MiB report uses UTF-8 sizing. JSON escaping can expand it, and the final
result appears twice on the wire. Default 128 MiB line / 512 MiB stream leaves
bounded room for escaping, target/schema overhead and progress. IPC is canonical
UTF-8 and gets a 32 MiB final frame. These limits never apply to credential input.
"""
PUBLIC_LINE_BYTES = 128 * 1024 * 1024
PUBLIC_STREAM_BYTES = 512 * 1024 * 1024
PUBLIC_FRAME_BYTES = 32 * 1024 * 1024
METADATA_LINE_BYTES = 32 * 1024 * 1024
@@ -0,0 +1,139 @@
"""One native aimctl process per call, under the existing execution UID.
AIM is invoked through its documented wire protocol. Never imports AIM, alters
Ansible arguments, supplies an actor, or switches operating-system identity.
"""
from __future__ import annotations
import json
import os
import pwd
import selectors
import signal
import subprocess
import threading
import time
from aim_webgui.errors import WebError
from .protocol import validate_request, validate_secrets, safe_event
from .limits import PUBLIC_LINE_BYTES, PUBLIC_STREAM_BYTES, METADATA_LINE_BYTES
from .jsonio import loads
def stop(process):
if process.poll() is not None: return
try: process.send_signal(signal.SIGTERM)
except ProcessLookupError: return
try: process.wait(timeout=12)
except subprocess.TimeoutExpired:
try: os.killpg(process.pid,signal.SIGKILL)
except ProcessLookupError: pass
process.wait(timeout=3)
def invoke(settings, message, *, credentials=None, emit=None, cancel=None, connected=None, deadline=None):
"""Return a raw, authoritative response. Side-channel secrets never join stdin."""
validate_request(message)
operation=message['operation']
secret=validate_secrets(credentials or {})
if secret and operation!='execute':
raise WebError('core_request','Only execute accepts a private credential channel.')
request=(json.dumps(message,ensure_ascii=False,allow_nan=False,separators=(',',':'))+'\n').encode('utf-8')
command=list(settings.core_command)+['--config',str(settings.core_config)]
read_fd=write_fd=None
process=None; writer=None; delivered=threading.Event()
credential_bytes=None
try:
if operation=='execute':
read_fd,write_fd=os.pipe()
command+=['--credentials-fd',str(read_fd)]
credential_bytes=json.dumps(secret,ensure_ascii=False,allow_nan=False).encode('utf-8')
command+=['request']
# Trusted operator configuration chooses the executable; no browser-controlled argv.
account=pwd.getpwuid(os.geteuid())
env={'PATH':'/usr/local/bin:/usr/bin:/bin','HOME':str(settings.core_home) if settings.core_home else account.pw_dir,'LANG':'C.UTF-8',
'PYTHONDONTWRITEBYTECODE':'1','PYTHONNOUSERSITE':'1'}
process=subprocess.Popen(command,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,
pass_fds=(() if read_fd is None else (read_fd,)),env=env,cwd='/',start_new_session=True)
if read_fd is not None: os.close(read_fd);read_fd=None
if write_fd is not None:
fd=write_fd;write_fd=None
def deliver():
try:
with os.fdopen(fd,'wb',buffering=0) as stream:
view=memoryview(credential_bytes)
while view:
n=stream.write(view)
if not n: break
view=view[n:]
except (OSError,BrokenPipeError): pass
finally: delivered.set()
writer=threading.Thread(target=deliver,daemon=True);writer.start()
# stdin is small/bounded (128 KiB). Writer runs concurrently with event reads.
def request_writer():
try: process.stdin.write(request);process.stdin.close()
except (OSError,BrokenPipeError): pass
sender=threading.Thread(target=request_writer,daemon=True);sender.start()
timeout=(message.get('request',{}).get('timeout_seconds',3600)+180 if operation=='execute'
else 120 if operation=='readiness' else 30)
end=time.monotonic()+timeout
line_limit=PUBLIC_LINE_BYTES if operation=='execute' else METADATA_LINE_BYTES
buffer=bytearray(); total=0; final=None; sequence=0; executing=False
with selectors.DefaultSelector() as selector:
selector.register(process.stdout,selectors.EVENT_READ)
eof=False
while not eof:
if cancel is not None and cancel.is_set():
stop(process); raise WebError('core_cancelled','Cancellation requested; completed remote work is not rolled back.',409)
if connected is not None and not connected():
stop(process); raise WebError('core_disconnected','Execution client disconnected; cancellation requested.',409)
if deadline is not None and not executing and time.monotonic()>deadline:
stop(process);raise WebError('credential_expired','Credentials expired before execution began. Review a fresh attempt.',409)
if time.monotonic()>end:
stop(process);raise WebError('core_timeout','Core did not complete within its bounded lifecycle. Outcome requires review.',504)
for key,_ in selector.select(.1):
chunk=os.read(key.fileobj.fileno(),65536)
if not chunk:
eof=True;break
total+=len(chunk);buffer.extend(chunk)
if total>PUBLIC_STREAM_BYTES:
raise WebError('core_protocol','Core output exceeded bounded protocol limits.',502)
while b'\n' in buffer:
raw,_,remainder=buffer.partition(b'\n');buffer=bytearray(remainder)
if len(raw)>line_limit:raise WebError('core_protocol','Core JSON line exceeded its response budget.',502)
if not raw.strip(): continue
try: value=loads(raw)
except (ValueError,UnicodeError): raise WebError('core_protocol','Core output is not valid JSONL.',502) from None
if final is not None: raise WebError('core_protocol','Unexpected data after final core response.',502)
if not isinstance(value,dict): raise WebError('core_protocol','Invalid core frame.',502)
if value.get('type')=='event':
event=safe_event(value.get('event'))
if event is not None:
if event['sequence']<=sequence: raise WebError('core_protocol','Non-monotonic core event stream.',502)
sequence=event['sequence']
if event.get('kind')=='stage' and event.get('stage')=='execution': executing=True
if emit: emit({'type':'event','event':event})
elif value.get('type')=='response': final=value
else: raise WebError('core_protocol','Unknown wire frame type.',502)
if len(buffer)>line_limit:raise WebError('core_protocol','Core JSON line exceeded its response budget.',502)
if buffer.strip(): raise WebError('core_protocol','Truncated core wire frame.',502)
process.wait(timeout=5)
sender.join(timeout=1)
if final is None: raise WebError('core_outcome_unknown','Core exited without a final response. Do not assume success or retry automatically.',502)
if final.get('ok') is True and process.returncode!=0:
raise WebError('core_outcome_unknown','Core exit status disagrees with its final response.',502)
return final
except (OSError,subprocess.TimeoutExpired):
raise WebError('core_unavailable','Configured aimctl could not run. Check the core launcher and execution identity.',503) from None
finally:
if process is not None:
stop(process)
for stream in (process.stdin,process.stdout):
if stream is not None:
try: stream.close()
except OSError: pass
for fd in (read_fd,write_fd):
if fd is not None:
try: os.close(fd)
except OSError: pass
if writer: writer.join(timeout=1)
secret.clear();credential_bytes=None
@@ -0,0 +1,279 @@
"""Narrow validation of the documented core wire/event protocol.
Only structured core progress reaches a browser. Unknown additive response fields
are ignored; unknown errors fail safely. Requests never carry passwords.
"""
from __future__ import annotations
import json
import re
from aim_webgui.errors import WebError
from .reports import contract, operation_result, UNSPECIFIED
OPERATIONS = {'capabilities': set(), 'staging_check': set(), 'list_customers': set(),
'list_hosts': {'customer'}, 'inventory_hierarchy': {'customer'}, 'list_playbooks': {'customer'},
'prepare': {'request'}, 'readiness': {'request'},
'execute': {'request', 'expected_revision'}}
REQUEST_FIELDS = {'customer','playbook','hosts','overrides','check','key_mode','become_password','timeout_seconds','progress_mode'}
CREDENTIAL_FIELDS = {'vault_password','connection_password','ssh_key_passphrase'}
COUNTS = {'ok','changed','failures','unreachable','skipped','rescued','ignored'}
ERRORS = {
'operation_result_missing': 'Execution completed but a required report was missing. Remote changes may be complete; do not retry automatically.',
'operation_result_invalid': 'Core rejected an operation report. Inspect report availability; no automatic retry was made.',
'operation_result_withheld': 'Core withheld sensitive operation report data. This is not a credential-retry instruction.',
'operation_result_limit': 'Operation report exceeded its declared limits. Remote work may be complete.',
'operation_result_incomplete': 'Core did not receive a complete operation report stream. Review the execution and report states separately.',
'invalid_credentials': 'Core rejected the supplied one-run credential fields.',
'event_bridge_unavailable': 'Native execution did not emit final safe counters; success is not assumed.',
'runtime_config_invalid': 'Core rejected the local Ansible configuration.',
'runtime_incomplete': 'Core needs the native Ansible CLI tools in one consistent runtime.',
'runtime_identity_ambiguous': 'The native Ansible tools do not report one unambiguous interpreter environment.',
'collection_discovery_failed': 'Core could not inspect collections for the execution account.',
'ssh_tools_missing': 'Core requires OpenSSH key-loading tools for this request.',
'unencrypted_vault': 'Core requires the standard customer Vault to be encrypted.',
'access_denied': 'The configured core execution identity cannot access a required resource or is not in AIM required_group.',
'execution_disabled': 'AIM core has external execution disabled. An operator must review addons.execution_enabled in aim.yml.',
'invalid_request': 'AIM rejected the request schema. Review the selection and supported options.',
'invalid_target': 'A selected target is missing, incompatible or ambiguous. Review the current inventory.',
'invalid_options': 'A selected catalog option is invalid. Review the declared inputs.',
'source_invalid': 'Core configuration, inventory or catalog is invalid or inaccessible to its execution identity.',
'customer_unavailable': 'The selected customer is unavailable to AIM core.',
'unknown_playbook': 'The requested catalog playbook is not available.',
'playbook_unavailable': 'The catalog entry has no installed playbook for this customer.',
'resource_busy': 'Another AIM operation holds the customer lock. No automatic retry was made.',
'review_stale': 'AIM sources or reviewed options changed. Review a new run before submitting.',
'key_access_policy': 'Customer key mode requires a canonical 0600 key owned by the core execution account. No ownership changes were made.',
'key_unavailable': 'The canonical customer SSH key is unavailable to AIM core.',
'key_load_failed': 'AIM could not load the customer key. Verify ownership, key format and key passphrase in the terminal.',
'vault_missing': 'This catalog operation requires a customer Vault which is not available.',
'vault_unlock_failed': 'The customer Vault could not be unlocked. No automatic execution retry was made.',
'credential_required': 'Core requires fresh credentials for the reviewed run.',
'credentials_expired': 'The one-run credential window expired. Review and submit a new attempt.',
'invalid_credential_channel': 'The private credential channel was rejected; no insecure fallback is available.',
'runtime_missing': 'Core could not locate its configured native Ansible runtime.',
'runtime_version_unsupported': 'The configured native Ansible runtime is not supported by AIM core.',
'collections_missing': 'A catalog-required collection is missing for the core execution identity.',
'collection_version_unsupported': 'A required Ansible collection version is outside the Core-supported range. Update the approved Core collection environment before retrying.',
'runtime_credential_defaults_unsupported': 'Core rejected controller-wide Vault defaults for unattended execution. Inspect core readiness in the terminal.',
'controller_staging_unavailable': 'Core executor staging is unavailable. Review the executor sandbox/write path; credentials were not consumed.',
'controller_staging_unsafe': 'Core executor staging has unsafe ownership, permissions, type or symlinks. No automatic repair was made.',
'controller_staging_config_unsupported': 'Core could not safely resolve the configured controller staging path.',
'controller_staging_timeout': 'Core staging preflight timed out before remote execution.',
'controller_staging_probe_failed': 'Core staging preflight failed inside the executor sandbox.',
'event_bridge_incomplete': 'Core detailed progress was incomplete; success is not assumed.',
'event_limit': 'Core detailed progress exceeded its bounded event limit; success is not assumed.',
'syntax_check_failed': 'Native syntax/preflight checking failed. Use AIM terminal for detailed diagnostics.',
'host_unreachable': 'Ansible reported unreachable hosts. This does not distinguish network failure from rejected authentication.',
'playbook_failed': 'Ansible reported an execution or task failure. Remote work may have started.',
'timeout': 'Core timed out. Completed remote work is not rolled back.',
'cancelled': 'Core cancelled the local execution. Completed remote work is not rolled back.',
'event_sink_failed': 'The progress consumer disconnected or failed; execution cancellation was requested.',
'invalid_event_stream': 'Core could not validate native progress. The outcome requires operator review.',
'internal_error': 'AIM core could not complete the operation. No raw exception or secret was exposed.',
'api_version_unsupported': 'AIM service API version is incompatible with this add-on.',
'source_symlink_unsupported': 'Core does not accept symlinks in this reviewed source set.',
'revision_limit': 'Core source revision limits were exceeded.',
}
TRANSPORT_ERRORS = {
'operation_result_missing': 'Execution completed but a required report was missing. Remote changes may be complete; do not retry automatically.',
'operation_result_invalid': 'Core rejected an operation report. Inspect report availability; no automatic retry was made.',
'operation_result_withheld': 'Core withheld sensitive operation report data. This is not a credential-retry instruction.',
'operation_result_limit': 'Operation report exceeded its declared limits. Remote work may be complete.',
'operation_result_incomplete': 'Core did not receive a complete operation report stream. Review the execution and report states separately.',
'executor_busy': 'The executor is busy. Submit a fresh attempt when it is ready; credentials are not retained.',
'credential_expired': 'Credentials expired before execution began. Submit a fresh attempt.',
'core_cancelled': 'Core cancellation requested; completed remote work is not rolled back.',
'core_unavailable': 'The executor could not start configured aimctl. Check the launcher, configuration path and execution account.',
'core_timeout': 'The bounded core call timed out. Inspect the execution account and outcome before any explicit retry.',
'core_protocol': 'Core returned an invalid or unsupported wire response. No success is assumed.',
'core_outcome_unknown': 'Core exited without an authoritative result. The remote outcome is unknown; do not replay automatically.',
'core_disconnected': 'The local execution client disconnected; cancellation was requested.',
}
def request_message(operation, **fields):
value={'api_version':'1.0','operation':operation,**fields}
validate_request(value)
return value
def validate_request(value):
if not isinstance(value,dict) or value.get('api_version') != '1.0':
raise WebError('core_request', 'Use the supported AIM service API 1.0.')
op=value.get('operation')
if op not in OPERATIONS or set(value) != {'api_version','operation'}|OPERATIONS[op]:
raise WebError('core_request','Unsupported core operation or request field.')
if len(json.dumps(value,ensure_ascii=False,allow_nan=False).encode('utf-8')) > 131071:
raise WebError('core_request','Core request exceeds its size limit.')
if 'request' in value:
req=value['request']
if not isinstance(req,dict) or set(req)-REQUEST_FIELDS or not {'customer','playbook','hosts'} <= set(req):
raise WebError('core_request','Use documented run fields only; no credential, actor, path or command fields.')
if req.get('become_password',False) is not False:
raise WebError('become_unsupported','Become password entry is not exposed in this add-on release.')
return value
def validate_secrets(values):
if not isinstance(values,dict) or set(values)-CREDENTIAL_FIELDS:
raise WebError('invalid_credentials','Only supported one-run credential fields are accepted.')
result={}
for k,v in values.items():
try: valid = isinstance(v,str) and not any(c in v for c in ('\x00','\r','\n')) and len(v.encode('utf-8'))<=2048
except UnicodeError: valid=False
if not valid:
raise WebError('invalid_credentials','Use literal single-line passwords up to 2048 UTF-8 bytes.')
if v: result[k]=v
if len(json.dumps(result,ensure_ascii=False).encode())>8192:
raise WebError('invalid_credentials','Credential hand-off exceeds 8 KiB.')
return result
def safe_event(value):
if not isinstance(value,dict) or value.get('event_version')!='1.0':
raise WebError('core_protocol','Unsupported core event version.',502)
base={k:value[k] for k in ('event_version','run_id','sequence','timestamp','kind') if k in value}
if type(base.get('sequence')) is not int or base['sequence'] < 1:
raise WebError('core_protocol','Invalid core event sequence.',502)
kind=value.get('kind')
if kind=='stage':
if value.get('stage') not in {'credentials','syntax_check','execution'}: return None
base['stage']=value['stage']; return base
if kind=='progress':
if value.get('status') not in {'started','task','ok','failed','unreachable','skipped'}: return None
base['status']=value['status']; return base
if kind=='stats':
if not isinstance(value.get('counts'),dict): raise WebError('core_protocol','Invalid progress counters.',502)
base['counts']={k:v for k,v in value['counts'].items() if k in COUNTS and type(v) is int and v>=0}; return base
if kind=='result':
summary=value.get('result',value)
base['status']=summary.get('status') if isinstance(summary,dict) and summary.get('status') in {'succeeded','failed','cancelled'} else 'pending'
return base
# AIM 3.2 detailed events: validate the public safe projection again at the add-on boundary.
if kind=='play_started':
if not _id(value.get('play_id'),'p'): raise WebError('core_protocol','Invalid detailed play event.',502)
return {**base,'play_id':value['play_id'],'label':_label(value.get('label')),'label_redacted':value.get('label_redacted') is True}
if kind in {'play_skipped','play_stopped'}:
expected='no_hosts_matched' if kind=='play_skipped' else 'no_hosts_remaining'
if not _id(value.get('play_id'),'p') or value.get('reason')!=expected: raise WebError('core_protocol','Invalid detailed play state.',502)
return {**base,'play_id':value['play_id'],'reason':expected}
if kind=='task_started':
if not _id(value.get('play_id'),'p') or not _id(value.get('task_id'),'t') or type(value.get('handler')) is not bool:
raise WebError('core_protocol','Invalid detailed task event.',502)
return {**base,'play_id':value['play_id'],'task_id':value['task_id'],'label':_label(value.get('label')),
'label_redacted':value.get('label_redacted') is True,'handler':value['handler']}
if kind=='host_result':
status=value.get('status')
if (not _id(value.get('play_id'),'p') or not _id(value.get('task_id'),'t') or status not in {'ok','changed','skipped','failed','unreachable'}
or any(type(value.get(k)) is not bool for k in ('host_redacted','changed','ignored','details_redacted'))):
raise WebError('core_protocol','Invalid detailed host result.',502)
error=value.get('error')
if error is not None:
if (not isinstance(error,dict) or set(error)!={'code','message','classification'} or not _label(error.get('message'),400)
or not isinstance(error.get('code'),str) or not isinstance(error.get('classification'),str)):
raise WebError('core_protocol','Invalid detailed diagnostic hint.',502)
code=error['code']
messages={
'connection_refused':'Connection refused. Check the listener, port and firewall.',
'connection_timeout':'Connection timed out.', 'name_resolution_failed':'Name resolution failed.',
'tls_verification_failed':'TLS certificate verification failed.',
'authentication_failed':'Connection authentication failed. Check the configured identity and authentication method.',
'permission_denied':'Permission was denied.',
'host_unreachable':'Ansible could not reach or authenticate to this host. Further details are withheld.',
'task_failed':'The task failed. Further details are withheld.',
'details_withheld':'Sensitive task details are withheld by Core.'}
code=code if code in messages else 'details_withheld'
error={'code':code,'message':messages[code],'classification':code}
return {**base,'play_id':value['play_id'],'task_id':value['task_id'],'host':_host(value.get('host')),
'host_redacted':value['host_redacted'],'status':status,'changed':value['changed'],'ignored':value['ignored'],
'details_redacted':value['details_redacted'],'error':error}
if kind in {'task_retry','task_async_poll'}:
attempt=value.get('attempt')
if (not _id(value.get('play_id'),'p') or not _id(value.get('task_id'),'t') or type(value.get('host_redacted')) is not bool
or type(value.get('details_redacted')) is not bool or (attempt is not None and (type(attempt) is not int or not 0<=attempt<=1_000_000))):
raise WebError('core_protocol','Invalid detailed retry/poll event.',502)
return {**base,'play_id':value['play_id'],'task_id':value['task_id'],'host':_host(value.get('host')),
'host_redacted':value['host_redacted'],'attempt':attempt,'details_redacted':value['details_redacted']}
if kind=='host_recap':
if type(value.get('host_redacted')) is not bool or not isinstance(value.get('counts'),dict):
raise WebError('core_protocol','Invalid detailed recap event.',502)
counts={k:v for k,v in value['counts'].items() if k in COUNTS and type(v) is int and v>=0}
return {**base,'host':_host(value.get('host')),'host_redacted':value['host_redacted'],'counts':counts}
return None
def _id(value,prefix):
return isinstance(value,str) and bool(re.fullmatch(prefix+r'[1-9][0-9]{0,8}',value))
def _label(value,limit=200):
if not isinstance(value,str) or not value or len(value)>limit or any(ord(c)<32 and c!='\t' for c in value):
raise WebError('core_protocol','Invalid detailed label.',502)
return value
def _host(value):
if value is None:return None
if not isinstance(value,str) or len(value)>255 or not re.fullmatch(r'[A-Za-z0-9_][A-Za-z0-9_.-]*',value):
raise WebError('core_protocol','Invalid detailed host label.',502)
return value
def safe_error(value):
code=value.get('code','internal_error') if isinstance(value,dict) else 'internal_error'
if not isinstance(code,str) or not re.fullmatch('[a-z][a-z0-9_]{0,79}',code): code='internal_error'
return {'code':code, 'message':ERRORS.get(code,'Core reported an unsupported error code. Inspect the core contract; no automatic retry was made.'),
'stage':value.get('stage','unknown') if isinstance(value,dict) and value.get('stage') in {'validation','authorization','readiness','credentials','syntax_check','execution','unknown','preparation','key_loading','vault_unlock','completed','result_validation'} else 'unknown',
'retryable':bool(isinstance(value,dict) and value.get('retryable') is True)}
def _target_outcomes(result):
summary=result.get('target_summary')
targets=result.get('targets')
if not isinstance(summary,dict) or summary.get('schema')!='target_outcome_summary_v1' or not isinstance(targets,list):
raise WebError('core_protocol','Core did not provide authoritative per-target outcomes.',502)
fields=('requested','successful','failed','unreachable','not_started','indeterminate','accounted')
if any(type(summary.get(k)) is not int or summary[k]<0 for k in fields) or type(summary.get('complete')) is not bool:
raise WebError('core_protocol','Invalid target outcome summary.',502)
if summary['accounted']!=summary['requested'] or len(targets)!=summary['requested']:
raise WebError('core_protocol','Incomplete target outcome accounting.',502)
states={'successful','failed','unreachable','not_started','indeterminate'}
projected=[]; totals={state:0 for state in states}; seen=set()
for item in targets:
if not isinstance(item,dict) or item.get('outcome') not in states or not isinstance(item.get('counts'),dict):
raise WebError('core_protocol','Invalid per-target outcome.',502)
host=_host(item.get('host'))
if host is None or host in seen:raise WebError('core_protocol','Invalid per-target host accounting.',502)
seen.add(host); outcome=item['outcome'];totals[outcome]+=1
counts={k:v for k,v in item['counts'].items() if k in COUNTS and type(v) is int and v>=0}
if set(counts)!=COUNTS:raise WebError('core_protocol','Invalid per-target counters.',502)
projected.append({'host':host,'outcome':outcome,'counts':counts})
if any(summary[state]!=totals[state] for state in states):
raise WebError('core_protocol','Target outcome totals do not match target facts.',502)
projected_summary={'schema':'target_outcome_summary_v1',**{k:summary[k] for k in fields},'complete':summary['complete']}
return projected_summary,projected
def response_result(value, operation, *, declaration=UNSPECIFIED, request=None, secrets=()):
if not isinstance(value,dict) or value.get('type')!='response' or value.get('api_version')!='1.0' or type(value.get('ok')) is not bool:
raise WebError('core_protocol','Missing or invalid authoritative core response.',502)
if 'result' in value and operation=='execute':
result=value['result']
if not isinstance(result,dict) or result.get('status') not in {'succeeded','failed','cancelled'} or type(result.get('remote_work_may_have_started')) is not bool:
raise WebError('core_protocol','Core execution outcome could not be established.',502)
if result['status']=='succeeded' and (value['ok'] is not True or not isinstance(result.get('counts'),dict) or not COUNTS<=set(result['counts']) or any(type(result['counts'][k])is not int or result['counts'][k]<0 for k in COUNTS) or result.get('exit_code')!=0 or result.get('error')):
raise WebError('core_protocol','Core did not provide final execution counters; success is not assumed.',502)
target_summary,targets=_target_outcomes(result)
if request is not None and [x['host'] for x in targets]!=request['hosts']:
raise WebError('core_protocol','Final target accounting differs from the reviewed request.',502)
report=operation_result(result.get('operation_result'),declaration,targets=[x['host'] for x in targets],
check=request.get('check',True) if request else None,secrets=secrets)
return {'api_version':'1.0', 'run_id':str(result.get('run_id',''))[:100], 'status':result['status'],
'stage':result.get('stage') if result.get('stage') in {'validation','authorization','readiness','credentials','syntax_check','execution','preparation','key_loading','vault_unlock','completed','result_validation'} else 'unknown', 'exit_code':result.get('exit_code') if type(result.get('exit_code')) is int else None,
'remote_work_may_have_started':result['remote_work_may_have_started'],
'error':safe_error(result['error']) if result.get('error') else None,
'counts':{k:v for k,v in (result.get('counts') if isinstance(result.get('counts'),dict) else {}).items() if k in COUNTS and type(v)is int and v>=0},
'target_summary':target_summary,'targets':targets,'operation_result':report}
if not value['ok']:
err=safe_error(value.get('error',{}))
exc=WebError('core_'+err['code'],err['message'],409 if err['code'] in {'review_stale','resource_busy'} else 503)
exc.core_error=err
raise exc
result=value.get('result')
if operation=='prepare' and isinstance(result,dict):
result={**result,'result_contract':contract(result.get('result_contract'))}
elif operation=='readiness' and isinstance(result,dict) and isinstance(result.get('prepared'),dict):
result={**result,'prepared':{**result['prepared'],'result_contract':contract(result['prepared'].get('result_contract'))}}
elif operation=='list_playbooks' and isinstance(result,list):
result=[{**item,'result':contract(item.get('result'))} for item in result]
return result
@@ -0,0 +1,171 @@
"""Validate the documented Core operation-report schema at the add-on boundary.
Independent consumer implementation of the public, bounded JSON-schema subset.
Never import Core/Ansible, resolve schema files, execute validators or fetch $refs.
"""
from __future__ import annotations
import json
import math
import re
from aim_webgui.errors import WebError
PROTOCOL = 'aim_operation_result_v1'
PUBLISHER = 'aim_output_v1'
SLOT_BYTES = 1024 * 1024
RUN_BYTES = 16 * 1024 * 1024
MAX_DEPTH, MAX_NODES, MAX_ITEMS = 20, 200_000, 20_000
SCHEMA_ID = re.compile(r'[a-z][a-z0-9_]{0,79}_v[1-9][0-9]*\Z')
SECRET_KEY = re.compile(r'(^|_)(password|passwd|passphrase|secret|token|credential|private_key|vault)(_|$)', re.I)
AVAILABILITY = frozenset({'available','missing','withheld','invalid','not_started','indeterminate'})
REPORT_ERRORS = frozenset({'operation_result_missing','operation_result_invalid','operation_result_withheld',
'operation_result_limit','operation_result_incomplete'})
KEYWORDS = {'type','properties','required','additionalProperties','items','enum','maxItems','maxLength','minimum','maximum','description'}
KINDS = {'object','array','string','number','integer','boolean','null'}
CONTRACT_FIELDS = {'protocol','schema','scope','required','sensitivity','max_bytes_per_host','data_schema'}
UNSPECIFIED = object()
def bad(message='Core supplied an invalid operation-report contract or payload.'):
raise WebError('core_protocol', message, 502)
def encoded(value):
try:
return json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(',',':')).encode('utf-8')
except (ValueError, TypeError, UnicodeError, RecursionError):
bad()
def _text(value, maximum=8192):
if not isinstance(value,str) or len(value)>maximum or any(ord(c)<32 or ord(c)==127 for c in value):
bad()
try: value.encode('utf-8')
except UnicodeError: bad()
return value
def contract(value):
if value is None:
return None
if not isinstance(value,dict) or not CONTRACT_FIELDS <= value.keys():
bad()
if (value['protocol']!=PUBLISHER or not isinstance(value['schema'],str) or not SCHEMA_ID.fullmatch(value['schema'])
or value['scope'] not in ('per_host','global') or type(value['required']) is not bool
or value['sensitivity']!='safe' or type(value['max_bytes_per_host']) is not int
or not 1<=value['max_bytes_per_host']<=SLOT_BYTES):
bad()
budget=[0]
def shape(node, depth=0):
budget[0]+=1
if depth>MAX_DEPTH or budget[0]>5000 or not isinstance(node,dict) or set(node)-KEYWORDS:
bad('Core report schema uses an unsupported or unbounded schema language.')
kinds=node.get('type'); kinds=kinds if isinstance(kinds,list) else [kinds]
if not kinds or any(not isinstance(k,str) or k not in KINDS for k in kinds):bad()
if len(kinds)!=len(set(kinds)):bad()
clean={k:v for k,v in node.items() if k in KEYWORDS}
if 'description' in node:_text(node['description'])
if 'object' in kinds:
if type(node.get('additionalProperties'))is not bool or not isinstance(node.get('properties'),dict):bad()
if len(node['properties'])>MAX_ITEMS:bad()
props={}
for key,child in node['properties'].items():
_text(key,256)
if SECRET_KEY.search(key):bad()
props[key]=shape(child,depth+1)
required=node.get('required',[])
if not isinstance(required,list) or any(not isinstance(k,str) or k not in props for k in required):bad()
if len(required)!=len(set(required)):bad()
clean['properties']=props;clean['required']=required
if 'array' in kinds:
if type(node.get('maxItems'))is not int or not 0<=node['maxItems']<=MAX_ITEMS:bad()
clean['items']=shape(node.get('items'),depth+1)
if 'string' in kinds and (type(node.get('maxLength'))is not int or not 0<=node['maxLength']<=8192):bad()
if 'enum' in node:
if not isinstance(node['enum'],list) or len(node['enum'])>100:bad()
if any(type(item) not in (type(None),bool,int,float,str) for item in node['enum']):bad()
for item in node['enum']:
if isinstance(item,str):_text(item)
if type(item) in (int,float) and (not -1e100<=item<=1e100 or not math.isfinite(item)):bad()
for key in ('minimum','maximum'):
if key in node and (type(node[key])not in (int,float) or not -1e100<=node[key]<=1e100 or not math.isfinite(node[key])):bad()
return clean
result={key:value[key] for key in CONTRACT_FIELDS}
result['data_schema']=shape(value['data_schema'])
if len(encoded(result))>256*1024:bad('Core report schema exceeds the supported metadata size.')
return result
def data(value, schema, *, secrets=()):
"""Copy and validate types, sizes and declared fields. No string reinterpretation."""
budget=[0]
dynamic={'type':list(KINDS),'properties':{},'additionalProperties':True,'maxItems':MAX_ITEMS,'maxLength':8192}
dynamic['items']=dynamic
def walk(item,node,depth=0):
budget[0]+=1
if depth>MAX_DEPTH or budget[0]>MAX_NODES:bad('Core report exceeds its structural bounds.')
kind=('null' if item is None else 'boolean' if type(item)is bool else 'integer' if type(item)is int
else 'number' if type(item)is float else 'string' if type(item)is str
else 'object' if type(item)is dict else 'array' if type(item)is list else 'invalid')
kinds=node['type'] if isinstance(node['type'],list) else [node['type']]
if kind not in kinds and not(kind=='integer' and 'number' in kinds):bad()
if 'enum' in node and not any(item==v and (type(item)is type(v) or type(item)in(int,float) and type(v)in(int,float)) for v in node['enum']):bad()
if kind=='object':
if len(item)>MAX_ITEMS:bad()
props=node['properties']
if (not node['additionalProperties'] and set(item)-set(props)) or set(node.get('required',[]))-set(item):bad()
result={}
for key,val in item.items():
_text(key,256)
if (SECRET_KEY.search(key) and val!='[REDACTED]') or any(s and s in key for s in secrets):bad('Core report contained disallowed sensitive content.')
result[key]=walk(val,props.get(key,dynamic),depth+1)
return result
if kind=='array':
if len(item)>node['maxItems']:bad()
return [walk(v,node['items'],depth+1) for v in item]
if kind=='string':
_text(item,node['maxLength'])
if any(s and s in item for s in secrets):bad('Core report contained a supplied credential value.')
if kind in ('integer','number'):
if not node.get('minimum',-1e100)<=item<=node.get('maximum',1e100) or not math.isfinite(item):bad()
return item
return walk(value,schema)
def operation_result(value, declaration=UNSPECIFIED, *, targets=(), check=None, secrets=()):
"""Project only public fields and bind every report to the reviewed contract."""
if value is None:
if declaration not in (None,UNSPECIFIED):bad('Declared report accounting is absent from the final Core result.')
return None
if declaration is None:bad('Core returned an undeclared operation report.')
if declaration is UNSPECIFIED:bad('A reviewed result contract is required to accept operation reports.')
decl=contract(declaration)
if not isinstance(value,dict):bad()
for key,expect in (('protocol',PROTOCOL),('schema',decl['schema']),('scope',decl['scope']),('required',decl['required'])):
if value.get(key)!=expect or key=='required' and type(value.get(key))is not bool:bad()
if type(value.get('complete'))is not bool or type(value.get('check_mode'))is not bool:bad()
if check is not None and value['check_mode'] is not check:bad('Core report mode differs from the reviewed request.')
hosts=value.get('hosts')
if not isinstance(hosts,dict) or len(hosts)>1000:bad()
if decl['scope']=='per_host':
if set(hosts)!=set(targets) or len(hosts)!=len(targets) or value.get('global')is not None:bad('Core report hosts differ from the reviewed targets.')
slots=hosts
else:
if hosts or not isinstance(value.get('global'),dict):bad()
slots={'':value['global']}
cleaned={};total=0
for host,entry in slots.items():
if (not isinstance(entry,dict) or entry.get('schema')!=decl['schema'] or not isinstance(entry.get('status'),str) or entry.get('status')not in AVAILABILITY
or entry.get('error') is not None and (not isinstance(entry.get('error'),str) or entry.get('error')not in REPORT_ERRORS)):bad()
content=None
if entry['status']=='available':
if entry.get('error')is not None:bad()
content=data(entry.get('data'),decl['data_schema'],secrets=secrets)
size=len(encoded({'protocol':PUBLISHER,'schema':decl['schema'],'data':content}))
total+=size
if size>decl['max_bytes_per_host'] or total>RUN_BYTES:bad('Core operation report exceeded its advertised byte limits.')
elif entry.get('data')is not None:bad('Unavailable report slots must not carry data.')
cleaned[host]={'schema':decl['schema'],'status':entry['status'],'data':content,'error':entry.get('error')}
if value['complete'] and any(v['status']!='available' for v in cleaned.values()):bad()
return {'protocol':PROTOCOL,'schema':decl['schema'],'scope':decl['scope'],'required':decl['required'],
'complete':value['complete'],'check_mode':value['check_mode'],
'hosts':cleaned if decl['scope']=='per_host' else {},'global':cleaned.get('') if decl['scope']=='global' else None}
@@ -0,0 +1 @@
"""Job-scoped credentials. No durable secret store or cross-run cache."""
@@ -0,0 +1,77 @@
"""Non-secret credential/attention presentation. Never prepares or runs Core work."""
from __future__ import annotations
import json
import time
from aim_webgui.errors import WebError
from aim_webgui.workflows import Workflows, actor, allowed
SUPPORTED = frozenset({'vault_password', 'connection_password',
'ssh_key_passphrase', 'ssh_key_passphrase_or_customer_vault_value'})
def form_context(job: dict, *, now: int | None = None) -> dict:
requirements = job['plan'].get('credential_requirements', [])
if not isinstance(requirements, list) or not requirements or any(r not in SUPPORTED for r in requirements):
raise WebError('unsupported_credentials', 'This run needs credentials not supported by this form. Review the job.', 409)
return {'job': job, 'credential_requirements': requirements,
'credential_server_now': int(time.time()) if now is None else now,
'has_vault': 'vault_password' in requirements,
'has_connection': 'connection_password' in requirements,
'has_key': any(r.startswith('ssh_key_passphrase') for r in requirements),
'key_from_vault': ('vault_password' in requirements
and 'ssh_key_passphrase_or_customer_vault_value' in requirements
and 'ssh_key_passphrase' not in requirements)}
def status(flow: Workflows, user_id: int, ident: str) -> dict:
"""Owner-only status, not an eligibility token or an execution/renewal action."""
job = flow.job(user_id, ident)
if job['owner_id'] != user_id:
raise WebError('credential_owner', 'Only the requesting account may supply this job\'s credentials.', 403)
now = int(time.time())
deadline = job.get('credential_deadline') or 0
phase = job.get('credential_phase') or ''
can_submit = (flow.settings.credentials_enabled and job['status'] == 'running'
and phase == 'waiting' and deadline > now and not job['cancel_requested'])
if can_submit:
with flow.store.read() as db:
allowed(db, user_id, job['plan']['customer'], job['plan']['playbook'])
return {'job_id': ident, 'status': job['status'], 'phase': phase,
'can_submit': bool(can_submit), 'cancel_requested': bool(job['cancel_requested']),
'server_now': now, 'deadline': deadline if phase == 'waiting' else None}
def attention(flow: Workflows, user_id: int, *, limit: int = 8) -> dict:
"""All actionable jobs, not the latest-100 history page. No secrets or Core calls."""
now = int(time.time())
items = []
with flow.store.read() as db:
who = actor(db, user_id)
rows = db.execute('''SELECT jobs.id, jobs.owner_id, jobs.plan, jobs.mode, jobs.status,
jobs.credential_deadline, jobs.created_at, users.username
FROM jobs JOIN users ON users.id=jobs.owner_id
WHERE jobs.cancel_requested=0 AND (
(jobs.owner_id=? AND jobs.status='running' AND jobs.credential_phase='waiting'
AND jobs.credential_deadline>?) OR
(?='admin' AND jobs.owner_id<>? AND jobs.status='pending'))
ORDER BY CASE WHEN jobs.status='running' THEN 0 ELSE 1 END,
jobs.credential_deadline, jobs.created_at''', (user_id, now, who['role'], user_id))
for row in rows:
credential = row['status'] == 'running'
if credential and not flow.settings.credentials_enabled:
continue
plan = json.loads(row['plan'])
try:
allowed(db, row['owner_id'], plan['customer'], plan['playbook'])
except WebError:
continue
items.append({'id': row['id'], 'customer': plan['customer'], 'playbook': plan['playbook'],
'username': row['username'], 'mode': row['mode'],
'target_count': len(plan.get('targets', [])),
'kind': 'credentials' if credential else 'approval',
'deadline': row['credential_deadline'] if credential else None})
return {'items': items[:limit], 'total': len(items), 'shown': min(limit, len(items)),
'credentials': sum(i['kind'] == 'credentials' for i in items),
'approvals': sum(i['kind'] == 'approval' for i in items)}
@@ -0,0 +1,67 @@
"""HTTP-to-worker hand-off. Never retain secrets awaiting a queue slot."""
from __future__ import annotations
import json
import time
from aim_webgui.credentials import wire
from aim_webgui.errors import WebError
from aim_webgui.workflows import Workflows, allowed
HANDOFF_SECONDS = 60
WAIT_SECONDS = 300
def fields(value, requirements):
"""Core-reported one-run fields only. Passwords are literal, not overrides."""
from aim_webgui.core.protocol import validate_secrets
result=validate_secrets(value)
permitted=set(requirements)-{'ssh_key_passphrase_or_customer_vault_value'}
if 'ssh_key_passphrase_or_customer_vault_value' in requirements:permitted.add('ssh_key_passphrase')
if set(result)-permitted:raise WebError('unexpected_credentials','Only fields requested by this prepared core run may be supplied.')
for name in ('vault_password','connection_password','ssh_key_passphrase'):
if name in requirements and not result.get(name):
raise WebError('credential_required','Enter the required one-run credential; values are not saved.')
if ('ssh_key_passphrase_or_customer_vault_value' in requirements and not result.get('vault_password')
and not result.get('ssh_key_passphrase')):
raise WebError('credential_required','Enter the key passphrase or unlock the customer Vault containing it.')
return result
def valid_session(flow, token_hash, user_id):
now = int(time.time())
with flow.store.read() as db:
row = db.execute('SELECT * FROM sessions WHERE token_hash=? AND user_id=?', (token_hash, user_id)).fetchone()
if not row or row['expires_at'] <= now or row['last_seen_at'] + flow.settings.idle_minutes * 60 <= now:
raise WebError('session_expired', 'Sign in again before submitting credentials.', 403)
def eligible(flow, user_id, ident):
if not flow.settings.credentials_enabled:
raise WebError('credentials_disabled', 'Credential execution is disabled in this release profile.', 501)
job = flow.job(user_id, ident)
if job['owner_id'] != user_id:
raise WebError('credential_owner', 'Only the requesting account may supply this job\'s credentials.', 403)
if (job['status'] != 'running' or job['credential_phase'] != 'waiting'
or (job['credential_deadline'] or 0) <= time.time() or job['cancel_requested']):
raise WebError('worker_not_ready', 'Credentials are accepted only while this job is awaiting credentials.', 409)
with flow.store.read() as db:
allowed(db, user_id, job['plan']['customer'], job['plan']['playbook'])
return job
def submit(settings, user_id, session_hash, ident, supplied):
flow = Workflows(settings)
job = eligible(flow, user_id, ident)
valid_session(flow, session_hash, user_id)
secret = fields(supplied, job['plan']['credential_requirements'])
try:
with wire.connect(settings.state_dir / '.credential.sock') as sock:
wire.send(sock, {'job': ident, 'user': user_id, 'session': session_hash, 'credentials': secret}, wire.SECRET_LIMIT)
result = wire.receive(sock, wire.SECRET_LIMIT)
if result.get('accepted') is not True:
raise WebError('credential_rejected', 'The worker did not accept this credential hand-off. Check the job before another manual attempt.', 409)
return {'accepted': True, 'notice': 'One-run hand-off accepted; this is not remote authentication verification.'}
except (OSError, ValueError):
raise WebError('worker_not_ready', 'The credential hand-off could not be confirmed. Check the job state before another manual attempt; do not resubmit automatically.', 409) from None
finally:
secret.clear()
@@ -0,0 +1,78 @@
"""Bounded, length-framed local IPC. This module uses only the standard library."""
from __future__ import annotations
import json
import os
import socket
import stat
import struct
MAX_MESSAGE = 1024 * 1024
SECRET_LIMIT = 8192
def recv_exact(sock, size):
buf = bytearray()
while len(buf) < size:
chunk = sock.recv(size - len(buf))
if not chunk:
raise ValueError('Credential channel closed.')
buf.extend(chunk)
return bytes(buf)
def receive(sock, limit=MAX_MESSAGE, *, decoder=json.loads):
size = struct.unpack('!I', recv_exact(sock, 4))[0]
if size < 2 or size > limit:
raise ValueError('Credential message exceeds its limit.')
result = decoder(recv_exact(sock, size))
if not isinstance(result, dict):
raise ValueError('Invalid credential message.')
return result
def send(sock, value, limit=MAX_MESSAGE):
data = json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(',', ':')).encode('utf-8')
if len(data) > limit:
raise ValueError('Credential message exceeds its limit.')
sock.sendall(struct.pack('!I', len(data)) + data)
def peer(sock, *, same_group=False):
pid, uid, gid = struct.unpack('3i', sock.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))
if uid != os.geteuid() or (same_group and os.getpgid(pid) != os.getpgrp()):
raise ValueError('Untrusted local credential peer.')
return pid
def connect(path, *, timeout=5):
item = os.lstat(path)
if not stat.S_ISSOCK(item.st_mode) or item.st_uid != os.geteuid() or item.st_mode & 0o077:
raise ValueError('Credential endpoint must be private and service-owned.')
sock = socket.socket(socket.AF_UNIX)
sock.settimeout(timeout)
try:
sock.connect(str(path))
peer(sock)
except BaseException:
sock.close()
raise
return sock
def listen(path):
# Caller owns a private directory. Never follow/delete an arbitrary symlink.
if os.path.lexists(path):
item = os.lstat(path)
if not stat.S_ISSOCK(item.st_mode) or item.st_uid != os.geteuid():
raise ValueError('Unsafe existing credential endpoint.')
os.unlink(path)
sock = socket.socket(socket.AF_UNIX)
try:
sock.bind(str(path))
os.chmod(path, 0o600)
sock.listen(2)
sock.settimeout(.5)
except BaseException:
sock.close()
raise
return sock
@@ -0,0 +1,27 @@
CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE users (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin','viewer')),
enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0,1)),
must_change_password INTEGER NOT NULL DEFAULT 1 CHECK(must_change_password IN (0,1)),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_login_at INTEGER
);
CREATE TABLE sessions (
token_hash TEXT PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
csrf TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL
);
CREATE INDEX sessions_user ON sessions(user_id);
CREATE INDEX sessions_expiry ON sessions(expires_at);
CREATE TABLE rate_limits (bucket TEXT PRIMARY KEY, started INTEGER NOT NULL, attempts INTEGER NOT NULL);
CREATE TABLE audit (
id INTEGER PRIMARY KEY, occurred_at INTEGER NOT NULL,
actor TEXT NOT NULL, action TEXT NOT NULL, subject TEXT NOT NULL
);
@@ -0,0 +1,54 @@
CREATE TABLE plans (
id TEXT PRIMARY KEY,
owner_id INTEGER NOT NULL REFERENCES users(id),
name TEXT NOT NULL,
customer TEXT NOT NULL,
playbook TEXT NOT NULL,
payload TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX plans_owner ON plans(owner_id, created_at);
CREATE TABLE grants (
user_id INTEGER NOT NULL REFERENCES users(id),
customer TEXT NOT NULL,
playbook TEXT NOT NULL,
PRIMARY KEY(user_id, customer, playbook)
);
CREATE TABLE jobs (
id TEXT PRIMARY KEY,
owner_id INTEGER NOT NULL REFERENCES users(id),
approver_id INTEGER REFERENCES users(id),
plan TEXT NOT NULL,
mode TEXT NOT NULL CHECK(mode IN ('apply','check')),
status TEXT NOT NULL CHECK(status IN ('pending','queued','running','successful','failed','canceled','timed_out','blocked','interrupted')),
created_at INTEGER NOT NULL,
scheduled_at INTEGER NOT NULL,
started_at INTEGER,
finished_at INTEGER,
return_code INTEGER,
cancel_requested INTEGER NOT NULL DEFAULT 0,
reason TEXT NOT NULL DEFAULT ''
);
CREATE INDEX jobs_due ON jobs(status, scheduled_at);
CREATE TABLE job_events (
id INTEGER PRIMARY KEY,
job_id TEXT NOT NULL REFERENCES jobs(id),
occurred_at INTEGER NOT NULL,
event TEXT NOT NULL
);
CREATE INDEX events_job ON job_events(job_id, id);
CREATE TABLE selection_drafts (
user_id INTEGER NOT NULL REFERENCES users(id),
customer TEXT NOT NULL,
playbook TEXT NOT NULL,
targets TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY(user_id, customer, playbook)
);
CREATE INDEX audit_time ON audit(occurred_at, id);
CREATE TABLE job_requests (
owner_id INTEGER NOT NULL REFERENCES users(id),
request_key TEXT NOT NULL,
job_id TEXT NOT NULL REFERENCES jobs(id),
PRIMARY KEY(owner_id,request_key)
);
@@ -0,0 +1,3 @@
-- Non-secret phase metadata only. Passwords never enter this database.
ALTER TABLE jobs ADD COLUMN credential_phase TEXT NOT NULL DEFAULT 'none';
ALTER TABLE jobs ADD COLUMN credential_deadline INTEGER;
@@ -0,0 +1,19 @@
-- Additive migration. Keep historical jobs, accounts, grants, plans and audit.
ALTER TABLE plans ADD COLUMN name_key TEXT;
CREATE UNIQUE INDEX plan_names_unique ON plans(owner_id,name_key) WHERE name_key IS NOT NULL;
ALTER TABLE job_requests ADD COLUMN request_hash TEXT;
ALTER TABLE jobs ADD COLUMN core_result TEXT;
CREATE TABLE run_reviews (
id TEXT PRIMARY KEY,
owner_id INTEGER NOT NULL REFERENCES users(id),
payload TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX review_owner_expiry ON run_reviews(owner_id,expires_at);
INSERT INTO audit(occurred_at,actor,action,subject)
SELECT CAST(strftime('%s','now') AS INTEGER),'migration','legacy-job-stopped',id FROM jobs WHERE status IN ('pending','queued','running');
UPDATE jobs SET status=CASE WHEN status='running' THEN 'interrupted' ELSE 'blocked' END,
finished_at=CAST(strftime('%s','now') AS INTEGER),credential_phase='released',credential_deadline=NULL,
reason='Core API migration: this old-core job was not replayed. Review a new run against AIM 3.1.0.'
WHERE status IN ('pending','queued','running');
@@ -0,0 +1,36 @@
-- Retained WebGUI-owned evidence. No raw output, credential or inventory archive.
CREATE TABLE job_progress_state (
job_id TEXT PRIMARY KEY REFERENCES jobs(id) ON DELETE CASCADE,
run_id TEXT, capture_state TEXT NOT NULL DEFAULT 'recording',
cursor INTEGER NOT NULL DEFAULT 0, last_sequence INTEGER NOT NULL DEFAULT 0,
retained_events INTEGER NOT NULL DEFAULT 0, retained_bytes INTEGER NOT NULL DEFAULT 0,
omitted_events INTEGER NOT NULL DEFAULT 0, dropped_events INTEGER NOT NULL DEFAULT 0,
checkpoint TEXT NOT NULL DEFAULT '{}', updated_at REAL NOT NULL, closed_at REAL
);
CREATE TABLE job_progress_events (
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, cursor INTEGER NOT NULL,
run_id TEXT NOT NULL, sequence INTEGER NOT NULL, received_at REAL NOT NULL,
payload TEXT NOT NULL, size_bytes INTEGER NOT NULL,
PRIMARY KEY(job_id,cursor), UNIQUE(job_id,run_id,sequence)
);
CREATE TABLE job_operation_results (
job_id TEXT PRIMARY KEY REFERENCES jobs(id) ON DELETE CASCADE,
contract TEXT, protocol TEXT, schema_id TEXT, scope TEXT,
required INTEGER, complete INTEGER, check_mode INTEGER, stored_at REAL NOT NULL,
retention_policy TEXT NOT NULL
);
CREATE TABLE job_operation_reports (
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
slot TEXT NOT NULL, schema_id TEXT NOT NULL, status TEXT NOT NULL, error TEXT,
retention TEXT NOT NULL, data TEXT, size_bytes INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(job_id,slot)
);
-- Never replay plans prepared against the old independently upgraded Core.
INSERT INTO audit(occurred_at,actor,action,subject)
SELECT CAST(strftime('%s','now') AS INTEGER),'migration','core33-job-stopped',id
FROM jobs WHERE status IN ('pending','queued','running');
UPDATE jobs SET status=CASE WHEN status='running' THEN 'interrupted' ELSE 'blocked' END,
finished_at=CAST(strftime('%s','now') AS INTEGER),credential_phase='released',credential_deadline=NULL,
reason='Core 3.3 migration: old prepared work was not replayed. Review a fresh run.'
WHERE status IN ('pending','queued','running');
DELETE FROM run_reviews;
@@ -0,0 +1,149 @@
from __future__ import annotations
from contextlib import contextmanager
import os
from pathlib import Path
import sqlite3
import stat
import time
from aim_webgui import SCHEMA_VERSION
def private_file(path: Path, content: str, *, exclusive: bool = True) -> None:
flags = os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW
flags |= os.O_EXCL if exclusive else os.O_TRUNC
fd = os.open(path, flags, 0o600)
with os.fdopen(fd, 'w', encoding='utf-8') as stream:
os.fchmod(stream.fileno(), 0o600)
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
def ensure_private_dir(path: Path) -> None:
path.mkdir(parents=True, mode=0o700, exist_ok=True)
mode = path.lstat()
if not stat.S_ISDIR(mode.st_mode) or mode.st_uid != os.geteuid():
raise ValueError('State directory must be a real directory owned by the current service identity.')
if stat.S_IMODE(mode.st_mode) & 0o077:
raise ValueError('State directory must have mode 0700.')
class Store:
def __init__(self, path: Path):
self.path = path
def connect(self, *, create: bool = False, timeout: float = 10) -> sqlite3.Connection:
ensure_private_dir(self.path.parent)
if create and not self.path.exists():
fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, 0o600)
os.close(fd)
mode = self.path.lstat()
if (not stat.S_ISREG(mode.st_mode) or mode.st_uid != os.geteuid()
or stat.S_IMODE(mode.st_mode) & 0o077):
raise ValueError('Database must be a service-owned regular file with mode 0600.')
db = sqlite3.connect(self.path.as_uri() + '?mode=rw', uri=True, timeout=timeout)
db.row_factory = sqlite3.Row
db.execute('PRAGMA foreign_keys=ON')
db.execute(f'PRAGMA busy_timeout={max(0,int(timeout * 1000))}')
# Retain rollback journaling and FULL commits; evidence uses bounded batches.
db.execute('PRAGMA synchronous=FULL')
return db
@contextmanager
def transaction(self, *, timeout: float = 10):
db = self.connect(timeout=timeout)
try:
db.execute('BEGIN IMMEDIATE')
yield db
db.commit()
except BaseException:
db.rollback()
raise
finally:
db.close()
@contextmanager
def read(self):
db = self.connect()
try:
yield db
finally:
db.close()
def migrate(self, *, create: bool = False) -> None:
db = self.connect(create=create)
try:
db.execute('PRAGMA journal_mode=DELETE')
db.execute('BEGIN IMMEDIATE')
version = db.execute('PRAGMA user_version').fetchone()[0]
if version > SCHEMA_VERSION:
raise ValueError('Database schema is newer than this add-on. Restore the matched backup before rollback.')
for target in range(version + 1, SCHEMA_VERSION + 1):
matches = list((Path(__file__).parent / 'migrations').glob(f'{target:04d}_*.sql'))
if len(matches) != 1:
raise ValueError('Missing or ambiguous database migration.')
statement = ''
for line in matches[0].read_text(encoding='utf-8').splitlines(True):
statement += line
if sqlite3.complete_statement(statement):
db.execute(statement)
statement = ''
if statement.strip():
raise ValueError('Incomplete database migration.')
if target == 4:
from aim_webgui.names import name_key
seen=set()
for row in db.execute('SELECT id,owner_id,name FROM plans ORDER BY created_at,id').fetchall():
key=(row['owner_id'],name_key(row['name']))
# Preserve existing duplicate titles/payloads unchanged. First
# occurrence reserves the key; new saves also check all legacy names.
if key not in seen:
db.execute('UPDATE plans SET name_key=? WHERE id=?',(key[1],row['id']))
seen.add(key)
db.execute(f'PRAGMA user_version={target}')
db.commit()
except BaseException:
db.rollback()
raise
finally:
db.close()
def check(self, *, require_admin: bool = True) -> None:
with self.read() as db:
if db.execute('PRAGMA user_version').fetchone()[0] != SCHEMA_VERSION:
raise ValueError('Database migration required. Stop the service and run aim-web db migrate.')
if db.execute("SELECT value FROM metadata WHERE key='initialized'").fetchone() is None:
raise ValueError('Database not initialized. Run aim-web init once.')
if require_admin and not db.execute("SELECT 1 FROM users WHERE role='admin' AND enabled=1 LIMIT 1").fetchone():
raise ValueError('No enabled administrator; use local recovery.')
def backup(self, destination: Path) -> None:
if destination.exists() or destination.is_symlink():
raise ValueError('Backup destination already exists.')
fd = os.open(destination, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, 0o600)
os.close(fd)
try:
with self.read() as source:
target = sqlite3.connect(destination)
try:
source.backup(target)
if target.execute('PRAGMA integrity_check').fetchone()[0] != 'ok':
raise ValueError('Database backup integrity check failed.')
finally:
target.close()
fd = os.open(destination, os.O_RDONLY | os.O_NOFOLLOW)
try:
os.fsync(fd)
finally:
os.close(fd)
except BaseException:
destination.unlink(missing_ok=True)
raise
def audit(db, actor: str, action: str, subject: str) -> None:
# Fixed event names and account identifiers only; never request bodies/credentials.
db.execute('INSERT INTO audit(occurred_at,actor,action,subject) VALUES(?,?,?,?)',
(int(time.time()), actor, action, subject))
@@ -0,0 +1,45 @@
"""Read-only diagnostics. No automatic repairs, ownership changes or secrets."""
from __future__ import annotations
import os
from pathlib import Path
import time
from aim_webgui import __version__, SCHEMA_VERSION, COMPATIBLE_AIM
from aim_webgui.config import Settings
from aim_webgui.db.store import Store
def report(settings: Settings, config: Path | None = None) -> dict:
checks = []
def check(name, operation):
try:
value = operation()
checks.append({'name': name, 'ok': True, 'detail': str(value)})
except Exception as exc:
checks.append({'name': name, 'ok': False, 'detail': type(exc).__name__ + ': check service identity, permissions or installed dependencies.'})
check('configuration', lambda: settings.validate() and 'Valid')
from aim_webgui.adapters.core_v1 import CoreAdapter
check('AIM service contract',lambda:CoreAdapter(settings).version)
check('Core protected metadata access',lambda:len(CoreAdapter(settings).customers()))
check('Core execution opt-in',lambda:CoreAdapter(settings).capabilities['execution'])
from aim_webgui.assets import check_assets
check('local browser assets', lambda: check_assets() or 'Verified')
store = Store(settings.database)
check('database schema and enabled administrator', lambda: store.check() or f'Schema {SCHEMA_VERSION}')
heartbeat = None
try:
with store.read() as db:
row = db.execute("SELECT value FROM metadata WHERE key='worker_heartbeat'").fetchone()
heartbeat = max(0, int(time.time()) - int(row[0])) if row else None
except Exception:
pass
if settings.execution_enabled:
checks.append({'name': 'worker heartbeat', 'ok': heartbeat is not None and heartbeat < 15, 'detail': f'{heartbeat}s ago' if heartbeat is not None else 'Worker has not reported'})
return {'version': __version__, 'aim_compatibility': list(COMPATIBLE_AIM), 'schema': SCHEMA_VERSION,
'config': str(config) if config else '(provided at startup)',
'runtime': str(Path(__file__).resolve().parent), 'service_uid': os.geteuid(),'core_transport':settings.core_transport,'core_executor_user':settings.core_executor_user,
'listener': f'{settings.host}:{settings.port}', 'public_url': settings.public_url,
'trusted_proxies': list(settings.forwarded_allow_ips), 'execution_enabled': settings.execution_enabled,
'worker_heartbeat_age_seconds': heartbeat, 'checks': checks,
'ok': all(c['ok'] for c in checks),
'transport_note': 'TLS verification between NPM and controller must be checked in Nginx; this application cannot prove it.'}
@@ -0,0 +1,5 @@
class WebError(Exception):
"""Only this deliberately public message may be returned to a browser."""
def __init__(self, code: str, message: str, status: int = 400):
super().__init__(message)
self.code, self.message, self.status = code, message, status
@@ -0,0 +1,102 @@
"""Authorized read/replay endpoints for WebGUI-owned execution evidence."""
from __future__ import annotations
import asyncio
import json
import time
from urllib.parse import urlencode
from fastapi import Request
from fastapi.responses import JSONResponse, StreamingResponse
from starlette.concurrency import run_in_threadpool
from aim_webgui.errors import WebError
from aim_webgui.journal import Journal
from aim_webgui.reports import Reports, presentation
from aim_webgui.workflows import Workflows
def job_evidence(settings,user_id,ident):
return {'progress':Journal(settings).snapshot(user_id,ident),'reports':Reports(settings).index(user_id,ident)}
def install_evidence_views(app,settings,auth,render):
journal=Journal(settings);reports=Reports(settings);flow=Workflows(settings)
def uid(request):return request.state.session['user_id']
@app.get('/api/v2/runs/{ident}/progress')
async def progress(request: Request,ident: str,after: int|None=None,before: int|None=None,limit: int=200):
return await run_in_threadpool(journal.snapshot,uid(request),ident,after=after,before=before,limit=limit)
@app.get('/jobs/{ident}/progress')
async def progress_page(request: Request,ident: str,before: int|None=None):
data=await run_in_threadpool(journal.snapshot,uid(request),ident,before=before)
job=await run_in_threadpool(flow.job,uid(request),ident)
return render(request,'pages/progress.html',title='Recorded execution progress',nav='jobs',job=job,progress=data)
@app.get('/api/v2/runs/{ident}/progress/stream')
@app.get('/api/v2/runs/{ident}/console')
async def stream_progress(request: Request,ident: str,after: int=0):
value=request.headers.get('Last-Event-ID')
if value is not None:
if not value.isascii() or not value.isdigit() or len(value)>16:raise WebError('invalid_cursor','Invalid progress cursor.')
after=int(value)
# Validate cursor and object authorization before streaming headers.
initial=await run_in_threadpool(journal.snapshot,uid(request),ident,after=after)
async def stream():
cursor=after;last_heartbeat=0.;last_snapshot=None;first=initial
yield 'retry: 1500\n\n'
try:
while not await request.is_disconnected():
session=await run_in_threadpool(auth.session,request.cookies.get(settings.cookie_name))
if not session or session.get('user_id')!=uid(request) or session.get('must_change_password'):
yield 'event: end\ndata: {"text":"Session expired or access was revoked.","clear":true}\n\n';return
# Do not hold a DB read transaction while waiting on a browser.
data=first if first is not None else await run_in_threadpool(journal.snapshot,uid(request),ident,after=cursor)
first=None
if data.get('gap_before'):
cursor=data['first_cursor']-1
yield f'id: {cursor}\nevent: gap\ndata: '+json.dumps({'text':'Earlier progress metadata was omitted by the retention limit.','first_cursor':data['first_cursor']})+'\n\n'
metadata={k:v for k,v in data.items() if k not in ('events','has_older','has_more','next_cursor')}
fingerprint=(data['cursor'],data['job_status'],data.get('capture_state'),data.get('dropped_events'),data.get('capture_interrupted'))
if fingerprint!=last_snapshot:
yield 'event: snapshot\ndata: '+json.dumps(metadata,ensure_ascii=False,separators=(',',':'))+'\n\n'
last_snapshot=fingerprint
for item in data['events']:
cursor=item['cursor']
# Legacy console consumers can still render `text`; new clients
# also receive the correlated public metadata. Neither is input.
payload={'text':item['text'],'record':item}
yield f'id: {cursor}\nevent: line\ndata: '+json.dumps(payload,ensure_ascii=False,separators=(',',':'))+'\n\n'
if data['terminal'] and cursor>=data['cursor']:
text=('Execution ended. Recorded metadata and retained reports remain with this job.' if data['available']
else 'No detailed progress was captured for this historical or pre-execution job.')
yield 'event: end\ndata: '+json.dumps({'text':text,'job_status':data['job_status'],'cursor':data['cursor']})+'\n\n';return
if data.get('has_more'):continue
now=time.monotonic()
if now-last_heartbeat>=10:
yield ': heartbeat (transport only, not task activity)\n\n';last_heartbeat=now
await asyncio.sleep(.5)
except WebError:
yield 'event: end\ndata: {"text":"Job evidence is unavailable or access was revoked.","clear":true}\n\n'
return StreamingResponse(stream(),media_type='text/event-stream',headers={
'Cache-Control':'no-store','X-Accel-Buffering':'no','Connection':'keep-alive'})
@app.get('/api/v2/runs/{ident}/reports')
async def report_index(request: Request,ident: str):
return await run_in_threadpool(reports.index,uid(request),ident)
@app.get('/_partials/jobs/{ident}/reports')
async def report_panel(request: Request,ident: str):
return render(request,'partials/reports.html',reports=await run_in_threadpool(reports.index,uid(request),ident))
@app.get('/jobs/{ident}/reports')
async def report_page(request: Request,ident: str,host: str|None=None,field: str='',page: int=1):
item=await run_in_threadpool(reports.slot,uid(request),ident,host)
index=await run_in_threadpool(reports.index,uid(request),ident)
job=await run_in_threadpool(flow.job,uid(request),ident)
return render(request,'pages/report.html',title=item['title'],nav='jobs',job=job,item=item,reports=index,
view=presentation(item,field=field,page=page),json_url='/api/v2/runs/'+ident+'/report?'+urlencode({'host':item['slot']}))
@app.get('/api/v2/runs/{ident}/report')
async def report_json(request: Request,ident: str,host: str|None=None):
item=await run_in_threadpool(reports.slot,uid(request),ident,host)
return JSONResponse({k:item[k] for k in ('schema_id','scope','slot','status','error','retention','data',
'recorded_at','check_mode','complete')},headers={'Cache-Control':'no-store'})
@@ -0,0 +1,130 @@
"""Inventory presentation using only the documented Core read operations.
The map is a deterministic focused hierarchy, not a network-topology or health
map. Path tuples are identities; repeated host membership does not duplicate
machine counts. No inventory is persisted and no execution endpoint is called.
"""
from __future__ import annotations
import json
import time
from urllib.parse import urlencode
from aim_webgui.adapters.core_v1 import CoreAdapter
from aim_webgui.activity import explorer_url, host_url, text
from aim_webgui.errors import WebError
class Explorer:
def __init__(self, settings):
self.core = CoreAdapter(settings)
def snapshot(self, customer: str) -> dict:
text(customer)
hierarchy = self.core.inventory_hierarchy(customer)
# Reuse the same request-local client/capabilities; list_hosts is public.
metadata = self.core.client.request('list_hosts', customer=customer)
hosts = {x['name']: {'name': x['name'], 'address': x.get('address'),
'platforms': list(x.get('platforms', [])), 'memberships': [],
'url': host_url(customer, x['name'])} for x in metadata}
groups = {}; occurrences = 0
def build(node, depth):
nonlocal occurrences
if depth > 64 or len(groups) >= 10000:
raise WebError('core_protocol', 'Inventory hierarchy exceeds the documented bounds.', 502)
path = tuple(node['path'])
if path in groups: raise WebError('core_protocol', 'Core returned duplicate group paths.', 502)
group = {'name': node['name'], 'path': list(path), 'direct': sorted(set(node['hosts']), key=str.casefold), 'children': []}
groups[path] = group
group['children'] = [build(x, depth+1) for x in node['children']]
all_hosts = set(group['direct'])
for child in group['children']: all_hosts.update(child['members'])
group['members'] = all_hosts
group['url'] = explorer_url(customer, branch=json.dumps(list(path), separators=(',', ':')))
for host in group['direct']:
if host not in hosts: raise WebError('inventory_changed', 'Inventory changed during discovery. Refresh to fetch a consistent view.', 409)
hosts[host]['memberships'].append({'path': list(path), 'label': ' / '.join(path), 'url': group['url']})
occurrences += 1
return group
roots = [build(x, 1) for x in hierarchy['groups']]
direct = sorted(set(hierarchy['hosts']), key=str.casefold)
for h in direct:
if h not in hosts: raise WebError('inventory_changed', 'Inventory changed during discovery. Refresh to fetch a consistent view.', 409)
hosts[h]['memberships'].append({'path': [], 'label': 'Customer root', 'url': explorer_url(customer)})
members = set(direct)
for root in roots: members.update(root['members'])
if set(hosts) != members:
raise WebError('inventory_changed', 'Host metadata and hierarchy changed during discovery. Refresh to try again.', 409)
root = {'name': customer, 'path': [], 'direct': direct, 'children': roots, 'members': members, 'url': explorer_url(customer)}
return dict(customer=customer, root=root, groups=groups, hosts=hosts, host_count=len(members),
group_count=len(groups), memberships=occurrences+len(direct), fetched_at=int(time.time()))
def page(self, customer, *, branch='', q='', view='auto', gpage=1, hpage=1):
text(q, 200); text(branch, 16000)
if view not in {'auto', 'map', 'outline'} or not 1 <= gpage <= 100000 or not 1 <= hpage <= 100000:
raise WebError('invalid_filter', 'Choose an available inventory view and page.')
try:
path = json.loads(branch) if branch else []
if not isinstance(path, list) or len(path)>64 or any(not isinstance(p,str) for p in path): raise ValueError()
except (ValueError, TypeError):
raise WebError('invalid_group', 'Choose a group from the current inventory.') from None
snap = self.snapshot(customer)
selected = snap['groups'].get(tuple(path)) if path else snap['root']
if selected is None: raise WebError('group_not_found', 'This group is no longer in the current inventory.', 404)
def url(**updates):
values = dict(branch=branch, q=q, view=view, gpage=gpage, hpage=hpage)
values.update(updates)
return explorer_url(customer, **{k: v for k,v in values.items() if v != ''})
children = sorted(selected['children'],key=lambda n:n['name'].casefold())
direct = selected['direct']
search = []
if q:
needle=q.casefold()
for group in snap['groups'].values():
if needle in ' / '.join(group['path']).casefold():
search.append({'kind':'Group','name':' / '.join(group['path']),'url':group['url'], 'detail':str(len(group['members']))+' distinct hosts'})
for host in snap['hosts'].values():
if needle in (host['name']+' '+(host['address'] or '')).casefold():
search.append({'kind':'Host','name':host['name'],'url':host['url'], 'detail':', '.join(host['platforms'])})
displayed_groups = children[(gpage-1)*12:gpage*12]
displayed_hosts = [snap['hosts'][h] for h in direct[(hpage-1)*24:hpage*24]]
# SVG presentation: one focus node and bounded group lanes, each with direct-host links.
nodes=[];edges=[];width=1040
nodes.append(dict(kind='focus', x=20,y=24,w=1000,h=70,label=selected['name'],
detail=f"{len(selected['members'])} distinct hosts / {len(selected['children'])} subgroups",
url=selected['url']))
y=132
for g in displayed_groups:
nodes.append(dict(kind='group',x=28,y=y,w=290,h=72,label=g['name'],
detail=f"{len(g['direct'])} direct / {len(g['members'])} total",url=g['url']))
edges.append((170,94,170,y))
preview=g['direct'][:3]
for i,h in enumerate(preview):
nodes.append(dict(kind='host',x=354+i*226,y=y+5,w=212,h=62,label=h,
detail=', '.join(snap['hosts'][h]['platforms']) or 'Host',url=host_url(customer,h)))
edges.append((318,y+36,354+i*226,y+36))
if not preview:
nodes.append(dict(kind='note',x=354,y=y+5,w=658,h=62,label='Open group to explore its subgroups' if g['children'] else 'Empty group',
detail='Membership only; no live health checks',url=g['url']))
y+=102
if displayed_hosts:
y+=22
for i,h in enumerate(displayed_hosts):
row,col=divmod(i,3);x=28+col*336;yy=y+row*94
nodes.append(dict(kind='host',x=x,y=yy,w=318,h=68,label=h['name'],detail=', '.join(h['platforms']) or 'Direct member',url=h['url']))
edges.append((170,94,x+159,yy))
y+=((len(displayed_hosts)+2)//3)*94
crumbs=[{'name':customer,'url':explorer_url(customer)}]
for i,p in enumerate(path):
crumbs.append({'name':p,'url':explorer_url(customer,branch=json.dumps(path[:i+1],separators=(',',':')))})
return dict(customer=customer, snap=snap, selected=selected, crumbs=crumbs, q=q, view=view,
groups=displayed_groups, direct_hosts=displayed_hosts, graph_nodes=nodes, graph_edges=edges,
graph_width=width, graph_height=max(y+20,310), branch=branch,
group_pages=max(1,(len(children)+11)//12), host_pages=max(1,(len(direct)+23)//24),
gpage=gpage,hpage=hpage, map_url=url(view='map'),outline_url=url(view='outline'),
groups_prev=url(gpage=gpage-1) if gpage>1 else None,
groups_next=url(gpage=gpage+1) if gpage*12<len(children) else None,
hosts_prev=url(hpage=hpage-1) if hpage>1 else None,
hosts_next=url(hpage=hpage+1) if hpage*24<len(direct) else None,
search=search[(hpage-1)*50:hpage*50],search_count=len(search),
search_next=url(hpage=hpage+1) if hpage*50<len(search) else None,
search_prev=url(hpage=hpage-1) if hpage>1 else None)
@@ -0,0 +1,235 @@
"""Bounded durable PUBLIC progress metadata, owned only by retained WebGUI jobs.
A bounded nonblocking queue isolates Core's event sink from DB/viewer latency.
No rendered console, final report payload, secret frame or arbitrary event keys
are stored. Readers use a committed per-job cursor, never a live-process socket.
"""
from __future__ import annotations
from collections import deque
from datetime import datetime
import json
import queue
import re
import sqlite3
import threading
import time
from aim_webgui.core.protocol import safe_event
from aim_webgui.db.store import Store
from aim_webgui.errors import WebError
from aim_webgui.workflows import actor, TERMINAL
KINDS=frozenset({'stage','play_started','play_skipped','play_stopped','task_started',
'host_result','task_retry','task_async_poll','host_recap','stats','result'})
QUEUE_EVENTS=2048
BATCH_EVENTS=128
FLUSH_SECONDS=.25
MAX_EVENT_BYTES=16*1024
MAX_CHECKPOINT_TASKS=64
def dumps(value):
return json.dumps(value,ensure_ascii=False,allow_nan=False,separators=(',',':'))
def authorize(db,user_id,job_id):
user=actor(db,user_id)
row=db.execute('SELECT id,owner_id,status,plan,created_at,finished_at,reason FROM jobs WHERE id=?',(job_id,)).fetchone()
if not row or row['owner_id']!=user_id and user['role']!='admin':
raise WebError('job_not_found','Job not found for this account.',404)
return row
def project(value,targets):
event=safe_event(value)
if event is None or event['kind']not in KINDS:return None
run=event.get('run_id');stamp=event.get('timestamp')
if not isinstance(run,str) or not re.fullmatch(r'[A-Za-z0-9_-]{1,100}',run):
raise ValueError('Invalid public run identity.')
if not isinstance(stamp,str) or len(stamp)>48:raise ValueError('Invalid event time.')
date=datetime.fromisoformat(stamp.replace('Z','+00:00'))
if date.tzinfo is None:raise ValueError('Event time requires a timezone.')
# The projected event may contain a withheld/null host. Never replace it by
# a nearby visible host/task. A public host must belong to the reviewed scope.
if event.get('host') is not None and event['host'] not in targets:
raise ValueError('Event host is outside the reviewed scope.')
body=dumps(event)
if len(body.encode())>MAX_EVENT_BYTES:raise ValueError('Progress event limit.')
return event,body
def checkpoint_update(checkpoint,event,received):
checkpoint['last_timestamp']=event['timestamp'];checkpoint['last_received_at']=received
kind=event['kind'];checkpoint['last_kind']=kind
if kind=='stage':checkpoint['stage']=event['stage']
if kind=='play_started':checkpoint['last_play']={k:event[k] for k in ('play_id','label','label_redacted')}
if kind=='task_started':
checkpoint['observed_task_starts']=checkpoint.get('observed_task_starts',0)+1
tasks=checkpoint.setdefault('tasks',{})
tasks[event['task_id']]={k:event[k] for k in ('task_id','play_id','label','label_redacted','handler')}
tasks[event['task_id']]['timestamp']=event['timestamp']
while len(tasks)>MAX_CHECKPOINT_TASKS:del tasks[next(iter(tasks))]
checkpoint['last_task']=tasks[event['task_id']]
if kind in ('host_result','task_retry','task_async_poll') and event.get('host'):
hosts=checkpoint.setdefault('hosts',{})
hosts[event['host']]={k:event[k] for k in ('host','task_id','play_id','timestamp')}
hosts[event['host']]['observation']=event.get('status',kind)
hosts[event['host']]['error_code']=(event.get('error')or{}).get('code')
while len(hosts)>500:del hosts[next(iter(hosts))]
if kind=='stats':checkpoint['counts']=event['counts']
# Final event is observed, NOT authoritative until the response is persisted.
if kind=='result':checkpoint['result_event_observed']=True
return checkpoint
class Journal:
def __init__(self,settings):
self.settings=settings;self.store=Store(settings.database)
def begin(self,job_id):
with self.store.transaction() as db:
db.execute('INSERT INTO job_progress_state(job_id,updated_at) VALUES(?,?) ON CONFLICT(job_id) DO NOTHING',(job_id,time.time()))
def append(self,job_id,batch,*,dropped=0,closed=False,timeout=.25):
"""Atomic metadata/checkpoint/cursor commit; idempotent public sequences."""
with self.store.transaction(timeout=timeout) as db:
row=db.execute('SELECT * FROM job_progress_state WHERE job_id=?',(job_id,)).fetchone()
if row is None:return # Deletion/absence cannot recreate a shadow journal.
state=dict(row);cp=json.loads(state['checkpoint']);run=state['run_id']
lost=max(state['dropped_events'],dropped)
for event,body,received in batch:
if run is not None and event['run_id']!=run:
lost+=1;continue
run=event['run_id']
if event['sequence']<=state['last_sequence']:continue
state['last_sequence']=event['sequence'];state['cursor']+=1
size=len(body.encode('utf-8'))
db.execute('INSERT INTO job_progress_events VALUES(?,?,?,?,?,?,?)',
(job_id,state['cursor'],run,event['sequence'],received,body,size))
state['retained_events']+=1;state['retained_bytes']+=size
checkpoint_update(cp,event,received)
remove=[]
if state['retained_events']>self.settings.journal_max_events or state['retained_bytes']>self.settings.journal_max_bytes:
for old in db.execute('SELECT cursor,size_bytes FROM job_progress_events WHERE job_id=? ORDER BY cursor',(job_id,)):
if state['retained_events']<=self.settings.journal_max_events and state['retained_bytes']<=self.settings.journal_max_bytes:break
state['retained_events']-=1;state['retained_bytes']-=old['size_bytes'];state['omitted_events']+=1;remove.append(old['cursor'])
if remove:db.execute('DELETE FROM job_progress_events WHERE job_id=? AND cursor<=?',(job_id,remove[-1]))
now=time.time();capture='closed' if closed else 'recording'
if lost:capture='closed_with_gaps' if closed else 'recording_with_gaps'
db.execute('''UPDATE job_progress_state SET run_id=?,cursor=?,last_sequence=?,retained_events=?,retained_bytes=?,
omitted_events=?,dropped_events=?,checkpoint=?,updated_at=?,capture_state=?,closed_at=? WHERE job_id=?''',
(run,state['cursor'],state['last_sequence'],state['retained_events'],state['retained_bytes'],
state['omitted_events'],lost,dumps(cp),now,capture,now if closed else None,job_id))
def snapshot(self,user_id,job_id,*,after=None,before=None,limit=200):
if type(limit)is not int or not 1<=limit<=500:raise WebError('invalid_cursor','Progress page size must be 1..500.')
for number in (after,before):
if number is not None and (type(number)is not int or not 0<=number<=2**53-1):raise WebError('invalid_cursor','Invalid progress cursor.')
if after is not None and before is not None:raise WebError('invalid_cursor','Choose before or after, not both.')
with self.store.read() as db:
db.execute('BEGIN')
job=authorize(db,user_id,job_id)
row=db.execute('SELECT * FROM job_progress_state WHERE job_id=?',(job_id,)).fetchone()
terminal=job['status']in TERMINAL
if row is None:
return {'job':job_id,'job_status':job['status'],'terminal':terminal,'available':False,'cursor':0,'next_cursor':0,
'first_cursor':0,'events':[],'has_older':False,'has_more':False,'checkpoint':{},'capture_state':'unavailable',
'omitted_events':0,'dropped_events':0,'capture_interrupted':False,
'message':'Detailed history was not captured for this job.' if terminal else 'Waiting for the execution worker to begin metadata capture.'}
state=dict(row);watermark=state['cursor']
if after is not None and after>watermark:raise WebError('invalid_cursor','Progress cursor is ahead of this job.',409)
first=db.execute('SELECT MIN(cursor) FROM job_progress_events WHERE job_id=?',(job_id,)).fetchone()[0]or 0
params=[job_id]
if after is not None:
clause=' AND cursor>?';params.append(after);order='ASC'
elif before is not None:
clause=' AND cursor<?';params.append(before);order='DESC'
else:clause='';order='DESC'
rows=list(db.execute('SELECT cursor,received_at,payload FROM job_progress_events WHERE job_id=?'+clause+' ORDER BY cursor '+order+' LIMIT ?',(*params,limit)))
if order=='DESC':rows.reverse()
events=[{'cursor':r['cursor'],'received_at':r['received_at'],'event':json.loads(r['payload'])} for r in rows]
for item in events:item['text']=format_event(item['event'])
next_cursor=events[-1]['cursor'] if events else after if after is not None else watermark
cp=json.loads(state['checkpoint'])
cp['tasks']=list(cp.get('tasks',{}).values())
cp['hosts']=list(cp.get('hosts',{}).values())
return {'job':job_id,'job_status':job['status'],'terminal':terminal,'available':True,
'cursor':watermark,'next_cursor':next_cursor,'first_cursor':first,'events':events,'checkpoint':cp,
'has_more':next_cursor<watermark,'has_older':bool(events and events[0]['cursor']>first),
'capture_state':state['capture_state'],'omitted_events':state['omitted_events'],
'dropped_events':state['dropped_events'],'capture_interrupted':terminal and state['closed_at']is None,
'retained_events':state['retained_events'],'retained_bytes':state['retained_bytes'],
'updated_at':state['updated_at'],'gap_before':bool(after is not None and first and after<first-1)}
class Capture:
"""Worker-only writer. Queue drops are explicit, never a reason to replay work."""
def __init__(self,settings,job_id,targets):
self.journal=Journal(settings);self.job_id=job_id;self.targets=frozenset(targets)
self.items=queue.Queue(maxsize=QUEUE_EVENTS);self.stopping=threading.Event()
self.dropped=0;self.closed=False;self.loss_lock=threading.Lock();self.flush_deadline=None
self.journal.begin(job_id)
self.thread=threading.Thread(target=self._write,name='aim-progress-journal',daemon=True);self.thread.start()
def lost(self,count):
with self.loss_lock:self.dropped+=count
def loss_count(self):
with self.loss_lock:return self.dropped
def submit(self,value):
if self.closed:return
try:
pair=project(value,self.targets)
if pair is None:return
self.items.put_nowait((*pair,time.time()))
except (WebError,ValueError,TypeError,UnicodeError,queue.Full):self.lost(1)
def _write(self):
while not self.stopping.is_set() or not self.items.empty():
batch=[];end=time.monotonic()+FLUSH_SECONDS
while len(batch)<BATCH_EVENTS:
try:batch.append(self.items.get(timeout=max(.001,end-time.monotonic())))
except queue.Empty:break
if time.monotonic()>=end:break
if self.flush_deadline is not None and time.monotonic()>=self.flush_deadline:
self.lost(len(batch))
while True:
try:self.items.get_nowait();self.lost(1)
except queue.Empty:break
break
if not batch:continue
try:self.journal.append(self.job_id,batch,dropped=self.loss_count())
except (sqlite3.Error,OSError,ValueError):
self.lost(len(batch))
# No exception bodies or payloads are logged. Next commit records
# loss. A persistent storage failure leaves capture unconfirmed.
try:self.journal.append(self.job_id,[],dropped=self.loss_count(),closed=True)
except (sqlite3.Error,OSError,ValueError):pass
def close(self):
if self.closed:return not self.thread.is_alive()
self.closed=True;self.flush_deadline=time.monotonic()+2.5
self.stopping.set();self.thread.join(timeout=3)
# A killed writer may lose its uncommitted batch. A missing durable
# closed_at is rendered as interrupted capture, not complete history.
return not self.thread.is_alive()
def format_event(e):
kind=e['kind'];host=e.get('host')or'<host withheld>';task=e.get('task_id','')
if kind=='stage':return '[AIM] Core stage: '+e['stage']
if kind=='play_started':return 'PLAY ['+e['label']+']'
if kind=='play_skipped':return 'skipping: no hosts matched'
if kind=='play_stopped':return 'stopped: no hosts remaining'
if kind=='task_started':return ('RUNNING HANDLER' if e['handler'] else 'TASK')+' ['+e['label']+'] ('+task+')'
if kind=='host_result':
status=e['status'];line=(f'fatal: [{host}]: '+status.upper()+'!' if status in ('failed','unreachable') else f'{status}: [{host}]')
line+=' ('+task+')'+(' (ignored)' if e['ignored'] else '')
if e.get('error'):line+='\n '+e['error']['message']
return line
if kind in ('task_retry','task_async_poll'):
return ('retrying' if kind=='task_retry' else 'async poll')+f': [{host}] ({task})'+(f" attempt={e['attempt']}" if e['attempt']is not None else '')
if kind=='host_recap':return f'RECAP [{host}] '+' '.join(f'{k}={v}' for k,v in e['counts'].items())
if kind=='stats':return 'PLAY RECAP\n[AIM] Counters: '+', '.join(f'{k}={v}' for k,v in sorted(e['counts'].items()))
if kind=='result':return '[AIM] Final result event observed. Authoritative response is recorded separately.'
return ''
@@ -0,0 +1,10 @@
"""Plan titles: presentation only, never record identifiers or upsert keys."""
import unicodedata
import uuid
from datetime import datetime, timezone
def name_key(name):
return unicodedata.normalize('NFKC',name.strip()).casefold()
def suggested_name(playbook):
return f"{playbook[:48]} - {datetime.now(timezone.utc):%Y%m%d-%H%M%S} - {uuid.uuid4().hex[:10]}"
@@ -0,0 +1,90 @@
"""Read-only presentation of Core's patch_summary_v1, including historic shapes.
This module never plans updates, interprets HRESULTs, changes a job verdict, or
starts a continuation. Values come only from a retained, schema-validated report.
"""
from __future__ import annotations
from typing import Any
BLOCK_NOTICES = {
'preexisting_reboot_required': (
'Pending reboot observed before patching',
'Core reports a pre-existing reboot prerequisite. In this preflight path, new '
'patch work did not start. Review the reboot policy before preparing another run.'),
'cycle_limit_reached': (
'Post-reboot continuation limit reached',
'Core stopped at its bounded continuation limit. Review the recorded results '
'before deciding whether to prepare another run.'),
'install_not_allowed': (
'Windows Update did not permit installation',
'This classification can indicate an active installer or a mandatory reboot. '
'It does not by itself prove a pending reboot; use the separate preflight observation.'),
}
def patch_view(item: dict[str, Any]) -> dict[str, Any] | None:
"""Explain independent patch/reboot/report facts without manufacturing state."""
payload = item.get('data')
if (item.get('schema_id') != 'patch_summary_v1'
or item.get('status') != 'available' or not isinstance(payload, dict)):
return None
windows = payload.get('platform') == 'windows'
check = item.get('check_mode') is True
notices: list[dict[str, str]] = []
blocked = payload.get('blocked_reason')
if blocked:
title, text = BLOCK_NOTICES.get(blocked, (
'A reported condition stopped this patch run',
'Inspect the bounded failure records and original Core verdict. '
'No failed update or job will be submitted again automatically.'))
notices.append({'tone': 'warning', 'title': title, 'text': text, 'code': blocked})
reasons=payload.get('reboot_reasons_before')
if windows and isinstance(reasons,list) and reasons:
notices.append({'tone':'secondary','title':'Native reboot prerequisite details','code':'',
'text':'Core recorded one or more reboot sources from ansible.windows.win_reboot_info before patching. '
'These are bounded observations from that run, not a live reboot-state probe.'})
if payload.get('reboot_deferred') is True:
notices.append({'tone': 'warning', 'title': 'Reboot deferred', 'code': '',
'text': 'The report records a remaining reboot requirement with automatic reboot '
'disabled. This is separate from whether the updates in this run succeeded. '
'Review an explicit reboot decision before continuing patching.'})
if windows and payload.get('continuation_required') is True:
notices.append({'tone': 'info', 'title': 'Another patch run needs review', 'code': '',
'text': 'Core reports that further patching needs a fresh operator decision. '
'A successful wave can still need a later run; this does not change '
'the Core verdict or create another job.'})
if windows and 'remaining_updates_known' not in payload:
notices.append({'tone': 'secondary', 'title': 'Historical patch-report format', 'code': '',
'text': 'This recorded report does not supply the newer remaining-update knowledge '
'flag. Missing wave or reboot fields are not assumed false or reconstructed.'})
if check:
notices.insert(0, {'tone': 'info', 'title': 'Check mode: no installation claim', 'code': '',
'text': 'These are recorded check-mode observations or predictions. They are not '
'evidence that updates were installed or that a reboot took place.'})
known = payload.get('remaining_updates_known')
if windows and known is False:
pending = {'title': 'Remaining updates not established', 'suppressed': True,
'note': 'Core did not establish an authoritative final remaining-update list for '
'this wave. No pending count or next-wave list is inferred, even if an '
'earlier queue appears in the retained JSON.',
'empty_text': 'Unknown after this wave. A new reviewed run owns the next discovery.'}
elif windows and known is True:
pending = {'title': 'Pending updates - final read-only discovery', 'suppressed': False,
'note': 'The pending list is authoritative for the final read-only search recorded '
'by Core, within the selected scope at that time. Discovery did not extend '
'the approved install queue; it is not a live compliance check.',
'empty_text': 'No pending updates reported by that final discovery in the selected scope.'}
elif windows:
pending = {'title': 'Pending updates - recorded legacy observation', 'suppressed': False,
'note': 'No explicit remaining-update knowledge flag exists in this older report. '
'Do not treat an empty list as proof that no later updates are applicable.',
'empty_text': 'No entries reported; later update applicability is not established.'}
else:
pending = {'title': 'Pending updates', 'suppressed': False,
'note': 'Use the report\'s evidence and mode. Linux package snapshots describe net '
'observed version-set changes, not every intermediate transaction.',
'empty_text': 'No entries reported.'}
return {'windows': windows, 'check_mode': check, 'notices': notices, 'pending': pending,
'failure_note': 'Reason, safe message and unsigned/hexadecimal native code are '
'supplied by Core. WebGUI does not parse fatal output or infer a reboot '
'requirement from an HRESULT. Missing older fields remain unknown.'}
@@ -0,0 +1,68 @@
"""Add-on-owned GET-only discovery/history endpoints. No run preparation or writes."""
from __future__ import annotations
from dataclasses import asdict
from urllib.parse import urlencode
from fastapi import Request
from starlette.concurrency import run_in_threadpool
from aim_webgui.activity import Activity, HistoryFilter, LABELS, host_url
from aim_webgui.explorer import Explorer
from aim_webgui.errors import WebError
from aim_webgui.reports import Reports
def install_read_views(app, settings, render):
activity = Activity(settings)
async def report(request, customer='', host='', playbook='', mode='apply', days='30', outcome='', q='', page=1):
return await run_in_threadpool(activity.report, request.state.session['user_id'],
HistoryFilter(customer,host,playbook,mode,days,outcome,q,page))
@app.get('/inventory/{customer}/explore')
async def explore(request: Request, customer: str, branch: str='', q: str='', view: str='auto', gpage: int=1, hpage: int=1):
model = await run_in_threadpool(Explorer(settings).page,customer,branch=branch,q=q,view=view,gpage=gpage,hpage=hpage)
return render(request,'pages/explorer.html',title='Inventory explorer',nav='customers',**model)
@app.get('/api/v2/inventory/{customer}/explore')
async def explore_api(request: Request, customer: str, branch: str='', gpage: int=1, hpage: int=1):
model = await run_in_threadpool(Explorer(settings).page,customer,branch=branch,gpage=gpage,hpage=hpage)
return {'source':'core_inventory_hierarchy_v1','customer':customer,'branch':model['selected']['path'],
'retrieved_at':model['snap']['fetched_at'],'host_count':model['snap']['host_count'],
'group_count':model['snap']['group_count'],'nodes':model['graph_nodes'],'edges':model['graph_edges'],
'width':model['graph_width'],'height':model['graph_height'],
'group_pages':model['group_pages'],'host_pages':model['host_pages'],
'gpage':gpage,'hpage':hpage}
@app.get('/inventory/{customer}/activity')
async def host_activity(request: Request, customer: str, host: str, playbook: str='', mode: str='apply', days: str='30', outcome: str='', page: int=1):
data = await report(request,customer,host,playbook,mode,days,outcome,page=page)
current = None; inventory_error = False; fetched_at = None
try:
snapshot = await run_in_threadpool(Explorer(settings).snapshot,customer)
current=snapshot['hosts'].get(host); fetched_at=snapshot['fetched_at']
except WebError:
# A historical page remains useful during a Core outage. Do not fake an empty inventory.
inventory_error=True
prev = host_url(customer,host,**{k:v for k,v in data['filters'].query(page=page-1).items() if k not in {'host','customer'}}) if page>1 else None
nxt = host_url(customer,host,**{k:v for k,v in data['filters'].query(page=page+1).items() if k not in {'host','customer'}}) if page<data['pages'] else None
links=await run_in_threadpool(Reports(settings).host_links,request.state.session['user_id'],customer,host)
return render(request,'pages/activity.html',report_links=links,title=host,nav='customers',customer=customer,host=host,
current=current,inventory_error=inventory_error,fetched_at=fetched_at,data=data,
previous_url=prev,next_url=nxt,outcome_labels=LABELS)
@app.get('/api/v2/activity')
async def activity_api(request: Request, customer: str, host: str, playbook: str='', mode: str='apply', days: str='30', outcome: str='', page: int=1):
data=await report(request,customer,host,playbook,mode,days,outcome,page=page)
return {**data,'filters':asdict(data['filters'])}
@app.get('/insights')
async def insights(request: Request, customer: str='', playbook: str='', mode: str='apply', days: str='30', outcome: str='', q: str='', page: int=1):
data=await report(request,customer,'',playbook,mode,days,outcome,q,page)
previous='/insights?'+urlencode(data['filters'].query(page=page-1)) if page>1 else None
next_url='/insights?'+urlencode(data['filters'].query(page=page+1)) if page<data['matrix_pages'] else None
return render(request,'pages/insights.html',title='Playbook insights',nav='insights',data=data,
customer=customer,previous_url=previous,next_url=next_url,outcome_labels=LABELS)
@app.get('/api/v2/insights')
async def insights_api(request: Request, customer: str='', playbook: str='', mode: str='apply', days: str='30', outcome: str='', q: str='', page: int=1):
data=await report(request,customer,'',playbook,mode,days,outcome,q,page)
return {**data,'filters':asdict(data['filters'])}
@@ -0,0 +1,197 @@
"""Job-owned report persistence/read models. No Core calls, files or raw output.
Reports have availability, retention and execution outcomes as separate facts.
Large payloads are separate from jobs.core_result and are fetched one slot at a time.
"""
from __future__ import annotations
import json
import time
from urllib.parse import urlencode
from aim_webgui.core.reports import contract, operation_result, encoded
from aim_webgui.db.store import Store
from aim_webgui.errors import WebError
from aim_webgui.journal import authorize
from aim_webgui.patch_view import patch_view
TITLES={
'host_capabilities_v1':'Detected host roles', 'filesystem_usage_v1':'Filesystem usage',
'event_log_export_v1':'Event log export', 'service_start_summary_v1':'Service recovery',
'patch_summary_v1':'Operating-system patch report', 'managed_cleanup_preview_v1':'Managed cleanup',
'checkmk_user_config_v1':'Checkmk user configuration', 'checkmk_agent_state_v1':'Checkmk agent state',
'checkmk_agent_config_v1':'Checkmk configuration changes'}
NOTES={
'host_capabilities_v1':'Recorded detection facts, not current inventory membership or live health.',
'filesystem_usage_v1':'Observed byte quantities. Windows reports attached local storage volumes; mapped/network drives are intentionally excluded. Unavailable is not zero.',
'event_log_export_v1':'Paths refer to files on the managed target, not downloadable controller artifacts. Check mode creates no export.',
'service_start_summary_v1':'Excluded services are not failed starts. Observed newly running services may include concurrent external starts.',
'patch_summary_v1':'Linux rows describe net observed package/version changes, not every transaction. Pending Windows updates are not installed. Evidence completeness is separate from report availability.',
'managed_cleanup_preview_v1':'Preview candidates are not deletions. Mode and completion indicators remain explicit.',
'checkmk_user_config_v1':'Parsed sections are operational configuration data, not guaranteed secret-free. Redacted values are not absent settings. Paths are display-only.',
'checkmk_agent_state_v1':'Installed versions are observations from the registry/package database. Null means unknown; check mode is not evidence of installation.',
'checkmk_agent_config_v1':'Named semantic changes, not raw before/after diffs. Unknown files remain untouched and unenumerated.'}
LABELS={'is_dc':'Domain controller','is_dhcp_server':'DHCP server','is_hyperv_host':'Hyper-V host',
'has_veeam_vbr':'Veeam Backup & Replication','has_veeam_vbo':'Veeam Microsoft 365 backup',
'has_veeam_em':'Veeam Enterprise Manager','is_unifi_controller':'UniFi controller','is_unifi_os_server':'UniFi OS server',
'complete':'Evidence complete','required':'Required report','used_bytes':'Used bytes','total_bytes':'Total bytes',
'available_bytes':'Available bytes','used_percent':'Used percent','failed_to_start':'Failed to start',
'before_count':'Initially stopped count','after_observed':'After state observed','newly_running':'Observed newly running',
'started_count':'Attempted services observed running','changed':'Changes reported','last_write_time_utc':'Source last-write time (UTC)',
'reboot_required':'Final reboot requirement (legacy field)',
'reboot_required_before':'Reboot required before this run',
'reboot_reasons_before':'Native reboot reasons observed before this run',
'reboot_required_after':'Reboot required after this run',
'reboot_performed':'AIM-performed reboot', 'reboot_deferred':'Reboot deferred',
'reboot_delay_minutes':'Reviewed reboot delay (minutes)',
'rescan_after_reboot':'Reviewed post-reboot continuation option',
'patch_cycles':'Windows discovery/install cycles entered',
'continuation_required':'Further patching needs review',
'remaining_updates_known':'Final remaining-update list established',
'blocked_reason':'Core-reported stop reason',
'native_code':'Native code (unsigned)', 'native_code_hex':'Native code (hex)',
'failed_updates':'Failed Windows updates', 'message':'Core diagnostic message'}
CONFIG_METADATA=('path','exists','size_bytes','last_write_time_utc','redacted_paths','comment_preservation')
def label(key):return LABELS.get(key,key.replace('_',' ').capitalize())
def url(job_id,slot=''):
return '/jobs/'+job_id+'/reports'+('?' + urlencode({'host':slot}) if slot else '')
def persist(db,settings,job_id,plan,result):
"""One authoritative response transaction. Never persist the result event body."""
decl=contract(plan.get('result_contract'))
report=operation_result(result.get('operation_result'),decl,targets=plan['targets'],check=plan['core_request']['check'])
now=time.time();policy='full_configuration' if settings.reports_retain_configuration else 'configuration_metadata_only'
existing=db.execute('SELECT 1 FROM job_operation_results WHERE job_id=?',(job_id,)).fetchone()
if existing:raise WebError('result_already_recorded','This job already has an authoritative report record. No replay was made.',409)
db.execute('''INSERT INTO job_operation_results(job_id,contract,protocol,schema_id,scope,required,complete,check_mode,stored_at,retention_policy)
VALUES(?,?,?,?,?,?,?,?,?,?)''',
(job_id,encoded(decl).decode(),report['protocol'] if report else None,report['schema'] if report else None,
report['scope'] if report else None,report['required'] if report else None,report['complete'] if report else None,
plan['core_request']['check'],now,policy))
summary=None
if report:
summary={k:v for k,v in report.items() if k not in ('hosts','global')};summary['hosts']={};summary['global']=None
slots=report['hosts'] if report['scope']=='per_host' else {'':report['global']}
retained=0
for slot,entry in slots.items():
content=entry['data'];retention='unavailable';text=None;size=0
if entry['status']=='available':
retention='retained'
if report['schema']=='checkmk_user_config_v1' and not settings.reports_retain_configuration:
content={key:content[key] for key in CONFIG_METADATA};retention='metadata_only'
raw=encoded(content);size=len(raw)
if retained+size>settings.reports_max_bytes:
retention='not_retained_limit';size=0
else:text=raw.decode();retained+=size
db.execute('INSERT INTO job_operation_reports VALUES(?,?,?,?,?,?,?,?)',
(job_id,slot,entry['schema'],entry['status'],entry['error'],retention,text,size))
item={'schema':entry['schema'],'status':entry['status'],'error':entry['error'],'retention':retention,'size_bytes':size}
if slot:summary['hosts'][slot]=item
else:summary['global']=item
# Preserve the public accounting, not megabytes of report bodies, in job polls.
small={**result,'operation_result':summary}
return small
class Reports:
def __init__(self,settings):self.settings=settings;self.store=Store(settings.database)
def index(self,user_id,job_id):
with self.store.read() as db:
db.execute('BEGIN');job=authorize(db,user_id,job_id)
plan=json.loads(job['plan']);row=db.execute('SELECT * FROM job_operation_results WHERE job_id=?',(job_id,)).fetchone()
slots=[dict(r) for r in db.execute('SELECT slot,schema_id,status,error,retention,size_bytes FROM job_operation_reports WHERE job_id=? ORDER BY slot',(job_id,))]
declared=plan.get('result_contract')
model={'job':job_id,'job_status':job['status'],'customer':plan.get('customer'),'playbook':plan.get('playbook'),
'mode':'check' if plan.get('core_request',{}).get('check') else 'apply','recorded':row is not None,
'declared':declared is not None,'schema':declared.get('schema') if declared else None,
'slots':slots,'recorded_at':row['stored_at'] if row else None}
if row:
model.update(schema=row['schema_id'],declared=row['schema_id']is not None,complete=bool(row['complete']),scope=row['scope'],
required=bool(row['required']),retention_policy=row['retention_policy'])
model['title']=TITLES.get(model['schema'],'Operation report')
for item in slots:item['url']=url(job_id,item['slot'])
return model
def slot(self,user_id,job_id,host=None):
with self.store.read() as db:
db.execute('BEGIN');job=authorize(db,user_id,job_id)
meta=db.execute('SELECT * FROM job_operation_results WHERE job_id=?',(job_id,)).fetchone()
if not meta or not meta['schema_id']:raise WebError('report_unavailable','No retained report is available for this job.',404)
if host is None:
row=db.execute("SELECT * FROM job_operation_reports WHERE job_id=? ORDER BY CASE status WHEN 'available' THEN 0 ELSE 1 END,slot LIMIT 1",(job_id,)).fetchone()
else:row=db.execute('SELECT * FROM job_operation_reports WHERE job_id=? AND slot=?',(job_id,host)).fetchone()
if not row:raise WebError('report_unavailable','This report slot is unavailable.',404)
item=dict(row);item['data']=json.loads(item['data']) if item['data']is not None else None
item['contract']=json.loads(meta['contract']);item['recorded_at']=meta['stored_at'];item['scope']=meta['scope']
item['complete']=bool(meta['complete']);item['check_mode']=bool(meta['check_mode']);item['required']=bool(meta['required'])
item['title']=TITLES.get(item['schema_id'],'Operation report');item['note']=NOTES.get(item['schema_id'],'Validated structured operation data; render as historical observation, not live state.')
item['url']=url(job_id,item['slot'])
return item
def host_links(self,user_id,customer,host,*,limit=20):
# Policy before SELECT/aggregation. Exact customer/logical hostname identity.
from aim_webgui.workflows import actor
with self.store.read() as db:
who=actor(db,user_id)
rows=db.execute('''SELECT r.job_id,r.schema_id,r.status,r.retention,m.stored_at,m.check_mode,
json_extract(j.plan,'$.playbook') AS playbook
FROM job_operation_reports r JOIN job_operation_results m ON m.job_id=r.job_id JOIN jobs j ON j.id=r.job_id
WHERE (?='admin' OR j.owner_id=?) AND json_extract(j.plan,'$.customer')=? AND r.slot=?
ORDER BY m.stored_at DESC,r.job_id DESC LIMIT ?''',(who['role'],user_id,customer,host,limit))
result=[dict(r) for r in rows]
for item in result:item['url']=url(item['job_id'],host);item['title']=TITLES.get(item['schema_id'],item['schema_id'])
return result
def presentation(item,*,field='',page=1):
"""Schema-keyed summaries plus generic bounded sections for every supported shape.
A page has at most 50 rows per collection; JSON is fetched separately on demand.
This never follows returned file paths/URLs or interprets strings as markup.
"""
if type(page)is not int or not 1<=page<=400:raise WebError('invalid_page','Invalid report page.')
content=item.get('data')
if not isinstance(content,dict):
return {'facts':[],'sections':[],'scalar':content,'is_scalar':True,'generic':True}
if field and field not in content:raise WebError('invalid_page','Unknown report section.')
patch=patch_view(item)
facts=[];sections=[]
for key,value in content.items():
if isinstance(value,list):
current=page if field==key else 1;start=(current-1)*50;selected=value[start:start+50]
headers=[]
if selected and all(isinstance(x,dict) for x in selected):
headers=list(dict.fromkeys(k for x in selected for k in x))[:40]
section={'key':key,'title':label(key),'rows':selected,'headers':headers,'total':len(value),
'page':current,'pages':max(1,(len(value)+49)//50),'start':start,'kind':'array'}
elif isinstance(value,dict):
current=page if field==key else 1;start=(current-1)*50;keys=list(value)[start:start+50]
section={'key':key,'title':label(key),'rows':[(k,value[k]) for k in keys], 'headers':[], 'total':len(value),
'page':current,'pages':max(1,(len(value)+49)//50),'start':start,'kind':'object'}
else:
facts.append({'key':key,'title':label(key),'value':value});continue
if patch and key=='pending':
section.update(patch['pending'])
if section['suppressed']:
section.update(rows=[],headers=[],total=None,page=1,pages=1)
current=1
elif patch and key=='failed_updates':
section['note']=patch['failure_note']
params={'host':item['slot'],'field':key}
section['previous']=('/jobs/'+item['job_id']+'/reports?'+urlencode({**params,'page':current-1})+'#report-'+key) if current>1 else None
section['next']=('/jobs/'+item['job_id']+'/reports?'+urlencode({**params,'page':current+1})+'#report-'+key) if current<section['pages'] else None
sections.append(section)
return {'facts':facts,'sections':sections,'scalar':None,'is_scalar':False,'generic':item['schema_id']not in TITLES,'patch':patch}
def cell(value):
if value is None:return 'Unknown / not supplied'
if type(value)is bool:return 'Yes' if value else 'No'
if isinstance(value,(dict,list)):
text=json.dumps(value,ensure_ascii=False,allow_nan=False)
return text if len(text)<=1200 else text[:1200]+' ... [display shortened; use retained JSON]'
return str(value)
@@ -0,0 +1,280 @@
from __future__ import annotations
from datetime import datetime, timezone
import asyncio
import json
import uuid
from fastapi import Request
from fastapi.responses import JSONResponse, StreamingResponse
from starlette.concurrency import run_in_threadpool
from aim_webgui.diagnostics import report
from aim_webgui.adapters.core_v1 import CoreAdapter
from aim_webgui.errors import WebError
from aim_webgui.workflows import Workflows
from aim_webgui.credentials.presentation import form_context, status as credential_status, attention
def install(app, settings, auth, render, form, one, redirect, administrator):
flow = Workflows(settings)
def uid(request):
return request.state.session['user_id']
async def call(method, *args, **kwargs):
return await run_in_threadpool(getattr(flow, method), *args, **kwargs)
def plan_payload(value):
if (not isinstance(value,dict) or set(value)-{'customer','playbook','targets','overrides','check','key_mode'}
or not {'customer','playbook','targets','overrides'}<=set(value)
or not isinstance(value['customer'],str) or not isinstance(value['playbook'],str)):
raise WebError('invalid_plan','Use customer, playbook, targets, overrides and optional check/key_mode.')
return value
async def api_payload(request):
if request.headers.get('content-type', '').split(';')[0] != 'application/json':
raise WebError('unsupported_content_type', 'Use application/json.', 415)
try:
value = await request.json()
except (ValueError, UnicodeError):
raise WebError('invalid_json', 'Invalid JSON.') from None
if not isinstance(value, dict):
raise WebError('invalid_request', 'Expected a JSON object.')
return value
@app.get('/setup')
async def setup(request: Request):
administrator(request)
return render(request, 'pages/setup.html', title='Named administrator setup', nav='setup',
setup=await call('onboarding', uid(request)))
@app.post('/setup')
async def setup_submit(request: Request):
administrator(request)
values = await form(request)
action = one(values, 'action')
if action == 'create':
if one(values, 'password') != one(values, 'confirm'):
raise WebError('password_mismatch', 'Temporary passwords do not match.')
await call('named_admin', uid(request), one(values, 'username'), one(values, 'password'))
elif action == 'retire':
await call('retire_bootstrap', uid(request))
else:
raise WebError('invalid_action', 'Choose create or retire.')
return redirect(request, '/setup')
@app.get('/system')
async def system(request: Request):
administrator(request)
return render(request, 'pages/system.html', title='System & diagnostics', nav='system',
system=await run_in_threadpool(report, settings))
@app.get('/api/v2/system')
async def api_system(request: Request):
administrator(request)
return await run_in_threadpool(report, settings)
@app.get('/audit')
async def audit_page(request: Request, before: int | None = None, action: str = ''):
administrator(request)
items = await call('audit', uid(request), before=before, action=action[:100])
return render(request, 'pages/audit.html', title='Audit history', nav='audit', items=items, action=action)
@app.get('/api/v2/audit')
async def api_audit(request: Request, before: int | None = None, action: str = ''):
administrator(request)
return {'items': await call('audit', uid(request), before=before, action=action[:100])}
@app.get('/_partials/grant-fields')
async def grant_fields(request: Request, user_id: int, customer: str):
administrator(request)
users = await run_in_threadpool(auth.users)
customers = await run_in_threadpool(CoreAdapter(settings).customers)
valid_users = {u['id'] for u in users}
valid_customers = {c['name'] for c in customers}
if user_id not in valid_users:
raise WebError('user_not_found', 'Account not found.', 404)
if customer not in valid_customers:
raise WebError('customer_not_found', 'Customer is unavailable.', 404)
playbooks = await run_in_threadpool(CoreAdapter(settings).playbooks, customer)
granted = set(await call('granted_playbooks', uid(request), user_id, customer))
available = [p for p in playbooks if p['key'] not in granted]
return render(request, 'partials/grant_fields.html', users=users, customers=customers,
selected_user_id=user_id, selected_customer=customer, available_playbooks=available)
@app.post('/permissions')
async def permissions(request: Request):
administrator(request)
values = await form(request)
try:
user_id = int(one(values, 'user_id'))
except ValueError:
raise WebError('invalid_user', 'Choose a user.') from None
action = one(values, 'action')
if action not in {'grant','revoke'}:
raise WebError('invalid_action', 'Choose grant or revoke.')
await call('grant', uid(request), user_id, one(values, 'customer'), one(values, 'playbook'), revoke=action == 'revoke')
return redirect(request, '/users')
@app.get('/plans')
async def plans(request: Request):
return render(request, 'pages/plans.html', title='Saved plans', nav='plans', items=await call('plans', uid(request)))
@app.post('/plans')
async def save_plan(request: Request):
values = await form(request)
ident = await call('save_review',uid(request),one(values,'review_id'),one(values,'name'))
return redirect(request, '/plans/' + ident)
@app.get('/plans/{ident}')
async def plan(request: Request, ident: str):
saved = await call('plan', uid(request), ident)
return render(request, 'pages/plan_detail.html', title=saved['name'], nav='plans', saved=saved,
result=saved['payload'], execution=settings.execution_enabled, credential_feature=settings.credentials_enabled, submission_key=uuid.uuid4().hex)
@app.post('/plans/{ident}/delete')
async def delete_plan(request: Request, ident: str):
await call('delete_plan', uid(request), ident)
return redirect(request, '/plans')
@app.post('/plans/bulk-delete')
async def bulk_delete_plans(request: Request):
values = await form(request)
await call('delete_plans', uid(request), values.get('plan_ids', []))
return redirect(request, '/plans')
@app.get('/api/v2/plans')
async def api_plans(request: Request):
return {'items': await call('plans', uid(request))}
@app.post('/api/v2/plans')
async def api_save_plan(request: Request):
value = await api_payload(request)
if set(value)-{'name','customer','playbook','targets','overrides','check','key_mode'}:
raise WebError('invalid_plan', 'Expected name, customer, playbook, targets and overrides.')
name = value.pop('name','')
ident = await call('save_plan', uid(request), name, **plan_payload(value))
return JSONResponse({'id': ident}, status_code=201)
@app.post('/api/v2/selections')
async def selection(request: Request):
value = await api_payload(request)
if set(value) != {'customer', 'playbook', 'targets'}:
raise WebError('invalid_selection', 'Expected customer, playbook and targets.')
return await call('save_selection', uid(request), **value)
@app.get('/jobs')
async def jobs(request: Request):
return render(request, 'pages/jobs.html', title='Jobs', nav='jobs',
items=await call('jobs', uid(request)), execution=settings.execution_enabled,
attention=await run_in_threadpool(attention, flow, uid(request), limit=100), attention_expanded=True)
@app.get('/_partials/attention')
async def attention_fragment(request: Request, all_items: bool = False):
return render(request, 'partials/attention.html',
attention=await run_in_threadpool(attention, flow, uid(request), limit=100 if all_items else 8),
attention_expanded=all_items)
@app.post('/jobs')
async def queue(request: Request):
values = await form(request)
if one(values, 'confirm') != 'reviewed':
raise WebError('review_required', 'Confirm that you reviewed the targets and change risk.')
when = None
if one(values, 'scheduled_at'):
try:
when = int(datetime.fromisoformat(one(values, 'scheduled_at')).replace(tzinfo=timezone.utc).timestamp())
except ValueError:
raise WebError('invalid_schedule', 'Use the UTC date and time selector.') from None
key = one(values, 'submission_key')
if not key or len(key) > 80 or not key.isalnum():
raise WebError('submission_key', 'Reload the plan before submitting.')
ident = await call('queue_review',uid(request),one(values,'review_id'),when,idempotency_key=key)
return redirect(request, '/jobs/' + ident)
@app.get('/jobs/{ident}')
async def job(request: Request, ident: str):
from aim_webgui.evidence_views import job_evidence
evidence=await run_in_threadpool(job_evidence,settings,uid(request),ident)
return render(request, 'pages/job.html', title='Job detail', nav='jobs', job=await call('job', uid(request), ident), **evidence)
@app.get('/_partials/jobs/{ident}')
async def job_fragment(request: Request, ident: str):
return render(request, 'partials/job.html', job=await call('job', uid(request), ident))
@app.get('/_partials/jobs/{ident}/credentials')
async def credential_fragment(request: Request, ident: str):
from aim_webgui.credentials.service import eligible
job = await run_in_threadpool(eligible, flow, uid(request), ident)
return render(request, 'partials/credential_panel.html', **form_context(job))
@app.get('/api/v2/runs/{ident}/credential-status')
async def credential_state(request: Request, ident: str):
return await run_in_threadpool(credential_status, flow, uid(request), ident)
@app.get('/jobs/{ident}/credentials')
async def credential_page(request: Request, ident: str):
from aim_webgui.credentials.service import eligible
job = await run_in_threadpool(eligible, flow, uid(request), ident)
return render(request, 'pages/credentials.html', title='One-run credentials', nav='jobs', **form_context(job))
async def credentials_submit(request, ident, value):
from aim_webgui.credentials.service import submit
# Trust only server-authenticated identity and session hash, never body fields.
await run_in_threadpool(auth.throttle, [('credential:' + str(uid(request)), 5)], window=60)
return await run_in_threadpool(submit, settings, uid(request), request.state.session['token_hash'], ident, value)
@app.post('/jobs/{ident}/credentials')
async def credential_form(request: Request, ident: str):
values = await form(request)
if set(values) - {'_csrf','vault_password','connection_password','ssh_key_passphrase'}:
raise WebError('invalid_credentials', 'Unexpected credential fields.')
await credentials_submit(request, ident, {k: one(values, k) for k in ('vault_password','connection_password','ssh_key_passphrase')})
return redirect(request, '/jobs/' + ident)
@app.post('/api/v2/runs/{ident}/credentials')
@app.post('/api/v2/jobs/{ident}/credentials')
async def credential_api(request: Request, ident: str):
return JSONResponse(await credentials_submit(request, ident, await api_payload(request)), status_code=202)
@app.post('/jobs/bulk-delete')
async def bulk_delete_jobs(request: Request):
values = await form(request)
await call('delete_jobs', uid(request), values.get('job_ids', []))
return redirect(request, '/jobs')
@app.post('/jobs/{ident}/retry')
async def retry_job(request: Request, ident: str):
new_ident = await call('retry_job', uid(request), ident)
return redirect(request, '/jobs/' + new_ident)
@app.post('/jobs/{ident}/{action}')
async def job_action(request: Request, ident: str, action: str):
await call('job_action', uid(request), ident, action)
return redirect(request, '/jobs/' + ident)
@app.get('/api/v2/runs')
async def api_jobs(request: Request):
return {'items': await call('jobs', uid(request))}
@app.get('/api/v2/runs/{ident}')
async def api_job(request: Request, ident: str):
return await call('job', uid(request), ident)
@app.post('/api/v2/runs')
async def api_queue(request: Request):
if not settings.execution_enabled:
raise WebError('execution_disabled', 'Execution is disabled; no run was started.', 501)
payload = await api_payload(request)
if set(payload)-{'review_id','scheduled_at','confirm'} or payload.get('confirm') is not True:
raise WebError('review_required','Send review_id, optional scheduled_at and confirm=true. Saving a plan is not required.')
key=request.headers.get('Idempotency-Key','')
ident=await call('queue_review',uid(request),payload.get('review_id'),payload.get('scheduled_at'),idempotency_key=key)
return JSONResponse({'id': ident}, status_code=202)
@app.post('/api/v2/runs/{ident}/retry')
async def api_retry(request: Request, ident: str):
new_ident = await call('retry_job', uid(request), ident)
return JSONResponse({'id': new_ident, 'retried_from': ident}, status_code=202)
@app.post('/api/v2/runs/{ident}/{action}')
async def api_action(request: Request, ident: str, action: str):
await call('job_action', uid(request), ident, action)
return {'id': ident, 'action': action}
@@ -0,0 +1,61 @@
"""Small ASGI body limiter and response policy, independent of application routes."""
from urllib.parse import urlsplit
from starlette.responses import JSONResponse
CSP = ("default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
"connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; "
"frame-ancestors 'none'; form-action 'self'")
class RequestPolicy:
def __init__(self, app, secure: bool = False):
self.app, self.secure = app, secure
async def __call__(self, scope, receive, send):
if scope['type'] != 'http':
return await self.app(scope, receive, send)
async def secured_send(message):
if message['type'] == 'http.response.start':
headers = list(message.get('headers', []))
headers.extend([(b'content-security-policy', CSP.encode()),
(b'x-content-type-options', b'nosniff'),
(b'referrer-policy', b'same-origin'),
(b'x-frame-options', b'DENY'),
(b'cache-control', b'no-store'),
(b'permissions-policy', b'camera=(), microphone=(), geolocation=()')])
if self.secure:
headers.append((b'strict-transport-security', b'max-age=31536000'))
message['headers'] = headers
await send(message)
upstream = receive
if scope['method'] not in {'GET', 'HEAD', 'OPTIONS'}:
limit = 8192 if scope.get('path', '').endswith('/credentials') else 65536
body = bytearray()
while True:
message = await receive()
if message['type'] == 'http.disconnect':
return
body.extend(message.get('body', b''))
if len(body) > limit:
return await JSONResponse({'error': {'code': 'body_too_large', 'message': 'Request exceeds the body limit.'}},
status_code=413)(scope, receive, secured_send)
if not message.get('more_body', False):
break
delivered = False
async def replay():
nonlocal delivered
if not delivered:
delivered = True
return {'type': 'http.request', 'body': bytes(body), 'more_body': False}
return await upstream()
receive = replay
return await self.app(scope, receive, secured_send)
def same_origin(actual: str, expected: str) -> bool:
try:
a, b = urlsplit(actual), urlsplit(expected)
return (a.scheme, a.hostname, a.port or (443 if a.scheme == 'https' else 80)) == (
b.scheme, b.hostname, b.port or (443 if b.scheme == 'https' else 80))
except ValueError:
return False
@@ -0,0 +1,200 @@
a {text-decoration: none;} a:hover {text-decoration: underline;}
:focus-visible {outline: 3px solid var(--aim-accent-ink); outline-offset: 3px;}
h1 {font-size: clamp(1.65rem, 2.4vw, 2.1rem);font-weight: 700;letter-spacing: -.035em;}
h2,h3 {letter-spacing: -.025em;}
.app-shell {display: flex; min-height: 100vh;}
.sidebar {width: 256px; flex: 0 0 256px; background: var(--aim-sidebar); color: var(--aim-ink); padding: 2rem 1rem; display: flex; flex-direction: column;}
.brand {display: flex; gap: .75rem; align-items: center; color: white; padding: 0 .75rem 2.4rem; font-size: 1.8rem;font-weight: 750;line-height: 1.1;letter-spacing: -.03em;}
.brand:hover {color: white;text-decoration: none;}
.brand-mark {display: grid; place-items: center; background: var(--aim-accent); color: var(--aim-sidebar); width: 44px; height: 44px; border-radius: .7rem; font-size: 1.8rem;}
.brand-subtitle {display: block; font-size: .5rem; letter-spacing: .13em; margin-top: .5rem; color: var(--aim-sidebar-muted);}
.sidebar-caption {padding: 1rem .8rem .65rem; color: var(--aim-sidebar-muted); font-size: .65rem; font-weight: 600; letter-spacing: .12em;}
.sidebar .nav-link {color: var(--aim-sidebar-link); border-radius: .45rem; padding: .8rem; font-size: .85rem; margin-bottom: .3rem; display: flex; align-items: center; gap: .85rem;}
.sidebar .nav-link:hover {background: var(--aim-sidebar-hover);text-decoration: none;color: white;}
.sidebar .nav-link.active {background: var(--aim-sidebar-active);color: var(--aim-sidebar-active-ink);box-shadow: inset 3px 0 var(--aim-accent);}
.nav-symbol {font-size: .65rem; opacity: .75; font-family: monospace;}
.sidebar-bottom {margin-top: auto; padding: 3rem .8rem 0; font-size: .7rem; color: var(--aim-sidebar-muted);line-height: 1.8;}
.sidebar-bottom p {margin: .65rem 0 0;}
.status-dot {display: inline-block; width: 6px; height: 6px; background: var(--aim-accent); border-radius: 50%; margin-right: .5rem;vertical-align: middle;}
.workspace {flex: 1;min-width: 0;display: flex;flex-direction: column;}
.topbar {min-height: 77px;display: flex; align-items: center; justify-content: space-between;gap: 1rem; padding: 1rem 2.5rem; border-bottom: 1px solid var(--aim-border);background: var(--aim-surface);}
.environment-label {font-size: .65rem;font-weight: 650;letter-spacing: .12em;color: var(--aim-muted);}
.account-links {display: flex; align-items: center;gap: .7rem;font-size: .8rem;}
.account-links a {color: var(--aim-ink);font-weight: 600;}.account-links form {margin: 0;}
.theme-control {display:inline-flex;align-items:center;gap:.2rem;padding:.2rem;border:1px solid var(--aim-border);border-radius:.55rem;background:var(--aim-surface-raised);}
.theme-option {display:grid;place-items:center;width:2rem;height:2rem;padding:0;border:0;border-radius:.38rem;background:transparent;color:var(--aim-muted);font:inherit;line-height:1;cursor:pointer;}
.theme-option span {font-size:1.08rem;transform:translateY(-.02rem);}
.theme-option:hover {background:var(--aim-neutral-soft);color:var(--aim-ink);}
.theme-option.active,.theme-option[aria-pressed="true"] {background:var(--aim-accent-soft);color:var(--aim-accent-ink);box-shadow:inset 0 0 0 1px var(--aim-pill-border);}
.theme-option:focus-visible {outline-offset:2px;}
.role-label {border: 1px solid var(--aim-border);border-radius: .3rem;padding: .1rem .4rem;color: var(--aim-muted);font-size: .65rem;}
.main-content {padding: 2.5rem;max-width: 1540px;width: 100%;margin: 0 auto;flex: 1;}
.page-heading {margin-bottom: 1.7rem;}.page-heading p:last-child {max-width: 850px;margin-top: .65rem;font-size: .92rem;line-height: 1.65;}
.eyebrow {font-size: .64rem;font-weight: 700;letter-spacing: .13em;color: var(--aim-muted);margin-bottom: .65rem;display: block;}
.mode-banner {border: 1px solid var(--aim-banner-border);background: linear-gradient(115deg,var(--aim-banner-start),var(--aim-banner-end));border-radius: var(--aim-radius);padding: 1.7rem;display: flex;align-items: center;justify-content: space-between;gap: 1.5rem;margin-bottom: 1.5rem;}
.mode-banner h2 {font-size: 1.45rem;margin: .8rem 0 .5rem;}.mode-banner p {font-size: .85rem;color: var(--aim-muted);margin: 0;max-width: 590px;}.mode-banner .btn {flex-shrink: 0;}
.pill {color: var(--aim-accent-ink);font-size: .6rem;letter-spacing: .09em;font-weight: 700;background: var(--aim-accent-soft);border: 1px solid var(--aim-pill-border);padding: .3rem .5rem;border-radius: .3rem;display: inline-block;}
.metric-card {padding: 1.5rem;}.metric-label {font-size: .8rem;color: var(--aim-muted);font-weight: 500;}.metric-value {font-size: 2.4rem;letter-spacing: -.05em;line-height: 1.7;}
.badge-success {background: var(--aim-success-soft);color: var(--aim-success);}.badge-warning {background: var(--aim-warning-soft);color: var(--aim-warning);}.badge-neutral {background: var(--aim-neutral-soft);color: var(--aim-neutral);}
.entity-link,.entity-name {font-weight: 600;}.entity-link {color: var(--aim-ink);}
.system-facts {display: grid;grid-template-columns: minmax(110px,1fr) minmax(0,1.4fr);font-size: .8rem;margin: 1.4rem 0;}.system-facts dt,.system-facts dd {padding: .65rem 0;border-bottom: 1px solid var(--aim-border);margin: 0;overflow-wrap: anywhere;}.system-facts dt {font-weight: 500;color: var(--aim-muted);}
.note {font-size: .8rem;color: var(--aim-note-ink);background: var(--aim-note-bg);padding: 1rem;border-radius: .4rem;line-height: 1.6;border-left: 3px solid var(--aim-accent);}
.empty-state {text-align: center;color: var(--aim-muted);padding: 3rem 1.5rem!important;}.empty-state p {font-size: .85rem;margin: .5rem 0 0;}
.toolbar {display: flex;align-items: center;justify-content: space-between;gap: 1rem;margin-bottom: 1.5rem;}.search-form {display: flex;gap: .5rem;flex: 1;max-width: 570px;}.section-heading {display: flex;align-items: start;justify-content: space-between;gap: 1rem;}
.form-card {max-width: 580px;}.login-layout {display: grid;grid-template-columns: minmax(270px,430px) minmax(250px,420px);gap: 4rem;align-items: center;}.login-note {color: var(--aim-muted);font-size: .9rem;line-height: 1.8;}.login-note h2 {color: var(--aim-ink);line-height: 1.4;}.section-index {display: block;font-size: .65rem;letter-spacing: .12em;color: var(--aim-accent-ink);font-weight: 650;margin-bottom: 1rem;}.reset-form {max-width: 360px;}
.review-result {border-top: 3px solid var(--aim-accent);}.code-panel {background: var(--aim-code-panel);border: 1px solid var(--aim-border);padding: 1rem;border-radius: .4rem;margin-top: 1rem;white-space: pre-wrap;overflow-wrap: anywhere;}
.selection-card-header {align-items:flex-start;}
.selection-limit {font-size:.7rem;color:var(--aim-muted);white-space:nowrap;}
.selection-column {width:64px!important;min-width:64px;text-align:center!important;padding-left:1rem!important;padding-right:1rem!important;}
.selection-column .form-check-input {display:block;margin:0 auto;vertical-align:middle;}
.group-selector {display:grid;grid-template-columns:minmax(130px,auto) 1fr;align-items:center;gap:1rem 1.25rem;padding:.9rem 1rem;border-bottom:1px solid var(--aim-border);background:var(--aim-surface-raised);}
.group-selector-heading {display:flex;flex-direction:column;gap:.15rem;}
.group-selector-label {font-size:.65rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--aim-muted);}
.group-selector-help {font-size:.68rem;color:var(--aim-muted);white-space:nowrap;}
.group-selector-items {display:flex;flex-wrap:wrap;gap:.42rem;}
.group-choice {display:inline-flex;align-items:center;gap:.45rem;min-height:2rem;padding:.32rem .58rem;border:1px solid var(--aim-border);border-radius:.5rem;background:var(--aim-surface);color:var(--aim-ink);font-size:.78rem;cursor:pointer;}
.group-choice:hover {border-color:var(--aim-accent);}
.group-choice .form-check-input {margin:0;}
.group-count {display:inline-grid;place-items:center;min-width:1.45rem;height:1.45rem;padding:0 .35rem;border-radius:999px;background:var(--aim-neutral-soft);color:var(--aim-neutral);font-size:.68rem;font-variant-numeric:tabular-nums;}
html[data-theme="dark"] body {background:var(--aim-bg);color:var(--aim-ink);}
html[data-theme="dark"] code {color:#ffc18d;}
html[data-theme="dark"] .text-secondary {color:var(--aim-muted)!important;}
.workspace-footer {font-size: .65rem;color: var(--aim-muted);padding: 1.25rem 2.5rem;border-top: 1px solid var(--aim-border);display: flex;gap: 1rem;justify-content: space-between;}.workspace-footer span {white-space: nowrap;}
.table-responsive {overscroll-behavior-x:contain;-webkit-overflow-scrolling:touch;}
.skip-link {position: absolute;left: 1rem;top: -5rem;background: var(--aim-surface);color: var(--aim-ink);padding: .75rem;z-index: 100;}.skip-link:focus {top: 1rem;}.htmx-indicator {opacity: 0;}.htmx-request .htmx-indicator {opacity: 1;}
@media(max-width:1100px){
.sidebar{width:218px;flex-basis:218px;}
.main-content{padding:1.75rem;}
.mode-banner{align-items:flex-start;flex-direction:column;}
.login-layout{gap:2rem;grid-template-columns:1fr;}
.login-note{max-width:500px;}
.topbar{padding:1rem 1.75rem;}
}
/* Compact layout primitives. The mobile header/menu lives in experience.css. */
@media(max-width:760px){
.workspace{min-width:0;}
.main-content{padding:1.25rem max(1rem,env(safe-area-inset-right)) 1.5rem max(1rem,env(safe-area-inset-left));}
.page-heading{margin-bottom:1.3rem;}
.toolbar,.section-heading{flex-direction:column;align-items:stretch;}
.group-selector{grid-template-columns:1fr;align-items:start;gap:.65rem;padding:.85rem 1rem;}
.group-selector-help{white-space:normal;}
.group-selector-items{gap:.5rem;}
.group-choice{min-height:2.65rem;padding:.45rem .65rem;}
.selection-limit{white-space:normal;}
.selection-column{width:56px!important;min-width:56px;padding-left:.75rem!important;padding-right:.75rem!important;}
.mode-banner{padding:1.25rem;}
.workspace-footer{padding:1rem max(1rem,env(safe-area-inset-right)) calc(1rem + env(safe-area-inset-bottom)) max(1rem,env(safe-area-inset-left));flex-direction:column;gap:.35rem;}
.workspace-footer span{white-space:normal;}
.table-responsive>.table{min-width:620px;}
.host-selection-card .table{min-width:720px;}
.table td,.table th{padding:.8rem 1rem;}
}
@media(max-width:480px){
h1{font-size:1.55rem;}
.brand-subtitle{display:none;}
.topbar{padding-top:.55rem;padding-bottom:.55rem;}
.account-links>a{max-width:8.5rem;}
.main-content{padding-top:1rem;}
.card-body.p-4{padding:1.1rem!important;}
.card-header{padding:1rem;}
.search-form{max-width:none;flex-direction:column;}
.search-form .btn,.toolbar>.btn,.mode-banner>.btn{width:100%;}
.group-selector-items{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));width:100%;}
.group-choice{width:100%;justify-content:flex-start;}
.group-count{margin-left:auto;}
.system-facts{grid-template-columns:1fr;}
.system-facts dt{padding-bottom:.15rem;border-bottom:0;}
.system-facts dd{padding-top:0;}
}
@media(prefers-reduced-motion:reduce){*,*::before,*::after{scroll-behavior:auto!important;transition:none!important;}}
/* 1.0 shared responsive primitives. Count is a separate fixed grid track. */
.group-choice { display:grid; grid-template-columns:1.05rem minmax(0,1fr) auto; align-items:center; gap:.5rem; min-width:0; max-width:100%; }
.group-choice .group-name { min-width:0; overflow-wrap:anywhere; line-height:1.3; }
.group-choice .group-count { margin-left:0; white-space:nowrap; justify-self:end; flex:none; }
.filter-toolbar { display:flex; flex-wrap:wrap; gap:.65rem; padding:1rem; align-items:end; }
.filter-toolbar>label { flex:1 1 10rem; min-width:0; }
.filter-toolbar .btn { min-height:2.65rem; }
.selection-help { padding:0 1rem .75rem; margin:0; }
.workflow-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1rem; }
.workflow-grid>* { min-width:0; }
.workflow-actions { display:flex; flex-wrap:wrap; gap:.6rem; align-items:center; }
.workflow-actions form { margin:0; }
.form-text, .text-break, .system-facts dd { overflow-wrap:anywhere; }
.event-list { list-style:none; padding:0; }
.event-list li { border-bottom:1px solid var(--aim-border); padding:.75rem 0; }
.job-status { background:var(--aim-accent-soft); color:var(--aim-accent-ink); padding:.3rem .65rem; border-radius:1rem; }
@media(max-width:760px) {
.workflow-grid { grid-template-columns:1fr; }
.workflow-actions .btn { min-height:44px; }
}
@media(max-width:480px) {
.group-choice { grid-template-columns:1rem minmax(0,1fr) auto; gap:.35rem; padding:.45rem .5rem; }
.group-count { font-variant-numeric:tabular-nums; font-size:.72rem; padding:.15rem .3rem; }
.filter-toolbar>label, .filter-toolbar>.btn { flex-basis:100%; }
}
/* Shared, accessible credential controls. No page-local colour literals. */
.credential-card { max-width: 52rem; }
.credential-switch { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: .25rem; padding: .25rem; border: 1px solid var(--aim-border); border-radius: .75rem; }
.credential-switch label { position: relative; min-width: 0; margin: 0; }
.credential-switch label span, .credential-choice { display: flex; align-items: center; justify-content: center; min-height: 44px; padding: .6rem .75rem; border-radius: .5rem; text-align: center; overflow-wrap: anywhere; }
.credential-switch input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.credential-switch input:checked + span, .credential-choice.is-active { background: var(--aim-accent); color: var(--aim-on-accent); font-weight: 700; }
.credential-switch input:focus-visible + span { outline: 3px solid var(--aim-accent); outline-offset: 2px; }
.credential-mode { min-width: 0; }
@media (max-width: 480px) { [data-secret-form] .btn { width: 100%; margin-top: .5rem; } }
/* Ephemeral job console: dark terminal surface in both themes, accent-aware controls. */
.live-console-card { overflow: hidden; }
.console-toolbar { display:flex; justify-content:space-between; align-items:center; gap:1rem; padding:1rem 1.25rem; border-bottom:1px solid var(--bs-border-color); }
.console-controls { display:flex; align-items:center; justify-content:flex-end; gap:1rem; flex-wrap:wrap; }
.console-state { font-size:.78rem; letter-spacing:.06em; text-transform:uppercase; color:var(--bs-secondary-color); }
.live-console { margin:0; height:clamp(18rem,55dvh,40rem); max-height:calc(100dvh - 12rem); overflow-y:auto; overscroll-behavior:contain; scrollbar-gutter:stable; padding:1rem 1.25rem; border:0; border-radius:0; background:var(--aim-console-bg); color:var(--aim-console-ink); font-size:.82rem; line-height:1.55; white-space:pre-wrap; overflow-wrap:anywhere; }
.console-line-notice { color:inherit; }
.console-footnote { padding:.7rem 1.25rem; font-size:.78rem; color:var(--bs-secondary-color); border-top:1px solid var(--bs-border-color); }
@media (max-width: 575.98px) {
.console-toolbar { align-items:flex-start; flex-direction:column; }
.console-controls { justify-content:flex-start; width:100%; }
.live-console { height:48dvh; min-height:14rem; max-height:calc(100dvh - 13rem); font-size:.76rem; padding:.85rem; }
}
/* Operational overview hierarchy and semantic state chips. */
.nav-link-featured { font-weight:700; }
.status-tag { display:inline-flex; align-items:center; gap:.42rem; border:1px solid var(--aim-border); border-radius:999px; padding:.28rem .58rem; font-size:.74rem; font-weight:750; line-height:1.15; text-transform:capitalize; white-space:nowrap; background:var(--bs-tertiary-bg); }
.status-dot-mini { width:.48rem; height:.48rem; border-radius:50%; background:currentColor; flex:0 0 auto; }
.status-successful { color:var(--bs-success-text-emphasis); background:var(--bs-success-bg-subtle); border-color:var(--bs-success-border-subtle); }
.status-failed,.status-timed_out { color:var(--bs-danger-text-emphasis); background:var(--bs-danger-bg-subtle); border-color:var(--bs-danger-border-subtle); }
.status-running { color:var(--bs-primary-text-emphasis); background:var(--bs-primary-bg-subtle); border-color:var(--bs-primary-border-subtle); }
.status-running .status-dot-mini { animation:aim-status-pulse 1.5s ease-in-out infinite; }
.status-pending,.status-queued,.status-blocked { color:var(--bs-warning-text-emphasis); background:var(--bs-warning-bg-subtle); border-color:var(--bs-warning-border-subtle); }
.status-canceled,.status-interrupted { color:var(--bs-secondary-color); background:var(--bs-secondary-bg); }
@keyframes aim-status-pulse { 50% { opacity:.35; transform:scale(.82); } }
@media (prefers-reduced-motion:reduce) { .status-running .status-dot-mini { animation:none; } }
.mode-tag { display:inline-block; border:1px solid var(--aim-border); border-radius:.4rem; padding:.18rem .4rem; font-size:.72rem; font-weight:700; text-transform:uppercase; }
.overview-toolbar { display:flex; justify-content:space-between; align-items:end; gap:1rem; margin-bottom:.75rem; }
.overview-toolbar>div { display:grid; gap:.12rem; }
.operation-link { font-weight:750; }
.row-meta,.host-preview { margin-top:.2rem; color:var(--bs-secondary-color); font-size:.76rem; line-height:1.35; }
.host-preview { max-width:30rem; overflow-wrap:anywhere; }
.host-preview span { white-space:nowrap; font-weight:650; }
.operational-table tbody tr:hover { background:color-mix(in srgb,var(--aim-accent) 4%,transparent); }
.audit-stack { display:grid; gap:1rem; }
.audit-filter { padding:1rem 1.15rem; margin:0; border:1px solid var(--aim-border); border-radius:.65rem; background:var(--bs-body-bg); }
.audit-filter>label { flex:1 1 24rem; }
.filter-actions { display:flex; gap:.65rem; align-items:end; }
@media (max-width:760px) { .overview-toolbar { align-items:stretch; flex-direction:column; } .overview-toolbar .btn { align-self:flex-start; } .filter-actions { width:100%; } .filter-actions .btn { flex:1; } }
.status-partially_succeeded { color:var(--bs-warning-text-emphasis); background:var(--bs-warning-bg-subtle); border-color:var(--bs-warning-border-subtle); }
.target-outcomes { border:1px solid var(--aim-border); border-radius:.7rem; padding:1rem; background:var(--aim-surface-raised); }
.outcome-counters { display:flex; flex-wrap:wrap; gap:.55rem 1rem; margin-top:.8rem; font-size:.8rem; color:var(--aim-muted); }
.target-outcome-table td:last-child { min-width:18rem; }
.target-status-successful { color:var(--bs-success-text-emphasis); background:var(--bs-success-bg-subtle); border-color:var(--bs-success-border-subtle); }
.target-status-failed,.target-status-unreachable { color:var(--bs-danger-text-emphasis); background:var(--bs-danger-bg-subtle); border-color:var(--bs-danger-border-subtle); }
.target-status-not_started,.target-status-indeterminate { color:var(--bs-secondary-color); background:var(--bs-secondary-bg); }
.outcome-summary { margin-top:.35rem; }
@@ -0,0 +1,65 @@
/* Component variables are intentional: --bs-primary alone does not recolor buttons. */
:root {
--bs-primary: var(--aim-accent);
--bs-primary-rgb: var(--aim-accent-rgb);
--bs-body-color: var(--aim-ink);
--bs-body-bg: var(--aim-bg);
--bs-secondary-color: var(--aim-muted);
--bs-border-color: var(--aim-border);
--bs-link-color: var(--aim-accent-ink);
--bs-link-hover-color: var(--aim-link-hover);
--bs-border-radius: .5rem;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", sans-serif;
}
.btn-primary {
--bs-btn-color: #1e1d1a;
--bs-btn-bg: var(--aim-accent);
--bs-btn-border-color: var(--aim-accent);
--bs-btn-hover-color: #1e1d1a;
--bs-btn-hover-bg: var(--aim-accent-hover);
--bs-btn-hover-border-color: var(--aim-accent-hover);
--bs-btn-active-color: #1e1d1a;
--bs-btn-active-bg: var(--aim-accent-hover);
--bs-btn-active-border-color: var(--aim-accent-hover);
--bs-btn-disabled-bg: var(--aim-accent);
--bs-btn-disabled-color: #1e1d1a;
--bs-btn-disabled-border-color: var(--aim-accent);
--bs-btn-focus-shadow-rgb: var(--aim-accent-rgb);
}
.btn-outline-primary {
--bs-btn-color: var(--aim-accent-ink);
--bs-btn-border-color: var(--aim-accent);
--bs-btn-hover-color: #1e1d1a;
--bs-btn-hover-bg: var(--aim-accent);
--bs-btn-hover-border-color: var(--aim-accent);
--bs-btn-active-bg: var(--aim-accent-hover);
--bs-btn-active-color: #1e1d1a;
}
.btn-outline-secondary {
--bs-btn-color: var(--aim-muted);
--bs-btn-border-color: var(--aim-border);
--bs-btn-hover-color: var(--aim-ink);
--bs-btn-hover-bg: var(--aim-surface-raised);
--bs-btn-hover-border-color: var(--aim-border);
--bs-btn-active-color: var(--aim-ink);
--bs-btn-active-bg: var(--aim-surface-raised);
--bs-btn-active-border-color: var(--aim-border);
}
.btn {font-weight: 600; padding: .65rem 1rem;}
.btn-sm {padding: .35rem .6rem; font-size: .8rem;}
.card {--bs-card-border-color: var(--aim-border); --bs-card-border-radius: var(--aim-radius); --bs-card-bg: var(--aim-surface); color: var(--aim-ink); box-shadow: var(--aim-shadow); overflow: hidden;}
.card-header {background: var(--aim-surface); border-color: var(--aim-border); padding: 1.25rem 1.5rem; display: flex; gap: 1rem; align-items: center; justify-content: space-between;}
.form-control,.form-select {background-color: var(--aim-surface); color: var(--aim-ink); border-color: var(--aim-border); min-height: 2.65rem;}
.form-control::placeholder {color: var(--aim-muted); opacity: .8;}
.form-control:focus,.form-select:focus {background-color: var(--aim-surface); color: var(--aim-ink); border-color: var(--aim-accent); box-shadow: 0 0 0 .2rem rgb(var(--aim-accent-rgb) / 22%);}
.form-select option {background: var(--aim-surface); color: var(--aim-ink);}
.form-check-input {background-color: var(--aim-surface); border-color: var(--aim-border);}
.form-check-input:checked,.form-check-input:indeterminate {background-color: var(--aim-accent-ink);border-color: var(--aim-accent-ink);}
.form-label {font-size: .875rem; font-weight: 600;}
.form-text {color: var(--aim-muted);}
.table {--bs-table-bg: var(--aim-surface); --bs-table-color: var(--aim-ink); --bs-table-border-color: var(--aim-border); font-size: .9rem;}
.table th {background: var(--aim-table-head); color: var(--aim-muted); font-size: .7rem; letter-spacing: .06em; text-transform: uppercase; padding: .85rem 1.5rem;}
.table td {padding: 1rem 1.5rem;}
.table tr:last-child td {border-bottom: 0;}
.badge {font-weight: 550; border-radius: .35rem; padding: .45rem .6rem; line-height: 1.2; white-space: normal;}
.alert-danger {--bs-alert-bg: color-mix(in srgb, #dc3545 12%, var(--aim-surface)); --bs-alert-color: var(--aim-ink); --bs-alert-border-color: color-mix(in srgb, #dc3545 32%, var(--aim-border));}
@@ -0,0 +1,88 @@
/* One-run dialog and attention surface. Bootstrap 5 native radio/label semantics. */
.credential-dialog { padding: 0; border: 1px solid var(--aim-border); border-radius: 1.1rem; background: var(--aim-surface); color: var(--aim-ink); width: min(36rem, calc(100vw - 2rem)); max-width: 100%; max-height: calc(100dvh - 2rem); overflow: hidden; box-shadow: var(--aim-shadow); }
.credential-dialog[open] { display: flex; flex-direction: column; }
.credential-dialog::backdrop { background: var(--aim-dialog-backdrop); }
html.credential-modal-open { overflow: hidden; scrollbar-gutter: stable; }
.credential-dialog-header { flex: 0 0 auto; display: flex; align-items: flex-start; gap: 1rem; padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--aim-border); background: var(--aim-surface-raised); }
.credential-dialog-header > div { flex: 1; min-width: 0; }
.credential-dialog-header .eyebrow { margin: 0 0 .3rem; }
.credential-dialog-header h2 { font-size: 1.45rem; margin: 0; line-height: 1.2; }
.credential-dialog-header p:last-child { margin: .35rem 0 0; color: var(--aim-muted); font-size: .85rem; }
.credential-close { width: 2.75rem; min-width: 2.75rem; height: 2.75rem; padding: 0; font-size: 1.5rem; line-height: 1; }
.credential-dialog-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 1.25rem 1.5rem; }
.credential-panel { text-align: left; min-width: 0; }
.credential-panel [hidden] { display: none !important; }
.credential-scope { display: flex; align-items: center; gap: .75rem; padding: .85rem; background: var(--aim-code-panel); border: 1px solid var(--aim-border); border-radius: .7rem; margin-bottom: 1rem; }
.credential-scope > div { min-width: 0; }
.credential-scope strong { display: block; font-size: 1rem; overflow-wrap: anywhere; }
.credential-scope div > span { display: block; margin-top: .25rem; color: var(--aim-muted); font-size: .85rem; overflow-wrap: anywhere; }
.credential-scope-symbol { display: grid; place-items: center; width: 2.5rem; min-width: 2.5rem; height: 2.5rem; border-radius: .55rem; background: var(--aim-accent-soft); color: var(--aim-accent-ink); }
.credential-scope-symbol svg { fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
.credential-reservation { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: .5rem; color: var(--aim-muted); font-size: .83rem; margin-bottom: .85rem; }
[data-credential-countdown] { font-variant-numeric: tabular-nums; }
.credential-reservation.is-urgent { color: var(--aim-warning); }
.credential-intro { color: var(--aim-muted); font-size: .9rem; margin-bottom: 1.2rem; }
.credential-inputs { padding: 0; border: 0; margin: 0; min-width: 0; }
.credential-field { margin-bottom: 1.1rem; min-width: 0; }
.credential-field .form-label { display: flex; align-items: baseline; flex-wrap: wrap; justify-content: space-between; gap: .35rem; width: 100%; font-weight: 600; margin-bottom: .4rem; }
.field-requirement { color: var(--aim-muted); font-size: .76rem; font-weight: 400; }
.credential-input-row { display: flex; align-items: stretch; gap: .4rem; }
.credential-input-row .form-control { min-width: 0; font-size: 1rem; min-height: 2.8rem; }
.credential-reveal { min-width: 4rem; min-height: 2.8rem; font-size: .85rem; }
.credential-caps { color: var(--aim-warning); font-size: .8rem; margin: .35rem 0 0; }
.credential-field .form-text { margin-top: .35rem; line-height: 1.45; }
.credential-key-source { margin: 0 0 .8rem; border: 0; padding: 0; min-width: 0; }
.credential-key-source legend { font-size: .94rem; font-weight: 600; margin: 0 0 .5rem; float: none; width: auto; }
.credential-segments { display: flex; width: 100%; gap: 0; }
.credential-segments .btn { flex: 1 1 0; min-width: 0; min-height: 2.75rem; display: flex; justify-content: center; align-items: center; white-space: normal; font-size: .85rem; line-height: 1.25; border-color: var(--aim-border); background: var(--aim-surface); color: var(--aim-muted); }
.credential-segments .btn-check:checked + .btn { background: var(--aim-accent-soft); border-color: var(--aim-accent-ink); color: var(--aim-accent-ink); font-weight: 600; }
.credential-segments .btn-check:focus-visible + .btn { outline: 2px solid var(--aim-accent-ink); outline-offset: 3px; box-shadow: none; z-index: 2; }
.credential-source-choice .form-text { margin: .5rem 0 .9rem; }
.credential-explanation { padding: .8rem 0; margin: .2rem 0 .8rem; border-top: 1px solid var(--aim-border); border-bottom: 1px solid var(--aim-border); }
.credential-explanation summary { color: var(--aim-muted); font-size: .85rem; cursor: pointer; }
.credential-explanation p { font-size: .82rem; margin: .65rem 0 0; overflow-wrap: anywhere; }
.credential-privacy { margin: .9rem 0; color: var(--aim-muted); font-size: .83rem; }
.credential-privacy span { color: var(--aim-success); }
.credential-submit-row { display: flex; flex-wrap: wrap; align-items: stretch; gap: .6rem; }
.credential-submit-row .btn { min-height: 2.8rem; }
.credential-submit-row .btn-primary { flex: 1; }
.credential-submit-note { margin: .65rem 0 0; font-size: .78rem; }
.credential-feedback { border-radius: .6rem; border: 1px solid var(--aim-border); padding: .9rem; margin-bottom: 1rem; background: var(--aim-neutral-soft); color: var(--aim-ink); font-size: .9rem; }
.credential-feedback[data-tone="error"] { border-color: var(--aim-danger); }
.credential-feedback[data-tone="accepted"] { color: var(--aim-success); background: var(--aim-success-soft); }
.credential-fallback { max-width: 42rem; }
.credential-loading { padding: 1rem; color: var(--aim-muted); }
.status-waiting_credentials { color: var(--aim-warning); background: var(--aim-warning-soft); border-color: var(--aim-border); }
.attention-section { margin: 1.2rem 0; padding: 1rem 1.2rem; background: var(--aim-surface); border: 1px solid var(--aim-border); border-radius: var(--aim-radius); }
.attention-section .section-heading { margin-bottom: .75rem; }
.attention-count { display: grid; place-items: center; min-width: 2rem; min-height: 2rem; padding: .15rem .45rem; background: var(--aim-warning-soft); color: var(--aim-warning); border-radius: .5rem; font-weight: 700; }
.attention-items { display: grid; gap: .75rem; }
.attention-item { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: .9rem; border-radius: .65rem; background: var(--aim-code-panel); }
.attention-item > div { min-width: 0; }
.attention-item strong { display: block; margin-top: .4rem; overflow-wrap: anywhere; }
.attention-item p { margin: .15rem 0; color: var(--aim-muted); font-size: .84rem; overflow-wrap: anywhere; }
.attention-item small { color: var(--aim-muted); }
.attention-item > a { flex-shrink: 0; min-height: 2.6rem; align-content: center; }
.attention-empty { margin: 0; color: var(--aim-muted); font-size: .85rem; }
@media(max-width:760px) {
.credential-dialog { width: calc(100vw - 1rem); border-radius: .85rem; max-height: calc(100dvh - 1rem); }
.credential-dialog-header { padding: .9rem 1rem; }
.credential-dialog-header h2 { font-size: 1.2rem; }
.credential-dialog-body { padding: 1rem; }
.credential-submit-row { flex-direction: column; }
.credential-scope { padding: .65rem; }
.attention-section { padding: .9rem; }
.attention-item { align-items: stretch; flex-direction: column; gap: .6rem; }
}
@media(max-height:430px) {
.credential-dialog-header { padding: .55rem 1rem; }
.credential-dialog-header .eyebrow, .credential-dialog-header p:last-child { display: none; }
.credential-close { min-height: 2.5rem; height: 2.5rem; }
}
/* Do not inherit the old full-width mobile secret-form button rule in input groups. */
.credential-input-row .form-control { flex: 1 1 auto; width: 0; }
.credential-panel .credential-input-row .credential-reveal { flex: 0 0 4.25rem; width: 4.25rem; margin: 0; }
.credential-panel .credential-segments .btn { width: auto; margin-top: 0; }
.credential-panel .credential-submit-row .btn { margin-top: 0; }
/* Programmatic heading focus announces the modal; keyboard controls retain rings. */
.credential-dialog-header h2:focus { outline: none; }
@@ -0,0 +1,49 @@
/* Evidence views use the existing AIM tokens; no new theme or navigation language. */
.job-view-nav {display:flex; flex-wrap:wrap; gap:.4rem; margin:0 0 1.2rem; padding:.35rem; background:var(--aim-surface); border:1px solid var(--aim-border);border-radius:var(--aim-radius);}
.job-view-nav a {padding:.55rem 1rem; border-radius:.5rem; color:var(--aim-ink);text-decoration:none;}
.job-view-nav a:hover,.job-view-nav a[aria-current] {background:var(--aim-accent-soft);color:var(--aim-accent-ink);}
#job-status,#job-progress,#job-targets,#job-reports {scroll-margin-top:5rem;}
.progress-checkpoint {display:grid;grid-template-columns:1fr 2fr;gap:.55rem 1rem;color:var(--aim-muted);font-size:.875rem;}
.progress-checkpoint strong {color:var(--aim-ink);}
.progress-checkpoint span {min-width:0;overflow-wrap:anywhere;}
.journal-warning {color:var(--aim-warning);background:var(--aim-warning-soft);padding:.8rem;border-block:1px solid var(--aim-border);}
.journal-output {white-space:pre-wrap;overflow-wrap:anywhere;scrollbar-gutter:stable;}
.journal-timeline {list-style:none;padding:0;margin:0;}
.journal-timeline li {padding:.75rem 0;border-bottom:1px solid var(--aim-border);}
.journal-timeline time {font-size:.8rem;color:var(--aim-muted);}
.journal-timeline pre {white-space:pre-wrap;overflow-wrap:anywhere;margin:.35rem 0;font-size:.85rem;color:var(--aim-ink);}
.report-slot-list {display:grid;gap:.6rem;grid-template-columns:repeat(auto-fit,minmax(min(100%,20rem),1fr));}
.report-slot {display:grid;grid-template-columns:1fr auto;gap:.4rem .7rem;padding:.9rem;border:1px solid var(--aim-border);border-radius:.6rem;color:var(--aim-ink);text-decoration:none;min-width:0;}
.report-slot .entity-name {overflow-wrap:anywhere;min-width:0;}
.report-slot:hover {background:var(--aim-accent-soft);}
.report-slot small {color:var(--aim-muted);}
.report-open {font-size:.8rem;color:var(--aim-accent-ink);}
.report-availability {display:inline-flex;align-items:center;font-size:.78rem;border-radius:2rem;padding:.25rem .7rem;background:var(--aim-neutral-soft);color:var(--aim-neutral);width:max-content;max-width:100%;}
.report-available {color:var(--aim-success);background:var(--aim-success-soft);}
.report-invalid,.report-missing,.report-indeterminate {color:var(--aim-warning);background:var(--aim-warning-soft);}
.report-host-select {display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:.8rem;}
.report-facts {display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,14rem),1fr));gap:.75rem;margin:0;}
.report-facts>div {padding:.85rem;background:var(--aim-code-panel);border-radius:.5rem;min-width:0;}
.report-facts dt {font-size:.85rem;color:var(--aim-muted);font-weight:500;}
.report-facts dd {margin:.35rem 0 0;font-weight:600;overflow-wrap:anywhere;}
.fact-yes {color:var(--aim-success);}.fact-no {color:var(--aim-muted);}
.report-semantic-note {border-left:3px solid var(--aim-accent);padding:.7rem 1rem;background:var(--aim-note-bg);color:var(--aim-note-ink);}
.report-table {font-size:.85rem;min-width:32rem;}
.report-table td {white-space:normal;overflow-wrap:anywhere;max-width:28rem;min-width:8rem;vertical-align:top;}
.report-table th {min-width:7rem;}
.report-values {padding:1rem 1.2rem 1rem 2rem;overflow-wrap:anywhere;}
.report-values li {padding:.2rem;}
.report-key-values>div {padding:1rem;border-top:1px solid var(--aim-border);}
.report-key-values pre {white-space:pre-wrap;overflow-wrap:anywhere;margin:.4rem 0 0;max-height:14rem;overflow:auto;}
.report-json {min-height:8rem;max-height:min(45dvh,32rem);white-space:pre;overflow:auto;overscroll-behavior:contain;margin-top:.8rem;font-size:.85rem;}
[data-json-status] {margin-left:.7rem;color:var(--aim-muted);font-size:.85rem;}
@media (max-width:760px) {
.job-view-nav {gap:.1rem;}.job-view-nav a {flex:1;text-align:center;padding:.6rem .4rem;font-size:.85rem;}
.progress-checkpoint {grid-template-columns:1fr;gap:.35rem;}
.report-host-select {grid-template-columns:1fr auto;}.report-host-select label {grid-column:1/-1;}
.report-slot {grid-template-columns:minmax(0,1fr) auto;}.report-slot small {grid-column:1/-1;}
.report-slot-list {grid-template-columns:1fr;}.report-facts {grid-template-columns:repeat(2,minmax(0,1fr));gap:.45rem;}
.report-facts>div {padding:.65rem;}.report-facts dt {font-size:.8rem;}
.report-semantic-note {font-size:.875rem;}.report-json {max-height:38dvh;}
}
@media (max-width:360px) {.report-facts{grid-template-columns:1fr;}}
@@ -0,0 +1,162 @@
/* Read-only workspaces. Shared colours live in tokens.css. */
.mobile-topbar { display:none; }
.main-content { overflow-wrap:anywhere; }
.page-heading h1 { overflow-wrap:anywhere; }
.context-links,.inventory-breadcrumbs { display:flex; flex-wrap:wrap; gap:.6rem 1.15rem; align-items:center; margin-bottom:1.3rem; font-size:.85rem; }
.inventory-breadcrumbs { gap:.5rem; }
.inventory-breadcrumbs [aria-current] { color:var(--aim-ink); font-weight:750; }
.coverage-note { display:block; font-size:.76rem; line-height:1.65; color:var(--aim-muted); }
.context-links a,.membership-chip { color:var(--aim-accent-ink); }
.host-identity { display:flex; flex-direction:row; gap:1rem; padding:1.35rem; align-items:flex-start; }
.host-identity>div:last-child { min-width:0; }
.entity-symbol { display:grid; place-items:center; width:3rem; height:3rem; flex:0 0 3rem; border-radius:.8rem; background:var(--aim-accent-soft); color:var(--aim-accent-ink); font-size:1.5rem; }
.small-symbol { width:2.25rem; height:2.25rem; flex-basis:2.25rem; border-radius:.6rem; font-size:1.2rem; }
.membership-links { display:flex; flex-wrap:wrap; gap:.5rem; }
.membership-chip { display:inline-flex; border:1px solid var(--aim-border); padding:.3rem .6rem; border-radius:.45rem; font-size:.8rem; background:var(--aim-surface-raised); }
.history-filters { display:grid; grid-template-columns:repeat(auto-fit,minmax(135px,1fr)); align-items:end; gap:1rem; padding:1.1rem; margin:1rem 0; border:1px solid var(--aim-border); border-radius:var(--aim-radius); background:var(--aim-surface); }
.history-filters label { display:grid; gap:.45rem; font-size:.76rem; font-weight:650; min-width:0; }
.history-filters .btn { min-height:42px; }
.insight-metrics { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:1rem; margin:1.2rem 0; }
.insight-metrics .card { padding:1.25rem; min-width:0; }
.insight-metrics strong { font-size:2.2rem; font-weight:700; letter-spacing:-.04em; line-height:1.4; }
.insight-metrics section>span:last-child { font-size:.76rem; color:var(--aim-muted); }
.distribution-card { padding:1.25rem; }
.outcome-bar { display:block; width:100%; height:12px; border-radius:999px; overflow:hidden; margin:.6rem 0 1rem; background:var(--aim-neutral-soft); }
.chart-successful { fill:var(--aim-success); background:var(--aim-success); }
.chart-failed { fill:var(--aim-danger); background:var(--aim-danger); }
.chart-unreachable { fill:var(--aim-warning); background:var(--aim-warning); }
.chart-not_started { fill:var(--aim-muted); background:var(--aim-muted); }
.chart-indeterminate { fill:var(--aim-info); background:var(--aim-info); }
.chart-unavailable { fill:var(--aim-neutral); background:var(--aim-neutral); }
.chart-outstanding { fill:var(--aim-accent); background:var(--aim-accent); }
.outcome-legend { display:flex; gap:.6rem 1.15rem; flex-wrap:wrap; font-size:.76rem; margin-bottom:.8rem; }
.outcome-legend>span { display:flex; gap:.4rem; align-items:center; }
.legend-dot { display:inline-block; width:8px; height:8px; border-radius:50%; }
.target-status-unavailable,.target-status-outstanding { background:var(--aim-neutral-soft); color:var(--aim-neutral); }
.history-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:.65rem 1rem; padding:1.2rem; border-bottom:1px solid var(--aim-border); }
.history-item:last-child { border-bottom:0; }
.history-item-result { display:flex; flex-direction:column; align-items:flex-end; gap:.45rem; }
.history-item-main { display:grid; gap:.4rem; min-width:0; }
.counter-details { grid-column:1/-1; }
.counter-details summary { font-size:.76rem; cursor:pointer; color:var(--aim-muted); padding:.3rem 0; }
.counter-grid { display:flex; gap:1.25rem; flex-wrap:wrap; margin:.7rem 0 0; padding:1rem; background:var(--aim-bg); border-radius:.5rem; }
.counter-grid dt { font-size:.7rem; color:var(--aim-muted); font-weight:500; }
.counter-grid dd { font-size:.95rem; margin:0; font-variant-numeric:tabular-nums; }
.book-summary-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(230px,1fr)); }
.book-summary { display:grid; gap:.45rem; padding:1.2rem; color:var(--aim-ink); border-bottom:1px solid var(--aim-border); }
.book-summary:hover { background:var(--aim-surface-raised); text-decoration:none; }
.book-summary>span { font-size:.76rem; color:var(--aim-muted); }
.pagination-controls { display:flex; gap:.65rem; flex-wrap:wrap; align-items:center; margin-top:1rem; }
.insights-matrix table { min-width:640px; }
.insights-matrix th { min-width:170px; max-width:240px; }
.insights-matrix th:first-child { min-width:220px; }
.matrix-result { display:grid; gap:.4rem; justify-items:start; min-width:150px; }
.matrix-result>span:not(.status-tag,.mode-tag) { color:var(--aim-muted); font-size:.72rem; }
.matrix-mobile { display:none; }
.mobile-cell { display:grid; gap:.45rem; padding:.65rem 0; border-top:1px solid var(--aim-border); }
.mobile-cell strong { font-size:.83rem; }
.explorer-summary { display:flex; align-items:center; flex-wrap:wrap; gap:1rem; justify-content:space-between; padding:1rem 1.3rem; border-left:3px solid var(--aim-accent); background:var(--aim-surface); border-radius:0 .65rem .65rem 0; }
.explorer-summary strong { font-size:1.6rem; letter-spacing:-.03em; }
.summary-separator { color:var(--aim-border); margin:0 .65rem; }
.explorer-search { display:grid; gap:.5rem; margin:1.4rem 0; }
.explorer-search>label { font-size:.8rem; font-weight:650; }
.explorer-search>div { display:flex; gap:.65rem; }
.explorer-search input { flex:1; min-width:0; }
.explorer-toolbar { display:flex; flex-wrap:wrap; gap:1rem; align-items:center; justify-content:space-between; }
.view-switch { display:flex; gap:.4rem; }
.view-switch [aria-current] { color:var(--aim-accent-ink); background:var(--aim-accent-soft); border-color:var(--aim-pill-border); }
.explorer-panel { overflow:hidden; }
.map-controls { display:flex; gap:1rem; justify-content:space-between; align-items:center; padding:.8rem 1rem; }
.zoom-controls { display:flex; gap:.35rem; }
.zoom-controls button { min-width:40px; min-height:40px; padding:.3rem .7rem; border:1px solid var(--aim-border); background:var(--aim-surface); color:var(--aim-ink); border-radius:.4rem; }
.zoom-controls button:disabled { opacity:.4; }
.inventory-canvas { max-height:58dvh; min-height:240px; overflow:auto; overscroll-behavior:contain; background-color:var(--aim-map-bg); background-image:radial-gradient(var(--aim-graph-dot) 1px,transparent 1px); background-size:18px 18px; border-block:1px solid var(--aim-border); }
.inventory-canvas svg { display:block; max-width:none; }
.graph-edge { stroke:var(--aim-graph-edge); stroke-width:1.5; fill:none; }
.graph-node rect { fill:var(--aim-surface); stroke:var(--aim-border); stroke-width:1.5; }
.graph-node:hover rect,.graph-node:focus rect { stroke:var(--aim-accent-ink); stroke-width:2.5; }
.graph-node:focus { outline:none; }
.graph-focus rect { fill:var(--aim-accent-soft); stroke:var(--aim-pill-border); }
.graph-group rect { fill:var(--aim-surface-raised); }
.graph-symbol { font-size:22px; fill:var(--aim-accent-ink); }
.graph-label { font-size:12px; font-weight:650; fill:var(--aim-ink); }
.graph-focus .graph-label { font-size:17px; }
.graph-detail { font-size:11px; fill:var(--aim-muted); }
.graph-note rect { fill:var(--aim-bg); stroke-dasharray:4; }
.inventory-group-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1rem; padding:1.2rem; }
.inventory-group-card { padding:1rem; border:1px solid var(--aim-border); border-radius:.65rem; background:var(--aim-surface-raised); }
.group-card-title { display:flex; align-items:center; gap:.6rem; color:var(--aim-ink); margin-bottom:.65rem; }
.group-card-title>strong { flex:1; min-width:0; }
.branch-hosts { list-style:none; padding:0; margin:.85rem 0; }
.branch-hosts li { padding:.35rem 0; font-size:.8rem; }
.branch-hosts a { color:var(--aim-ink); }
.inventory-direct { padding:1.2rem; border-top:1px solid var(--aim-border); }
.direct-host-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:.8rem; }
.host-node-link { display:flex; align-items:center; gap:.65rem; color:var(--aim-ink); padding:.85rem; border:1px solid var(--aim-border); border-radius:.6rem; min-width:0; }
.host-node-link>span:last-child { display:grid; gap:.3rem; min-width:0; font-size:.8rem; }
.host-node-link small { color:var(--aim-muted); }
.view-map .explorer-outline,.view-auto .explorer-outline { display:none; }
.view-outline .explorer-map { display:none; }
.experience-links { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1rem; margin:1.5rem 0; }
.experience-link { padding:1.3rem; background:var(--aim-surface); border:1px solid var(--aim-border); border-radius:var(--aim-radius); display:flex; gap:1rem; color:var(--aim-ink); }
.experience-link>span:last-child { display:grid; gap:.4rem; }
.experience-link small { color:var(--aim-muted); line-height:1.5; }
@media(max-width:760px) {
.sidebar,.topbar { display:none; }
.app-shell { display:block; min-height:calc(100dvh - 64px); }
.mobile-topbar { display:flex; align-items:center; justify-content:space-between; gap:.65rem; min-height:64px; padding:.5rem max(.75rem,env(safe-area-inset-right)) .5rem max(.75rem,env(safe-area-inset-left)); background:var(--aim-surface); border-bottom:1px solid var(--aim-border); position:sticky; top:0; z-index:500; }
.mobile-brand { display:flex; align-items:center; gap:.6rem; font-size:1.25rem; color:var(--aim-ink); flex-shrink:0; }
.mobile-brand .brand-mark { width:36px; height:36px; font-size:1.35rem; border-radius:.55rem; }
.mobile-actions { display:flex; align-items:center; gap:.5rem; }
.mobile-actions .theme-control { padding:.13rem; }
.mobile-actions .theme-option { width:42px; height:42px; }
.mobile-menu { position:static; }
.mobile-menu summary { display:grid; place-items:center; width:44px; height:44px; cursor:pointer; border:1px solid var(--aim-border); border-radius:.5rem; color:var(--aim-ink); list-style:none; background:var(--aim-surface-raised); }
.mobile-menu summary::-webkit-details-marker { display:none; }
.mobile-menu summary svg { stroke:currentColor; stroke-width:1.7; fill:none; stroke-linecap:round; }
.mobile-menu[open] summary { color:var(--aim-accent-ink); background:var(--aim-accent-soft); }
.mobile-menu-panel { position:absolute; top:calc(100% + .35rem); right:max(.75rem,env(safe-area-inset-right)); width:min(350px,calc(100vw - 1.5rem)); max-height:calc(100dvh - 85px); overflow-y:auto; overscroll-behavior:contain; padding:.8rem; border:1px solid var(--aim-border); border-radius:.8rem; box-shadow:var(--aim-shadow); background:var(--aim-surface); }
.mobile-menu .primary-nav { display:flex; flex-direction:column; flex-wrap:nowrap; gap:.2rem; overflow:visible; padding:0; }
.mobile-menu .nav-link { display:flex; align-items:center; gap:.8rem; color:var(--aim-ink); font-size:.88rem; min-height:44px; padding:.7rem .8rem; border-radius:.5rem; white-space:normal; }
.mobile-menu .nav-link.active { background:var(--aim-accent-soft); color:var(--aim-accent-ink); box-shadow:inset 3px 0 var(--aim-accent); }
.mobile-menu .nav-link:hover { background:var(--aim-neutral-soft); text-decoration:none; }
.mobile-menu .sidebar-caption { display:block; margin:0; padding:.75rem .8rem .4rem; color:var(--aim-muted); }
.mobile-account { display:flex; flex-wrap:wrap; gap:.6rem; align-items:center; margin-top:.75rem; padding:1rem .75rem .4rem; border-top:1px solid var(--aim-border); font-size:.8rem; }
.mobile-account .account-name { max-width:100%; overflow-wrap:anywhere; }
.mobile-account form { margin:0; }
.mobile-account .btn { min-height:44px; }
.context-links { gap:.65rem 1rem; }
.context-links a { min-height:40px; display:flex; align-items:center; }
.history-filters { grid-template-columns:repeat(2,minmax(0,1fr)); padding:.9rem; gap:.9rem; }
.history-filters .form-control,.history-filters .form-select { min-height:44px; }
.history-item { grid-template-columns:minmax(0,1fr); padding:1rem; }
.history-item-result { align-items:flex-start; }
.insight-metrics { gap:.5rem; }
.insight-metrics .card { padding:.8rem; }
.insight-metrics strong { font-size:1.6rem; }
.insight-metrics .eyebrow { font-size:.55rem; letter-spacing:.06em; }
.insights-matrix { display:none; }
.matrix-mobile { display:block; }
.matrix-mobile .history-item { display:grid; grid-template-columns:minmax(0,1fr); }
.book-summary-grid { grid-template-columns:1fr; }
.inventory-group-grid,.direct-host-grid { grid-template-columns:minmax(0,1fr); }
.view-auto .explorer-map { display:none; }
.view-auto .explorer-outline { display:block; }
.inventory-canvas { min-height:220px; max-height:52dvh; }
.explorer-toolbar { padding:1rem; }
.view-switch .btn { min-height:44px; display:flex; align-items:center; }
.map-controls { flex-wrap:wrap; gap:.5rem; }
.zoom-controls button { min-width:44px; min-height:44px; }
.experience-links { grid-template-columns:minmax(0,1fr); }
.host-identity { padding:1rem; }
.live-console { min-height:0; max-height:60dvh; height:50dvh; }
}
@media(max-width:380px) {
.insight-metrics { grid-template-columns:1fr; }
.insight-metrics .card { display:grid; grid-template-columns:1fr auto; gap:.1rem .5rem; }
.insight-metrics strong { grid-column:2; grid-row:1/3; }
.history-filters { grid-template-columns:1fr; }
.explorer-search>div { flex-wrap:wrap; }
.explorer-search input { flex-basis:100%; }
}
@@ -0,0 +1,92 @@
/* AIM identity: change these tokens, not individual page templates. */
:root,
html[data-theme="light"] {
--aim-accent: #f58220;
--aim-on-accent: #202a39;
--aim-accent-rgb: 245, 130, 32;
--aim-accent-hover: #df7017;
--aim-accent-ink: #913900;
--aim-accent-soft: #fff1e5;
--aim-bg: #f5f6f8;
--aim-surface: #ffffff;
--aim-surface-raised: #ffffff;
--aim-ink: #202a39;
--aim-muted: #5d6879;
--aim-border: #e1e5ea;
--aim-sidebar: #18212e;
--aim-sidebar-muted: #a9b5c6;
--aim-sidebar-link: #d5ddea;
--aim-sidebar-hover: #253244;
--aim-sidebar-active: #352c27;
--aim-sidebar-active-ink: #ffac68;
--aim-danger: #a32f40;
--aim-info: #385eb1;
--aim-map-bg: #f4f6f9;
--aim-graph-edge: #bcc7d4;
--aim-graph-dot: #dce3ec;
--aim-console-bg: #17191d;
--aim-console-ink: #e6e7e9;
--aim-success: #176c4b;
--aim-success-soft: #e9f5ee;
--aim-warning: #785400;
--aim-warning-soft: #fff4df;
--aim-neutral: #495468;
--aim-neutral-soft: #eef1f6;
--aim-table-head: #fafbfc;
--aim-code-panel: #f6f7f9;
--aim-note-bg: #fff8f1;
--aim-note-ink: #5a483a;
--aim-banner-start: #fff8f1;
--aim-banner-end: #ffffff;
--aim-banner-border: #f0d9c6;
--aim-pill-border: #edcfb6;
--aim-link-hover: #632700;
--aim-dialog-backdrop: rgb(13 20 30 / 65%);
--aim-radius: .75rem;
--aim-shadow: 0 2px 6px rgb(24 33 46 / 3%);
--aim-space: 1.5rem;
}
html[data-theme="dark"] {
--aim-accent: #f58b32;
--aim-on-accent: #202a39;
--aim-accent-rgb: 245, 139, 50;
--aim-accent-hover: #ff9d52;
--aim-accent-ink: #ffb06f;
--aim-accent-soft: #3a291f;
--aim-bg: #171b21;
--aim-surface: #20262f;
--aim-surface-raised: #252c36;
--aim-ink: #e8edf3;
--aim-muted: #a7b0bc;
--aim-border: #343d49;
--aim-sidebar: #131920;
--aim-sidebar-muted: #929eac;
--aim-sidebar-link: #c7d0db;
--aim-sidebar-hover: #202a35;
--aim-sidebar-active: #382b22;
--aim-sidebar-active-ink: #ffb678;
--aim-danger: #f395a4;
--aim-info: #96b7ff;
--aim-map-bg: #1a2029;
--aim-graph-edge: #435268;
--aim-graph-dot: #313b4b;
--aim-console-bg: #17191d;
--aim-console-ink: #e6e7e9;
--aim-success: #7bd1a8;
--aim-success-soft: #1d382d;
--aim-warning: #e4c676;
--aim-warning-soft: #3b321f;
--aim-neutral: #c2cad5;
--aim-neutral-soft: #2b333e;
--aim-table-head: #252c35;
--aim-code-panel: #181e25;
--aim-note-bg: #2b231d;
--aim-note-ink: #d8c3b1;
--aim-banner-start: #2b231d;
--aim-banner-end: #20262f;
--aim-banner-border: #4a392d;
--aim-pill-border: #5a4030;
--aim-link-hover: #ffc28f;
--aim-shadow: 0 8px 24px rgb(0 0 0 / 16%);
}
@@ -0,0 +1,227 @@
'use strict';
// No inline handlers, no eval, no inventory or authentication tokens in browser storage.
document.addEventListener('htmx:configRequest', function (event) {
const token = document.querySelector('meta[name="csrf-token"]');
if (token) event.detail.headers['X-CSRF-Token'] = token.content;
});
document.addEventListener('htmx:beforeSwap', function (event) {
if (event.detail.xhr.status >= 400 && event.detail.xhr.status < 600) {
event.detail.shouldSwap = true;
event.detail.isError = false;
}
});
function formatLocalTimes(root) {
const scope = root || document;
const items = Array.from(scope.querySelectorAll('time.js-local-time[datetime]'));
if (scope.matches && scope.matches('time.js-local-time[datetime]')) items.unshift(scope);
items.forEach(function (element) {
const date = new Date(element.getAttribute('datetime'));
if (Number.isNaN(date.getTime())) return;
try {
element.textContent = new Intl.DateTimeFormat(undefined, {dateStyle: 'medium', timeStyle: 'medium'}).format(date);
element.title = element.getAttribute('datetime') + ' (UTC)';
} catch (_) {
// Keep the server-rendered UTC fallback when Intl formatting is unavailable.
}
});
}
function hostGroups(input) {
try { return JSON.parse(input.dataset.groups || '[]'); }
catch (_) { return []; }
}
function allHosts() { return Array.from(document.querySelectorAll('[data-host-target]')); }
function visibleHosts() { return allHosts().filter(function (host) { return !host.closest('tr').hidden; }); }
function syncHostSelection() {
const hosts = allHosts();
const visible = visibleHosts();
const selected = hosts.filter(function (host) { return host.checked; }).length;
const selectedVisible = visible.filter(function (host) { return host.checked; }).length;
const all = document.querySelector('[data-select-all-hosts]');
const counter = document.querySelector('[data-selected-count]');
if (counter) counter.textContent = selected + ' selected' + (selected > 500 ? ' (limit 500)' : '');
const visibleCount = document.querySelector('[data-visible-count]');
if (visibleCount) visibleCount.textContent = visible.length + ' of ' + hosts.length + ' hosts match.';
if (all) {
all.checked = visible.length > 0 && selectedVisible === visible.length;
all.indeterminate = selectedVisible > 0 && selectedVisible < visible.length;
all.disabled = visible.length === 0;
}
document.querySelectorAll('[data-group-select]').forEach(function (group) {
const members = hosts.filter(function (host) { return hostGroups(host).includes(group.value); });
const checked = members.filter(function (host) { return host.checked; }).length;
group.checked = members.length > 0 && checked === members.length;
group.indeterminate = checked > 0 && checked < members.length;
});
}
function initializeHostSelection() {
const all = document.querySelector('[data-select-all-hosts]');
if (!all || all.dataset.initialized) return;
all.dataset.initialized = 'true';
const form = all.closest('form');
let timer;
let revision = 0;
function remember() {
syncHostSelection();
clearTimeout(timer);
const sequence = ++revision;
timer = setTimeout(async function () {
const status = document.querySelector('[data-selection-saved]');
const targets = allHosts().filter(function (h) { return h.checked; }).map(function (h) { return h.value; });
if (targets.length > 500) {
if (status) status.textContent = 'Select at most 500 hosts before saving or validating.';
return;
}
try {
const token = document.querySelector('meta[name="csrf-token"]').content;
const result = await fetch('/api/v2/selections', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': token},
body: JSON.stringify({customer: form.elements.customer.value, playbook: form.elements.playbook.value, targets: targets})
});
if (!result.ok) throw new Error('not saved');
if (status && revision === sequence) status.textContent = 'Selection saved privately for seven days.';
} catch (_) {
if (status && revision === sequence) status.textContent = 'Selection could not be saved. Your checkboxes are unchanged; reload after signing in.';
}
}, 300);
}
all.addEventListener('change', function () {
visibleHosts().forEach(function (host) { host.checked = all.checked; });
remember();
});
document.querySelectorAll('[data-group-select]').forEach(function (group) {
group.addEventListener('change', function () {
allHosts().forEach(function (host) { if (hostGroups(host).includes(group.value)) host.checked = group.checked; });
remember();
});
});
allHosts().forEach(function (host) { host.addEventListener('change', remember); });
const clear = document.querySelector('[data-clear-hosts]');
if (clear) clear.addEventListener('click', function () {
allHosts().forEach(function (host) { host.checked = false; }); remember();
});
const search = document.querySelector('[data-host-search]');
const group = document.querySelector('[data-host-group-filter]');
function filter() {
const q = search.value.toLocaleLowerCase().trim();
allHosts().forEach(function (host) {
const row = host.closest('tr');
const text = [row.dataset.name, row.dataset.address, row.dataset.group].join(' ').toLocaleLowerCase();
row.hidden = !text.includes(q) || (group.value && !hostGroups(host).includes(group.value));
});
syncHostSelection();
}
if (search && group) {
search.addEventListener('input', filter);
group.addEventListener('change', filter);
}
const sort = document.querySelector('[data-host-sort]');
if (sort) sort.addEventListener('change', function () {
const rows = Array.from(document.querySelectorAll('[data-host-row]'));
rows.sort(function (a, b) {
return (a.dataset[sort.value] || '').localeCompare(b.dataset[sort.value] || '', undefined, {numeric: true});
});
rows.forEach(function (row) { row.parentElement.appendChild(row); });
});
syncHostSelection();
}
document.addEventListener('DOMContentLoaded', function () { initializeHostSelection(); formatLocalTimes(document); });
document.addEventListener('htmx:afterSwap', function (event) { initializeHostSelection(); formatLocalTimes(event.detail.target); });
document.addEventListener('htmx:afterSettle', function (event) { formatLocalTimes(event.detail.target); });
window.addEventListener('pageshow', function () { formatLocalTimes(document); });
// Credentials never enter history, browser persistence or reusable form drafts.
window.addEventListener('pagehide', function () {
document.querySelectorAll('[data-secret-form] input[type="password"], [data-credential-secret]').forEach(function (input) { input.value = ''; });
});
function initConfirmForms(root) {
(root || document).querySelectorAll('form[data-confirm]').forEach(function (form) {
if (form.dataset.confirmBound) return; form.dataset.confirmBound='1';
form.addEventListener('submit', function (event) { if (!window.confirm(form.dataset.confirm)) event.preventDefault(); });
});
}
document.addEventListener('DOMContentLoaded', function () { initConfirmForms(document); });
document.addEventListener('htmx:afterSwap', function (event) { initConfirmForms(event.detail.target); });
function initBulkForms(root) {
(root || document).querySelectorAll('[data-bulk-form]').forEach(function (form) {
if (form.dataset.bulkBound) return;
form.dataset.bulkBound = '1';
const all = form.querySelector('[data-bulk-select-all]');
const items = Array.from(form.querySelectorAll('[data-bulk-item]'));
const submit = form.querySelector('[data-bulk-submit]');
const sync = function () {
const checked = items.filter(function (item) { return item.checked; }).length;
if (all) {
all.checked = items.length > 0 && checked === items.length;
all.indeterminate = checked > 0 && checked < items.length;
all.disabled = items.length === 0;
}
if (submit) submit.disabled = checked === 0;
};
if (all) all.addEventListener('change', function () {
items.forEach(function (item) { item.checked = all.checked; });
sync();
});
items.forEach(function (item) { item.addEventListener('change', sync); });
sync();
});
}
document.addEventListener('DOMContentLoaded', function () { initBulkForms(document); });
document.addEventListener('htmx:afterSwap', function (event) { initBulkForms(event.detail.target); });
function initJobConsole(root) {
(root || document).querySelectorAll('[data-job-console]').forEach(function (panel) {
if (panel.dataset.consoleBound) return;
panel.dataset.consoleBound = '1';
const output = panel.querySelector('[data-console-output]');
const state = panel.querySelector('[data-console-state]');
const auto = panel.querySelector('[data-console-autoscroll]');
if (!output || panel.dataset.consoleActive !== 'true' || !window.EventSource) return;
output.textContent = '';
let internalScroll = false;
output.addEventListener('scroll', function () {
if (internalScroll || !auto || !auto.checked) return;
const atBottom = output.scrollHeight - output.scrollTop - output.clientHeight < 24;
if (!atBottom) auto.checked = false;
}, {passive:true});
if (auto) auto.addEventListener('change', function () {
if (auto.checked) { internalScroll=true; output.scrollTop=output.scrollHeight;
requestAnimationFrame(function () { internalScroll=false; }); }
});
const lines = [];
const append = function (text, kind) {
if (typeof text !== 'string' || !text) return;
lines.push((kind === 'notice' ? '[AIM] ' : '') + text);
while (lines.length > 500) lines.shift();
output.textContent = lines.join('\n') + '\n';
if (auto && auto.checked) { internalScroll=true; output.scrollTop=output.scrollHeight; requestAnimationFrame(function(){ internalScroll=false; }); }
};
const source = new EventSource('/api/v2/runs/' + encodeURIComponent(panel.dataset.jobId) + '/console');
panel._aimConsoleSource = source;
source.addEventListener('open', function () { if (state) state.textContent = 'Live'; });
['line','notice'].forEach(function (kind) {
source.addEventListener(kind, function (event) {
try { append(JSON.parse(event.data).text, kind); } catch (_) {}
});
});
source.addEventListener('end', function (event) {
try { append(JSON.parse(event.data).text, 'notice'); } catch (_) {}
if (state) state.textContent = 'Ended';
source.close();
});
source.onerror = function () { if (state && source.readyState !== EventSource.CLOSED) state.textContent = 'Reconnecting'; };
});
}
document.addEventListener('DOMContentLoaded', function () { initJobConsole(document); });
window.addEventListener('pagehide', function () {
document.querySelectorAll('[data-job-console]').forEach(function (panel) {
if (panel._aimConsoleSource) panel._aimConsoleSource.close();
const output = panel.querySelector('[data-console-output]');
if (output) output.textContent = 'Live console cleared on navigation.';
});
});
@@ -0,0 +1,348 @@
'use strict';
// Presentation only. No browser secret cache, automatic POST retry or scope edits.
(function () {
const sessions = new Set();
const dialog = document.querySelector('[data-credential-dialog]');
const body = dialog && dialog.querySelector('[data-credential-dialog-body]');
let opener = null;
let openedJob = null;
let loadController = null;
let loadSequence = 0;
const uncertainJobs = new Set(); // IDs only; cleared when the page is left.
const terminal = new Set(['successful', 'failed', 'blocked', 'canceled', 'timed_out', 'interrupted']);
function clearSecrets(root) {
root.querySelectorAll('[data-credential-secret]').forEach(function (input) {
input.value = '';
input.type = 'password';
});
root.querySelectorAll('[data-credential-reveal]').forEach(function (button) {
button.textContent = 'Show';
button.setAttribute('aria-pressed', 'false');
button.setAttribute('aria-label', button.getAttribute('aria-label').replace(/^Hide /, 'Show '));
});
root.querySelectorAll('[data-credential-caps]').forEach(function (note) { note.hidden = true; });
}
function feedback(ctx, text, tone, focus) {
ctx.feedback.textContent = text;
ctx.feedback.dataset.tone = tone || 'info';
ctx.feedback.hidden = false;
if (focus) ctx.feedback.focus({preventScroll: true});
}
function finish(ctx, text, accepted) {
if (ctx.closed) return;
clearSecrets(ctx.panel);
ctx.locked = true;
ctx.inputs.disabled = true;
ctx.inputs.hidden = true;
ctx.submit.disabled = true;
ctx.submit.hidden = true;
ctx.panel.querySelector('.credential-intro').hidden = true;
ctx.panel.querySelector('.credential-reservation').hidden = true;
ctx.panel.querySelector('[data-credential-submit-note]').hidden = true;
ctx.dismiss.textContent = 'View job progress';
feedback(ctx, text, accepted ? 'accepted' : 'error', true);
}
function expire(ctx) {
if (ctx.busy || ctx.locked || ctx.closed) return;
finish(ctx, 'This credential window has expired. Check the job before creating a fresh attempt. No further credentials will be submitted from this dialog.', false);
}
function clock(ctx, serverNow, deadline) {
ctx.remainingAtSync = Math.max(0, Number(deadline) - Number(serverNow));
ctx.syncedAt = performance.now();
}
function tick(ctx) {
if (ctx.closed || ctx.locked) return;
const seconds = Math.max(0, Math.ceil(ctx.remainingAtSync - (performance.now() - ctx.syncedAt) / 1000));
ctx.countdown.textContent = 'Time left ' + String(Math.floor(seconds / 60)).padStart(2, '0') + ':' + String(seconds % 60).padStart(2, '0');
ctx.countdown.closest('.credential-reservation').classList.toggle('is-urgent', seconds <= 60);
if (seconds <= 60 && !ctx.warned && seconds > 0 && !ctx.busy && !ctx.uncertain) {
ctx.warned = true;
feedback(ctx, 'Less than one minute remains in this reservation. Opening the form does not extend it.', 'info', false);
}
if (seconds === 0) expire(ctx);
}
function sourceMode(ctx) {
const choice = ctx.form.querySelector('[data-key-source-choice]');
if (!choice) return;
const separate = choice.querySelector('input[value="separate"]').checked;
const field = ctx.form.querySelector('[data-key-passphrase-field]');
const input = field.querySelector('[data-credential-secret]');
field.hidden = !separate;
input.disabled = !separate;
input.required = separate;
if (!separate) clearSecrets(field);
choice.querySelector('[data-key-source-explanation]').textContent = separate
? 'Supply the private key passphrase for this run only. This does not change the reviewed authentication mode.'
: "Uses the Vault's stored key passphrase, checked after submission.";
}
async function poll(ctx) {
if (ctx.closed || ctx.checking || (ctx.locked && !ctx.uncertain)) return;
ctx.checking = true;
try {
const response = await fetch('/api/v2/runs/' + ctx.job + '/credential-status', {
credentials: 'same-origin', cache: 'no-store', headers: {'Accept': 'application/json'}, signal: ctx.getController.signal
});
if (ctx.closed) return;
if ([401, 403, 404].includes(response.status)) {
finish(ctx, 'This credential form is no longer available to this session. Return to the job or sign in again.', false);
ctx.uncertain = false;
return;
}
if (!response.ok) return; // Expiry remains local display only; POST always revalidates.
const state = await response.json();
if (ctx.closed || state.job_id !== ctx.job) return;
if (state.cancel_requested || terminal.has(state.status)) {
ctx.uncertain = false;
finish(ctx, 'This run has ended or cancellation was requested. Review the job result; do not resubmit credentials here.', false);
} else if (state.status === 'running' && state.phase === 'claimed') {
uncertainJobs.delete(ctx.job);
ctx.uncertain = false;
finish(ctx, 'The worker has claimed the credential handoff. Core validates credentials next; this is not confirmation of successful authentication.', true);
} else if (state.can_submit && !ctx.locked && !ctx.uncertain) {
clock(ctx, state.server_now, state.deadline);
tick(ctx);
} else if (!state.can_submit && !ctx.busy) {
ctx.uncertain = false;
finish(ctx, 'The worker is no longer accepting credentials for this reservation. Return to the job for its current status.', false);
}
} catch (_) {
// Never echo response bodies, exceptions, form values or credentials.
} finally { ctx.checking = false; }
}
function validationMessage(ctx) {
for (const input of ctx.form.querySelectorAll('[data-credential-secret]')) {
if (input.disabled) continue;
const value = input.value;
if (/[\r\n\u0000]/.test(value) || new TextEncoder().encode(value).length > 2048) {
input.focus();
return 'Use a single-line password of at most 2048 UTF-8 bytes. Spaces and other literal characters are preserved.';
}
}
return '';
}
async function submit(ctx, event) {
event.preventDefault();
if (ctx.busy || ctx.locked || ctx.uncertain || ctx.closed) return;
tick(ctx);
if (ctx.locked || !ctx.form.reportValidity()) return;
const invalid = validationMessage(ctx);
if (invalid) { feedback(ctx, invalid, 'error', false); return; }
let values = {};
ctx.form.querySelectorAll('[data-credential-secret]').forEach(function (input) {
if (!input.disabled && input.value !== '') values[input.name] = input.value;
});
let encoded = JSON.stringify(values);
if (new TextEncoder().encode(encoded).length > 8192) {
values = null; encoded = null;
feedback(ctx, 'The combined credential request exceeds 8 KiB. Nothing was submitted.', 'error', false);
return;
}
const csrf = ctx.form.querySelector('input[name="_csrf"]').value;
ctx.busy = true;
ctx.submit.disabled = true;
ctx.inputs.disabled = true;
ctx.form.setAttribute('aria-busy', 'true');
ctx.submit.textContent = 'Submitting...';
feedback(ctx, 'Submitting once. Core has not yet verified these credentials.', 'info', false);
// Minimize live DOM lifetime, including a currently revealed input.
clearSecrets(ctx.panel);
const controller = new AbortController();
const timeout = setTimeout(function () { controller.abort(); }, 15000);
try {
const request = fetch('/api/v2/runs/' + ctx.job + '/credentials', {
method: 'POST', credentials: 'same-origin', cache: 'no-store', redirect: 'error',
headers: {'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-Token': csrf},
body: encoded, signal: controller.signal
});
values = null; encoded = null; // Not a physical memory-erasure guarantee.
const response = await request;
const result = await response.json();
if (response.status === 202 && result.accepted === true) {
uncertainJobs.delete(ctx.job);
if (!ctx.closed) finish(ctx, 'Credential handoff accepted for this run. Core validates the Vault, key or connection next. Follow the job for the result.', true);
} else if (response.status === 400 && result.error && ['credential_required', 'invalid_credentials', 'unexpected_credentials'].includes(result.error.code)) {
if (!ctx.closed) {
ctx.inputs.disabled = false;
ctx.submit.disabled = false;
feedback(ctx, 'The credential fields were not accepted. Enter the required values again; nothing is saved or echoed here.', 'error', true);
}
} else if (response.status === 429) {
if (!ctx.closed) finish(ctx, 'Too many credential submissions. Check the job and wait before making another manual attempt.', false);
} else if ([401, 403, 404].includes(response.status)) {
if (!ctx.closed) finish(ctx, 'Your session or permission no longer allows this submission. Return to the job or sign in again.', false);
} else {
uncertainJobs.add(ctx.job);
if (!ctx.closed) {
ctx.uncertain = true;
finish(ctx, 'Submission was not confirmed. Checking the job status; do not submit the password again. Closing this dialog does not cancel a submitted run.', false);
}
}
} catch (_) {
uncertainJobs.add(ctx.job);
if (!ctx.closed) {
ctx.uncertain = true;
finish(ctx, 'Submission was not confirmed. Checking the job status; do not submit the password again. Closing this dialog does not cancel a submitted run.', false);
}
} finally {
values = null; encoded = null; clearTimeout(timeout);
ctx.busy = false;
ctx.form.removeAttribute('aria-busy');
ctx.submit.textContent = 'Submit credentials and continue';
if (!ctx.closed) { clearSecrets(ctx.panel); poll(ctx); }
}
}
function initialize(panel) {
if (panel.dataset.credentialBound) return;
panel.dataset.credentialBound = 'true';
const form = panel.querySelector('[data-credential-form]');
if (!form || !/^[a-f0-9]{32}$/.test(panel.dataset.jobId)) return;
const ctx = {panel: panel, form: form, job: panel.dataset.jobId, feedback: panel.querySelector('[data-credential-feedback]'),
inputs: form.querySelector('[data-credential-inputs]'), submit: form.querySelector('[data-credential-submit]'),
dismiss: form.querySelector('[data-credential-dismiss]'), countdown: panel.querySelector('[data-credential-countdown]'),
busy: false, closed: false, locked: false, checking: false, uncertain: false, warned: false, getController: new AbortController()};
sessions.add(ctx);
clock(ctx, panel.dataset.serverNow, panel.dataset.deadline);
panel.querySelectorAll('[data-credential-reveal]').forEach(function (button) {
button.hidden = false;
button.addEventListener('click', function () {
const input = panel.querySelector('#' + button.getAttribute('aria-controls'));
const show = input.type === 'password';
input.type = show ? 'text' : 'password';
button.textContent = show ? 'Hide' : 'Show';
button.setAttribute('aria-pressed', String(show));
button.setAttribute('aria-label', button.getAttribute('aria-label').replace(/^(Show|Hide) /, show ? 'Hide ' : 'Show '));
});
});
panel.querySelectorAll('[data-credential-secret]').forEach(function (input) {
function caps(event) { input.closest('.credential-field').querySelector('[data-credential-caps]').hidden = !event.getModifierState('CapsLock'); }
input.addEventListener('keydown', caps); input.addEventListener('keyup', caps);
input.addEventListener('blur', function () { input.closest('.credential-field').querySelector('[data-credential-caps]').hidden = true; });
});
const choice = form.querySelector('[data-key-source-choice]');
if (choice) {
choice.hidden = false;
choice.querySelectorAll('input').forEach(function (input) { input.disabled = false; });
choice.addEventListener('change', function () { sourceMode(ctx); }); sourceMode(ctx);
}
form.addEventListener('submit', function (event) { submit(ctx, event); });
ctx.dismiss.addEventListener('click', function (event) {
if (dialog && dialog.contains(panel)) { event.preventDefault(); closeDialog(); }
});
ctx.tickTimer = setInterval(function () { tick(ctx); }, 1000);
ctx.pollTimer = setInterval(function () { poll(ctx); }, 3000);
tick(ctx);
if (uncertainJobs.has(ctx.job)) {
ctx.uncertain = true;
finish(ctx, 'A previous submission has not been confirmed. Checking the job; do not resubmit credentials.', false);
}
poll(ctx);
}
function dispose(root) {
sessions.forEach(function (ctx) {
if (root.contains(ctx.panel)) {
ctx.closed = true; ctx.getController.abort(); clearInterval(ctx.tickTimer); clearInterval(ctx.pollTimer);
clearSecrets(ctx.panel); sessions.delete(ctx);
}
});
}
function fitDialog() {
if (!dialog || !dialog.open || !window.visualViewport) return;
const viewport = window.visualViewport;
dialog.style.maxHeight = Math.max(100, viewport.height - 16) + 'px';
dialog.style.top = (viewport.offsetTop + viewport.height / 2) + 'px';
dialog.style.bottom = 'auto'; dialog.style.margin = '0 auto'; dialog.style.transform = 'translateY(-50%)';
}
function closeDialog() {
if (!dialog) return;
++loadSequence;
if (loadController) { loadController.abort(); loadController = null; }
dispose(dialog);
if (dialog.open) dialog.close();
body.replaceChildren();
document.documentElement.classList.remove('credential-modal-open');
let target = opener && opener.isConnected ? opener : null;
if (!target && openedJob) target = document.querySelector('[data-credential-open][data-job-id="' + openedJob + '"]');
if (!target) target = document.querySelector('#job-status') || document.querySelector('main');
if (target) { if (!target.matches('a,button,input')) target.setAttribute('tabindex', '-1'); target.focus({preventScroll: true}); }
opener = null; openedJob = null;
}
async function openDialog(link) {
const job = link.dataset.jobId;
if (!/^[a-f0-9]{32}$/.test(job)) return;
if (dialog.open) return;
opener = link; openedJob = job;
const sequence = ++loadSequence;
const loading = document.createElement('p'); loading.className = 'credential-loading'; loading.setAttribute('role', 'status'); loading.textContent = 'Checking this worker reservation...'; body.replaceChildren(loading);
dialog.showModal(); document.documentElement.classList.add('credential-modal-open'); fitDialog();
dialog.querySelector('[data-credential-title]').focus({preventScroll: true});
loadController = new AbortController();
const timeout = setTimeout(function () { if (sequence === loadSequence && loadController) loadController.abort(); }, 10000);
try {
const response = await fetch('/_partials/jobs/' + job + '/credentials', {credentials: 'same-origin', cache: 'no-store', redirect: 'error', signal: loadController.signal});
if (!response.ok) throw new Error('unavailable');
const html = await response.text();
if (sequence !== loadSequence || !dialog.open) return;
const doc = new DOMParser().parseFromString(html, 'text/html');
const panel = doc.querySelector('[data-credential-panel]');
if (!panel || panel.dataset.jobId !== job) throw new Error('invalid fragment');
body.replaceChildren(document.importNode(panel, true));
initialize(body.querySelector('[data-credential-panel]'));
if (typeof formatLocalTimes === 'function') formatLocalTimes(body);
} catch (_) {
if (sequence !== loadSequence || !dialog.open) return;
const note = document.createElement('p'); note.setAttribute('role', 'alert'); note.textContent = 'This credential window is unavailable or the session changed. Check the job before trying again.';
const back = document.createElement('a'); back.href = '/jobs/' + job; back.className = 'btn btn-outline-primary'; back.textContent = 'Open job'; body.replaceChildren(note, back);
} finally { clearTimeout(timeout); }
}
if (dialog && typeof dialog.showModal === 'function') {
function enhanceOpeners() {
document.querySelectorAll('[data-credential-open]').forEach(function (link) {
link.setAttribute('role', 'button');
link.setAttribute('aria-haspopup', 'dialog');
link.setAttribute('aria-controls', 'credential-dialog');
});
}
enhanceOpeners();
document.addEventListener('htmx:afterSwap', enhanceOpeners);
document.addEventListener('keydown', function (event) {
const link = event.target.closest('[data-credential-open]');
if (link && event.key === ' ' && !event.ctrlKey && !event.metaKey && !event.altKey) {
event.preventDefault(); openDialog(link);
}
});
document.addEventListener('click', function (event) {
const link = event.target.closest('[data-credential-open]');
if (!link || event.defaultPrevented || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
event.preventDefault(); openDialog(link);
});
dialog.querySelector('[data-credential-close]').addEventListener('click', closeDialog);
dialog.addEventListener('cancel', function (event) { event.preventDefault(); closeDialog(); });
dialog.addEventListener('keydown', function (event) {
if (event.key !== 'Tab' || !dialog.open) return;
const focusable = Array.from(dialog.querySelectorAll('button,a[href],input,select,textarea,summary,[tabindex]:not([tabindex="-1"])')).filter(function (element) {
return !element.matches(':disabled') && element.tabIndex >= 0 && element.getClientRects().length > 0;
});
if (!focusable.length) { event.preventDefault(); dialog.querySelector('[data-credential-title]').focus(); return; }
const first = focusable[0], last = focusable[focusable.length - 1];
if (event.shiftKey && (document.activeElement === first || !focusable.includes(document.activeElement))) {
event.preventDefault(); last.focus();
} else if (!event.shiftKey && (document.activeElement === last || !focusable.includes(document.activeElement))) {
event.preventDefault(); first.focus();
}
});
// Deliberate Close/Escape only: a stray backdrop tap cannot erase typed input.
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', fitDialog);
window.visualViewport.addEventListener('scroll', fitDialog);
}
}
document.querySelectorAll('[data-credential-panel]').forEach(initialize);
window.addEventListener('pagehide', function () { dispose(document); closeDialog(); clearSecrets(document); uncertainJobs.clear(); });
window.addEventListener('pageshow', function (event) {
if (event.persisted) {
clearSecrets(document);
document.querySelectorAll('[data-credential-panel]').forEach(function (panel) { delete panel.dataset.credentialBound; initialize(panel); });
}
});
}());
@@ -0,0 +1,123 @@
/* Durable public metadata replay. No secrets, localStorage, execution or POST. */
(function () {
'use strict';
function stamp(value) {
const d = new Date(value);
return Number.isNaN(d.getTime()) ? value : new Intl.DateTimeFormat(undefined, {dateStyle:'medium',timeStyle:'medium'}).format(d);
}
function text(node, value) { if (node) node.textContent = value == null ? 'Not observed' : String(value); }
function dates(root) {
root.querySelectorAll('time[data-evidence-time], time[data-journal-time]').forEach(function (node) {
if (node.dateTime) { text(node, stamp(node.dateTime)); node.title = node.dateTime; }
});
}
async function read(url) {
const response=await fetch(url,{credentials:'same-origin',cache:'no-store',headers:{'Accept':'application/json'}});
if (!response.ok) throw new Error(response.status === 401 || response.status === 403 || response.status === 404 ? 'Access expired or this job is unavailable.' : 'Recorded evidence could not be loaded.');
return await response.json();
}
function journal(panel) {
if (panel.dataset.bound) return;
panel.dataset.bound='1';
const job=panel.dataset.jobId, base='/api/v2/runs/'+encodeURIComponent(job), output=panel.querySelector('[data-journal-output]');
const follow=panel.querySelector('[data-journal-follow]'), state=panel.querySelector('[data-journal-state]'), warning=panel.querySelector('[data-journal-warning]');
const older=panel.querySelector('[data-journal-older]');
let records=[], cursor=0, first=0, internal=false, source=null, alive=true, ended=false, olderTrimmed=false, drawPending=false;
function warningText(data) {
const messages=[];
if (data.omitted_events) messages.push(data.omitted_events+' older events omitted by retention policy.');
if (data.dropped_events) messages.push(data.dropped_events+' events were not recorded due to capture pressure.');
if (data.capture_interrupted) messages.push('Capture did not close cleanly; the last uncommitted batch may be absent.');
if (olderTrimmed) messages.push('The screen shows a bounded recent window; use the paged timeline for the remaining retained metadata.');
text(warning,messages.join(' ')); if(warning) warning.hidden=!messages.length;
}
function snapshot(data) {
const cp=data.checkpoint||{};
text(panel.querySelector('[data-journal-stage]'),cp.stage);
text(panel.querySelector('[data-journal-task]'),cp.last_task ? cp.last_task.label+' ('+cp.last_task.task_id+')' : 'Not observed');
text(panel.querySelector('[data-journal-tasks]'),cp.observed_task_starts||0);
const when=panel.querySelector('[data-journal-time]');
if (when && cp.last_timestamp) {when.dateTime=cp.last_timestamp;text(when,stamp(cp.last_timestamp));when.title=cp.last_timestamp;}
warningText(data);
const list=panel.querySelector('[data-journal-hosts]');
if (list) {
list.replaceChildren();
(cp.hosts||[]).slice(0,25).forEach(function(h){
const row=document.createElement('li');row.textContent=h.host+' - '+h.observation+' ('+h.task_id+')';list.appendChild(row);
});
if ((cp.hosts||[]).length>25) {const row=document.createElement('li');row.textContent='Latest observations for '+cp.hosts.length+' hosts; first 25 shown here. Use the correlated timeline for more.';list.appendChild(row);}
}
}
function render(preserve) {
const scroll=output.scrollTop, previousHeight=output.scrollHeight;
output.textContent=records.map(function(r){return stamp(r.event.timestamp)+' '+r.text;}).join('\n')+(records.length?'\n':'');
first=records.length?records[0].cursor:0;
internal=true;
if (preserve) output.scrollTop=scroll+output.scrollHeight-previousHeight;
else if (follow && follow.checked) output.scrollTop=output.scrollHeight;
else output.scrollTop=scroll;
requestAnimationFrame(function(){internal=false;});
}
function append(record) {
if (!record || !Number.isSafeInteger(record.cursor) || record.cursor<=cursor) return;
cursor=record.cursor;records.push(record);
if(records.length>2000) {records.splice(0,records.length-2000);olderTrimmed=true;}
if(!drawPending){drawPending=true;requestAnimationFrame(function(){drawPending=false;if(alive)render(false);});}
}
output.addEventListener('scroll',function(){if(!internal && follow && follow.checked && output.scrollHeight-output.scrollTop-output.clientHeight>24)follow.checked=false;},{passive:true});
if(follow)follow.addEventListener('change',function(){if(follow.checked)render(false);});
async function connect() {
try {
const data=await read(base+'/progress');if(!alive)return;
records=data.events||[];cursor=data.cursor;first=records.length?records[0].cursor:0;snapshot(data);render(false);
if(!records.length)text(output,data.message||'No metadata recorded yet.');
if(older)older.hidden=!data.has_older;
if(data.terminal){text(state,'Finished - retained');ended=true;return;}
if(!window.EventSource){text(state,'Use Refresh for updated metadata');return;}
source=new EventSource(base+'/progress/stream?after='+cursor);panel._journalSource=source;
source.addEventListener('open',function(){text(state,'Following');});
source.addEventListener('snapshot',function(e){try{snapshot(JSON.parse(e.data));}catch(_){text(state,'Invalid progress metadata');}});
source.addEventListener('line',function(e){try{const data=JSON.parse(e.data);append(data.record);}catch(_){text(state,'Invalid progress event');}});
source.addEventListener('gap',function(e){try{const data=JSON.parse(e.data);text(warning,data.text);warning.hidden=false;}catch(_){} });
source.addEventListener('end',function(e){
source.close();ended=true;text(state,'Ended - retained');
try{const data=JSON.parse(e.data);if(data.clear){records=[];text(output,data.text);text(state,'Access unavailable');return;}}catch(_){}
// Refresh only the final report summary, never the credential modal or window.
const report=document.getElementById('job-reports');
if(report && window.htmx) window.htmx.ajax('GET','/_partials/jobs/'+job+'/reports',{target:report,swap:'outerHTML'});
});
source.onerror=function(){if(!ended)text(state,'Reconnecting - recorded history preserved');};
} catch(e){text(state,'History unavailable');text(output,e.message);}
}
if(older)older.addEventListener('click',async function(e){
if(!window.fetch || !first)return;
e.preventDefault();older.setAttribute('aria-disabled','true');
try{const data=await read(base+'/progress?before='+first);if(!alive)return;
if(follow)follow.checked=false;
if(records.length+(data.events||[]).length>2000){window.location.assign('/jobs/'+job+'/progress?before='+first);return;}
records=(data.events||[]).concat(records);render(true);older.hidden=!data.has_older;
}catch(error){text(state,error.message);}finally{older.removeAttribute('aria-disabled');}
});
panel._journalClose=function(){alive=false;if(source)source.close();records=[];output.textContent='View cleared. Retained metadata remains available after authorized reload.';};
connect();
}
function jsonPanel(node) {
if(node.dataset.bound)return;node.dataset.bound='1';
let loaded=false,loading=false;
const output=node.querySelector('[data-json-output]'), status=node.querySelector('[data-json-status]'),copy=node.querySelector('[data-json-copy]');
node.addEventListener('toggle',async function(){
if(!node.open||loaded||loading)return;loading=true;text(status,'Loading');
try {const data=await read(node.dataset.reportJson);text(output,JSON.stringify(data.data,null,2));loaded=true;if(copy)copy.disabled=false;text(status,data.retention==='metadata_only'?'Retained metadata subset':'Retained report data');}
catch(e){text(status,e.message);}finally{loading=false;}
});
if(copy)copy.addEventListener('click',async function(){
try{await navigator.clipboard.writeText(output.textContent);text(status,'Copied');}
catch(_){text(status,'Copy is unavailable; select the JSON text manually.');}
});
}
function init(root){dates(root);root.querySelectorAll('[data-progress-journal]').forEach(journal);root.querySelectorAll('[data-report-json]').forEach(jsonPanel);}
document.addEventListener('DOMContentLoaded',function(){init(document);});
document.addEventListener('htmx:afterSwap',function(e){init(e.detail.target||document);});
window.addEventListener('pagehide',function(){document.querySelectorAll('[data-progress-journal]').forEach(function(p){if(p._journalClose)p._journalClose();});document.querySelectorAll('[data-json-output]').forEach(function(n){n.textContent='View cleared.';});});
window.addEventListener('pageshow',function(e){if(e.persisted)window.location.reload();});
})();
@@ -0,0 +1,47 @@
'use strict';
// Presentation-only state. No inventory, histories or credentials are persisted here.
(function () {
function initialize(root) {
const menu = document.querySelector('[data-mobile-menu]');
if (menu && !menu.dataset.bound) {
menu.dataset.bound = 'true';
const toggle = menu.querySelector('summary');
const close = (restore) => { menu.open = false; if (restore) toggle.focus(); };
menu.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && menu.open) { e.preventDefault(); close(true); }
});
document.addEventListener('click', (e) => { if (menu.open && !menu.contains(e.target)) close(false); });
menu.addEventListener('focusout', () => requestAnimationFrame(() => {
if (menu.open && !menu.contains(document.activeElement)) close(false);
}));
menu.querySelectorAll('a').forEach((a) => a.addEventListener('click', () => close(false)));
window.addEventListener('resize', () => { if (window.innerWidth > 760) close(false); });
window.addEventListener('pagehide', () => close(false));
}
(root || document).querySelectorAll('[data-explorer]').forEach((panel) => {
if (panel.dataset.graphBound) return;
panel.dataset.graphBound='true';
const graph=panel.querySelector('[data-inventory-graph]');
const box=panel.querySelector('.inventory-canvas');
if (!graph || !box) return;
const bw=Number(graph.dataset.baseWidth), bh=Number(graph.dataset.baseHeight);
let scale=1;
function setSize(n) {
scale=Math.max(.35,Math.min(1.8,n));
graph.setAttribute('width',String(Math.round(bw*scale)));
graph.setAttribute('height',String(Math.round(bh*scale)));
panel.querySelector('[data-graph-zoom="out"]').disabled=scale<=.35;
panel.querySelector('[data-graph-zoom="in"]').disabled=scale>=1.8;
}
// On phones avoid illegible fit-to-width; the graph remains internally scrollable.
setSize(Math.max(.65,Math.min(1,box.clientWidth/bw)));
panel.querySelectorAll('[data-graph-zoom]').forEach((button) => button.addEventListener('click', () => {
const dir=button.dataset.graphZoom;
setSize(dir==='reset'?Math.max(.65,Math.min(1,box.clientWidth/bw)):scale+(dir==='in'?.15:-.15));
}));
});
}
document.addEventListener('DOMContentLoaded',()=>initialize(document));
document.addEventListener('htmx:afterSwap',(e)=>initialize(e.detail.target));
window.addEventListener('pageshow',()=>initialize(document));
}());
@@ -0,0 +1,45 @@
'use strict';
(function () {
const key = 'aim-web-display-theme';
const valid = new Set(['light', 'dark']);
function storedTheme() {
try {
const value = window.localStorage.getItem(key);
return valid.has(value) ? value : 'light';
} catch (_) {
return 'light';
}
}
function syncButtons(theme) {
document.querySelectorAll('[data-theme-option]').forEach(function (button) {
const active = button.dataset.themeOption === theme;
button.setAttribute('aria-pressed', active ? 'true' : 'false');
button.classList.toggle('active', active);
});
}
function applyTheme(theme) {
const value = valid.has(theme) ? theme : 'light';
document.documentElement.dataset.theme = value;
document.documentElement.dataset.bsTheme = value;
document.documentElement.style.colorScheme = value;
syncButtons(value);
}
applyTheme(storedTheme());
document.addEventListener('DOMContentLoaded', function () {
const buttons = document.querySelectorAll('[data-theme-option]');
if (!buttons.length) return;
syncButtons(document.documentElement.dataset.theme || 'light');
buttons.forEach(function (button) {
button.addEventListener('click', function () {
const value = valid.has(button.dataset.themeOption) ? button.dataset.themeOption : 'light';
try { window.localStorage.setItem(key, value); } catch (_) { /* Display preference only. */ }
applyTheme(value);
});
});
});
}());
@@ -0,0 +1,38 @@
Assets installed by deploy/fetch_assets.py:
Bootstrap 5.3.8 (CSS only)
https://github.com/twbs/bootstrap/tree/v5.3.8
MIT License
Copyright (c) 2011-2025 The Bootstrap Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
htmx 2.0.10
https://github.com/bigskysoftware/htmx/tree/v2.0.10
Zero-Clause BSD
Permission to use, copy, modify, and/or distribute this software for
any purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL
WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES
OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE
FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY
DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,59 @@
<!doctype html>
<html lang="en" data-theme="light" data-bs-theme="light">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<title>{{ title }} | AIM Web</title>
<meta name="csrf-token" content="{{ csrf }}">
<meta name="htmx-config" content='{"allowEval":false,"allowScriptTags":false,"includeIndicatorStyles":false,"historyCacheSize":0,"selfRequestsOnly":true}'>
<script src="/static/js/theme.js?v={{ version }}"></script>
<link rel="stylesheet" href="/static/vendor/bootstrap.min.css">
<link rel="stylesheet" href="/static/css/tokens.css">
<link rel="stylesheet" href="/static/css/bootstrap-overrides.css">
<link rel="stylesheet" href="/static/css/aim.css?v={{ version }}">
<link rel="stylesheet" href="/static/css/experience.css?v={{ version }}">
<script src="/static/vendor/htmx.min.js" defer></script>
<script src="/static/js/aim.js?v={{ version }}" defer></script>
<script src="/static/js/experience.js?v={{ version }}" defer></script>
<link rel="stylesheet" href="/static/css/credentials.css?v={{ version }}">
<script src="/static/js/credentials.js?v={{ version }}" defer></script>
<link rel="stylesheet" href="/static/css/evidence.css?v={{ version }}">
<script src="/static/js/evidence.js?v={{ version }}" defer></script>
</head>
<body>
{% from 'partials/navigation.html' import links, theme, account %}
<a class="skip-link" href="#main">Skip to content</a>
<header class="mobile-topbar">
<a class="mobile-brand" href="/" aria-label="AIM homepage"><span class="brand-mark" aria-hidden="true">A</span><strong>AIM</strong></a>
<div class="mobile-actions">{{ theme() }}
<details class="mobile-menu" data-mobile-menu>
<summary aria-label="Navigation menu" aria-controls="mobile-navigation"><svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16"/></svg></summary>
<div class="mobile-menu-panel" id="mobile-navigation">
<p class="sidebar-caption">WORKSPACE</p>{{ links(nav,session) }}
<div class="mobile-account">{{ account(session,csrf) }}</div>
</div>
</details>
</div>
</header>
<div class="app-shell">
<aside class="sidebar" aria-label="Application navigation">
<a class="brand" href="/" aria-label="AIM homepage"><span class="brand-mark">A</span><span>AIM<span class="brand-subtitle">CONTROLLER WORKSPACE</span></span></a>
<div class="sidebar-caption">OPERATIONS</div>{{ links(nav,session) }}
<div class="sidebar-bottom"><span class="status-dot"></span> Independent add-on <strong>v{{ version }}</strong><p>AIM core 3.3.0rc8 / service v1<br>Core owns infrastructure. WebGUI owns the workspace.</p></div>
</aside>
<div class="workspace">
<header class="topbar">
<span class="environment-label"><span class="status-dot"></span> LOCAL CONTROLLER</span>
<div class="account-links">{{ theme() }}{{ account(session,csrf) }}</div>
</header>
<main id="main" class="main-content" tabindex="-1">
<div class="page-heading"><p class="eyebrow">AIM WEB / {{ nav|upper }}</p><h1>{{ title }}</h1>{% block subtitle %}{% endblock %}</div>
{% if error %}<div class="alert alert-danger" role="alert">{{ error }}</div>{% endif %}
{% block content %}{% endblock %}
</main>
<footer class="workspace-footer">AIM owns infrastructure data. This add-on owns presentation and login state.<span>WEB {{ version }} / WEB API v2</span></footer>
</div>
</div>
{% if session and session.user_id %}{% include 'partials/credential_dialog.html' %}{% endif %}
</body>
</html>
@@ -0,0 +1,23 @@
{% extends 'layouts/base.html' %}
{% from 'partials/activity_macros.html' import stamp, outcome, filters, summary, records %}
{% block subtitle %}<p class="text-secondary">Host activity &middot; <strong>{{ customer }}</strong>. Current membership from Core; execution history from this add-on only.</p>{% endblock %}
{% block content %}
<nav class="context-links" aria-label="Host workspace"><a href="{{ explorer_url(customer) }}">&larr; Inventory explorer</a><a href="/insights?customer={{ customer|urlencode }}">Customer insights</a><a href="#host-history">History</a><a href="#host-plans">Saved plans</a></nav>
<section class="card host-identity">
<div class="entity-symbol" aria-hidden="true">&#9635;</div>
<div><span class="eyebrow">CURRENT INVENTORY MEMBERSHIP</span>
{% if inventory_error %}<p class="mb-1">Current inventory is unavailable. Retained history is still shown below.</p><p class="small text-secondary">This is not an empty-inventory or offline-host result. Refresh after Core connectivity is restored.</p>
{% elif current %}<div class="membership-links">{% for member in current.memberships %}<a href="{{ member.url }}" class="membership-chip">{{ member.label }}</a>{% else %}<span>No group membership</span>{% endfor %}</div><p class="small text-secondary mt-2 mb-1">{{ current.platforms|join(', ') or 'Platform not supplied' }} &middot; {{ current.address or 'Address not supplied' }}</p><span class="coverage-note">Retrieved {{ stamp(fetched_at) }}</span>
{% else %}<p class="mb-1">Not in current inventory.</p><p class="small text-secondary">Historical records are retained under this logical customer/hostname identity; it is not a hardware identifier.</p>{% endif %}
</div></section>
<div class="section-heading mt-4"><div><p class="eyebrow">RETAINED WEBGUI HISTORY</p><h2 class="h4" id="host-history">Playbook activity</h2></div></div>
{{ filters(data,outcome_labels,'/inventory/' + (customer|urlencode) + '/activity',host) }}
<p class="coverage-note">{{ 'All retained history' if data.filters.days == 'all' else 'Rolling ' + data.filters.days + '-day window' }} &middot; {% if data.filters.mode == 'check' %}Check-mode results do not prove that changes were applied.{% elif data.filters.mode == 'all' %}Apply and check results are combined explicitly; each record is labeled.{% else %}Apply runs only.{% endif %} Deleted jobs and AIM terminal runs are not included.</p>
{{ summary(data,outcome_labels) }}
{% if data.book_stats %}<section class="card mt-3"><div class="card-header"><h2 class="h6 mb-0">Playbooks used on this host</h2></div><div class="book-summary-grid">{% for b in data.book_stats %}<a class="book-summary" href="{{ host_url(customer,host,playbook=b.playbook,mode=data.filters.mode,days=data.filters.days) }}"><strong>{{ b.playbook|replace('_',' ') }}</strong><span>{{ b.jobs }} runs &middot; {{ b.counts.successful }} succeeded &middot; {{ b.counts.unreachable }} unreachable</span><span>Latest: {{ outcome_labels[b.latest.outcome] }} &middot; {{ stamp(b.latest.recorded_at) }}</span></a>{% endfor %}</div></section>{% endif %}
<section class="card mt-3"><div class="card-header"><h2 class="h6 mb-0">Run timeline</h2><span class="small text-secondary">{{ data.matched }} samples &middot; page {{ data.page }} / {{ data.pages }}</span></div>{{ records(data,outcome_labels) }}</section>
<nav class="pagination-controls" aria-label="Host history pages">{% if previous_url %}<a class="btn btn-outline-secondary" href="{{ previous_url }}">Previous 25</a>{% endif %}{% if next_url %}<a class="btn btn-outline-secondary" href="{{ next_url }}">Next 25</a>{% endif %}</nav>
<section class="card mt-4" id="host-plans"><div class="card-header"><h2 class="h6 mb-0">Your saved plans containing this host</h2></div><div class="history-list">{% for p in data.plans %}<a class="book-summary" href="/plans/{{ p.id }}"><strong>{{ p.name }}</strong><span>{{ p.playbook|replace('_',' ') }} &middot; {{ stamp(p.created_at) }}</span></a>{% else %}<p class="empty-state mb-0">None of your retained saved plans select this exact hostname.</p>{% endfor %}</div></section>
<section class="card mt-4" id="host-reports"><div class="card-header"><h2 class="h6 mb-0">Recorded operation reports</h2><p class="coverage-note mb-0">Latest 20 retained report references for this exact host, across Apply/Check and all retained dates. These are dated WebGUI observations, not current inventory or health.</p></div><div class="history-list">{% for item in report_links %}<a class="book-summary" href="{{ item.url }}"><strong>{{ item.title }}</strong><span>{{ item.status|replace('_',' ') }} &middot; {{ item.retention|replace('_',' ') }} &middot; {{ 'CHECK' if item.check_mode else 'APPLY' }}</span><span>{{ stamp(item.stored_at) }} &middot; {{ item.job_id[:12] }}</span></a>{% else %}<p class="empty-state">No retained report references for this host.</p>{% endfor %}</div></section>
<p class="coverage-note mt-3">Final Ansible outcomes are historical execution facts, not current software versions or application health. Renamed or recreated hosts are not automatically merged.</p>
{% endblock %}
@@ -0,0 +1,5 @@
{% extends 'layouts/base.html' %}{% block content %}
<p class="text-secondary">Security and workflow metadata only. No passwords, request bodies, parameter values or command output. This local audit store is not tamper-proof against its service owner or root.</p>
<div class="audit-stack"><form class="filter-toolbar audit-filter" action="/audit" method="get"><label class="form-label mb-0"><span>Exact event name</span><input class="form-control mt-2" name="action" value="{{ action }}" placeholder="For example, login-failed"></label><div class="filter-actions"><button class="btn btn-outline-primary">Filter</button><a href="/audit" class="btn btn-outline-secondary">Reset</a></div></form>
<div class="card"><div class="table-responsive"><table class="table mb-0"><thead><tr><th>Time</th><th>Actor</th><th>Event</th><th>Subject</th></tr></thead><tbody>{% for item in items %}<tr><td><time class="js-local-time" datetime="{{ item.occurred_at|utc_iso }}">{{ item.occurred_at|utc }}</time></td><td>{{ item.actor }}</td><td>{{ item.action }}</td><td class="text-break">{{ item.subject }}</td></tr>{% else %}<tr><td colspan="4">No events match.</td></tr>{% endfor %}</tbody></table></div></div></div>
{% if items|length == 100 %}<a class="btn btn-outline-primary mt-3" href="/audit?before={{ items[-1].id }}&amp;action={{ action|urlencode }}">Older events</a>{% endif %}{% endblock %}
@@ -0,0 +1,7 @@
{% extends 'layouts/base.html' %}
{% block content %}
<section class="card credential-fallback" hx-history="false"><div class="card-body p-4">
<p class="eyebrow">ONE RUN / PRIVATE CREDENTIAL CHANNEL</p><h2 class="h4">Unlock this run</h2>
{% include 'partials/credential_panel.html' %}
</div></section>
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More