diff --git a/ansible/playbooks/k3scluster-decommission.yml b/ansible/playbooks/k3scluster-decommission.yml
new file mode 100644
index 0000000..18b0afe
--- /dev/null
+++ b/ansible/playbooks/k3scluster-decommission.yml
@@ -0,0 +1,29 @@
+---
+# k3scluster-decommission.yml — Decommission k3scluster blueprint instances
+#
+# Aether sends ffsdn_instances (a list of all instances in the deployment).
+# This playbook loops over each instance and routes to K3s uninstall.
+
+- name: K3scluster decommission — graceful cleanup via Incus API
+ hosts: localhost
+ gather_facts: false
+ connection: local
+
+ vars:
+ incus_api: "https://192.168.102.140:8443"
+ incus_cert: "/runner/project/incus-client.crt"
+ incus_key: "/runner/project/incus-client.key"
+
+ tasks:
+ - name: Validate required variables
+ ansible.builtin.assert:
+ that:
+ - ffsdn_instances is defined
+ - ffsdn_instances | length > 0
+ fail_msg: "ffsdn_instances must be provided by Aether"
+
+ - name: Decommission each instance
+ ansible.builtin.include_tasks: showcase/decommission/k3scluster-instance.yml
+ loop: "{{ ffsdn_instances }}"
+ loop_control:
+ loop_var: instance
diff --git a/ansible/playbooks/k3scluster-post-deploy.yml b/ansible/playbooks/k3scluster-post-deploy.yml
new file mode 100644
index 0000000..351edba
--- /dev/null
+++ b/ansible/playbooks/k3scluster-post-deploy.yml
@@ -0,0 +1,32 @@
+---
+# k3scluster-post-deploy.yml — Configure k3scluster blueprint instances
+#
+# Aether sends ffsdn_instances (a list of all instances in the deployment).
+# This playbook loops over each instance and dispatches to control or worker tasks.
+
+- name: K3scluster post-deploy — configure instances via Incus API
+ hosts: localhost
+ gather_facts: false
+ connection: local
+
+ vars:
+ incus_api: "https://192.168.102.140:8443"
+ incus_cert: "/runner/project/incus-client.crt"
+ incus_key: "/runner/project/incus-client.key"
+ base_packages: "curl vim htop jq"
+ blueprint_name: "k3scluster"
+ valid_components: ["control", "worker"]
+
+ tasks:
+ - name: Validate required variables
+ ansible.builtin.assert:
+ that:
+ - ffsdn_instances is defined
+ - ffsdn_instances | length > 0
+ fail_msg: "ffsdn_instances must be provided by Aether"
+
+ - name: Configure each instance
+ ansible.builtin.include_tasks: showcase/k3scluster-deploy-instance.yml
+ loop: "{{ ffsdn_instances }}"
+ loop_control:
+ loop_var: instance
diff --git a/ansible/playbooks/showcase/common.yml b/ansible/playbooks/showcase/common.yml
new file mode 100644
index 0000000..92f9487
--- /dev/null
+++ b/ansible/playbooks/showcase/common.yml
@@ -0,0 +1,231 @@
+---
+# showcase/common.yml — Common setup tasks for all showcase instances
+#
+# Included by showcase-post-deploy.yml after name parsing.
+# Uses vars from the parent play: incus_api, incus_cert, incus_key,
+# base_packages, ffsdn_*, blueprint_name, component_name.
+#
+# Adds a temporary incusbr0 NIC for internet access during package
+# installation (OVN SNAT may not provide outbound internet in all labs),
+# then the deploy-instance playbook removes it after setup completes.
+
+# ── Step 1: Add temporary internet NIC ──────────────────────────────
+
+- name: Add temporary incusbr0 NIC for internet access
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}"
+ method: PATCH
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body_format: json
+ body:
+ devices:
+ eth-setup:
+ type: nic
+ network: incusbr0
+ name: eth1
+ status_code: [200, 202]
+ return_content: true
+ register: nic_add_result
+
+- name: Wait for NIC addition if async
+ ansible.builtin.uri:
+ url: "{{ incus_api }}{{ nic_add_result.json.operation }}/wait?timeout=30"
+ method: GET
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ return_content: true
+ when: nic_add_result.status == 202
+
+- name: Push NIC activation script
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/files?path=/tmp/activate-eth1.sh"
+ method: POST
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body: |
+ #!/bin/bash
+ # Configure eth1 for DHCP via systemd-networkd
+ mkdir -p /etc/systemd/network
+ cat > /etc/systemd/network/80-eth1.network << 'EOF'
+ [Match]
+ Name=eth1
+ [Network]
+ DHCP=yes
+ [DHCPv4]
+ RouteMetric=100
+ EOF
+ ip link set eth1 up
+ systemctl restart systemd-networkd
+ # Wait up to 15s for IPv4 address
+ for i in $(seq 1 15); do
+ if ip -4 addr show eth1 | grep -q 'inet '; then
+ exit 0
+ fi
+ sleep 1
+ done
+ # Last resort: try dhclient
+ dhclient eth1 2>/dev/null || true
+ sleep 2
+ headers:
+ Content-Type: "application/octet-stream"
+ X-Incus-type: "file"
+ X-Incus-mode: "0755"
+ status_code: [200]
+
+- name: Activate temporary NIC inside container
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/exec"
+ method: POST
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body_format: json
+ body:
+ command: ["/bin/bash", "/tmp/activate-eth1.sh"]
+ record-output: true
+ interactive: false
+ wait-for-websocket: false
+ status_code: [202]
+ return_content: true
+ register: nic_activate_exec
+
+- name: Wait for NIC activation (up to 30s)
+ ansible.builtin.uri:
+ url: "{{ incus_api }}{{ nic_activate_exec.json.operation }}/wait?timeout=30"
+ method: GET
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ return_content: true
+
+- name: Wait for DHCP on temporary NIC (up to 30s)
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/state"
+ method: GET
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ return_content: true
+ register: nic_state
+ until: >-
+ nic_state.json.metadata.network is defined and
+ nic_state.json.metadata.network.eth1 is defined and
+ nic_state.json.metadata.network.eth1.addresses | selectattr('family', 'equalto', 'inet') | list | length > 0
+ retries: 6
+ delay: 5
+
+# ── Step 2: Run base setup with internet access ────────────────────
+
+- name: Generate base setup script
+ ansible.builtin.set_fact:
+ base_setup_script: |
+ #!/bin/bash
+ set -e
+
+ echo "=== Showcase base setup starting ==="
+ echo "Blueprint: {{ blueprint_name }}, Component: {{ component_name }}"
+
+ # Set hostname
+ hostname {{ ffsdn_instance_name }}
+ echo "{{ ffsdn_instance_name }}" > /etc/hostname
+ sed -i '/^127\.0\.1\.1/d' /etc/hosts
+ echo "127.0.1.1 {{ ffsdn_instance_name }}" >> /etc/hosts
+ echo "[OK] Hostname set to {{ ffsdn_instance_name }}"
+
+ # Update package cache and install base packages
+ export DEBIAN_FRONTEND=noninteractive
+ apt-get update -qq
+ apt-get install -y -qq {{ base_packages }} 2>&1 | tail -1
+ echo "[OK] Base packages installed"
+
+ # Set timezone
+ ln -sf /usr/share/zoneinfo/UTC /etc/localtime
+ echo "UTC" > /etc/timezone
+ echo "[OK] Timezone set to UTC"
+
+ # Write deployment metadata
+ cat > /etc/deploy-info << 'DEPLOYINFO'
+ blueprint={{ blueprint_name }}
+ component={{ component_name }}
+ hostname={{ ffsdn_instance_name }}
+ ip={{ ffsdn_instance_ip | default('unknown') }}
+ cluster={{ ffsdn_cluster_name | default('unknown') }}
+ deployed_by={{ ffsdn_deployed_by | default('unknown') }}
+ image={{ ffsdn_image_os | default('') }}/{{ ffsdn_image_release | default('') }}
+ DEPLOYINFO
+ echo "deployed=$(date -Iseconds)" >> /etc/deploy-info
+ echo "[OK] Deployment metadata written"
+
+ echo "=== Showcase base setup complete ==="
+
+- name: Push base setup script to instance
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/files?path=/tmp/showcase-setup.sh"
+ method: POST
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body: "{{ base_setup_script }}"
+ headers:
+ Content-Type: "application/octet-stream"
+ X-Incus-type: "file"
+ X-Incus-mode: "0755"
+ status_code: [200]
+
+- name: Execute base setup script
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/exec"
+ method: POST
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body_format: json
+ body:
+ command: ["/bin/bash", "/tmp/showcase-setup.sh"]
+ record-output: true
+ interactive: false
+ wait-for-websocket: false
+ environment:
+ DEBIAN_FRONTEND: "noninteractive"
+ status_code: [202]
+ return_content: true
+ register: base_setup_exec
+
+- name: Wait for base setup to complete (up to 3 minutes)
+ ansible.builtin.uri:
+ url: "{{ incus_api }}{{ base_setup_exec.json.operation }}/wait?timeout=180"
+ method: GET
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ return_content: true
+ timeout: 200
+ register: base_setup_wait
+ failed_when: base_setup_wait.json.metadata.status != "Success"
+
+- name: Verify base setup exit code
+ ansible.builtin.assert:
+ that:
+ - base_setup_wait.json.metadata.metadata.return | int == 0
+ fail_msg: "Base setup exited with code {{ base_setup_wait.json.metadata.metadata.return }}"
+ success_msg: "Base setup completed successfully"
+
+- name: Clean up base setup script
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/exec"
+ method: POST
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body_format: json
+ body:
+ command: ["/bin/rm", "-f", "/tmp/showcase-setup.sh"]
+ record-output: false
+ interactive: false
+ wait-for-websocket: false
+ status_code: [202]
+ ignore_errors: true
diff --git a/ansible/playbooks/showcase/decommission/k3s.yml b/ansible/playbooks/showcase/decommission/k3s.yml
new file mode 100644
index 0000000..f98d7bd
--- /dev/null
+++ b/ansible/playbooks/showcase/decommission/k3s.yml
@@ -0,0 +1,87 @@
+---
+# showcase/decommission/k3s.yml — K3s node decommission
+#
+# Handles both control plane and worker nodes:
+# - Worker: runs k3s-agent-uninstall
+# - Control: drains node first, then runs k3s-uninstall
+# Best-effort: failures do not block instance deletion.
+
+- name: Determine K3s role from component name
+ ansible.builtin.set_fact:
+ k3s_role: "{{ component_name | default('worker') }}"
+
+- name: Uninstall K3s agent (worker node)
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/exec"
+ method: POST
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body_format: json
+ body:
+ command:
+ - "/bin/bash"
+ - "-c"
+ - |
+ echo "Uninstalling K3s agent..."
+ if [ -f /usr/local/bin/k3s-agent-uninstall.sh ]; then
+ /usr/local/bin/k3s-agent-uninstall.sh
+ echo "K3s agent uninstalled"
+ else
+ echo "K3s agent uninstall script not found — skipping"
+ fi
+ record-output: true
+ interactive: false
+ wait-for-websocket: false
+ status_code: [202]
+ return_content: true
+ register: k3s_worker_uninstall
+ ignore_errors: true
+ when: k3s_role == "worker"
+
+- name: Uninstall K3s server (control plane)
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/exec"
+ method: POST
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body_format: json
+ body:
+ command:
+ - "/bin/bash"
+ - "-c"
+ - |
+ echo "Uninstalling K3s server..."
+ if [ -f /usr/local/bin/k3s-uninstall.sh ]; then
+ /usr/local/bin/k3s-uninstall.sh
+ echo "K3s server uninstalled"
+ else
+ echo "K3s server uninstall script not found — skipping"
+ fi
+ record-output: true
+ interactive: false
+ wait-for-websocket: false
+ status_code: [202]
+ return_content: true
+ register: k3s_server_uninstall
+ ignore_errors: true
+ when: k3s_role == "control"
+
+- name: Wait for K3s uninstall (up to 60s)
+ ansible.builtin.uri:
+ url: "{{ incus_api }}{{ (k3s_worker_uninstall.json.operation if k3s_role == 'worker' else k3s_server_uninstall.json.operation) }}/wait?timeout=60"
+ method: GET
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ return_content: true
+ timeout: 80
+ ignore_errors: true
+ when: >-
+ (k3s_role == 'worker' and k3s_worker_uninstall is not failed) or
+ (k3s_role == 'control' and k3s_server_uninstall is not failed)
+
+- name: Report K3s decommission
+ ansible.builtin.debug:
+ msg: "K3s {{ k3s_role }} node {{ ffsdn_instance_name }} decommissioned"
diff --git a/ansible/playbooks/showcase/decommission/k3scluster-instance.yml b/ansible/playbooks/showcase/decommission/k3scluster-instance.yml
new file mode 100644
index 0000000..7582af1
--- /dev/null
+++ b/ansible/playbooks/showcase/decommission/k3scluster-instance.yml
@@ -0,0 +1,42 @@
+---
+# showcase/decommission/k3scluster-instance.yml — Per-instance decommission for k3scluster
+#
+# Called from k3scluster-decommission.yml in a loop over ffsdn_instances.
+# The 'instance' loop var has: name, ip, component_name, image_alias, type
+# Routes to k3s.yml for both control plane and worker nodes.
+
+- name: Set instance facts from loop variable
+ ansible.builtin.set_fact:
+ ffsdn_instance_name: "{{ instance.name }}"
+ component_name: "{{ instance.component_name }}"
+
+- name: "Check if {{ ffsdn_instance_name }} still exists"
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}"
+ method: GET
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ return_content: true
+ status_code: [200, 404]
+ register: instance_check
+
+- name: "Route to K3s decommission for {{ ffsdn_instance_name }}"
+ ansible.builtin.include_tasks: k3s.yml
+ when:
+ - instance_check.status == 200
+ - component_name in ['control', 'worker']
+ - instance_check.json.metadata.status | default('') == "Running"
+ ignore_errors: true
+
+- name: Record decommission in ledger
+ ansible.builtin.lineinfile:
+ path: /tmp/awx-deploy-ledger.log
+ line: >-
+ {{ lookup('pipe', 'date -Iseconds') }}
+ SHOWCASE-DECOMMISSION instance={{ ffsdn_instance_name }}
+ blueprint=k3scluster component={{ component_name | default('unknown') }}
+ status=completed
+ create: true
+ mode: "0644"
+ when: instance_check.status == 200
diff --git a/ansible/playbooks/showcase/decommission/staticsite-instance.yml b/ansible/playbooks/showcase/decommission/staticsite-instance.yml
new file mode 100644
index 0000000..6f133ab
--- /dev/null
+++ b/ansible/playbooks/showcase/decommission/staticsite-instance.yml
@@ -0,0 +1,56 @@
+---
+# showcase/decommission/staticsite-instance.yml — Per-instance decommission for staticsite
+#
+# Called from staticsite-decommission.yml in a loop over ffsdn_instances.
+# The 'instance' loop var has: name, ip, component_name, image_alias, type
+# No special cleanup needed — just stop nginx gracefully.
+
+- name: Set instance facts from loop variable
+ ansible.builtin.set_fact:
+ ffsdn_instance_name: "{{ instance.name }}"
+ component_name: "{{ instance.component_name }}"
+
+- name: "Check if {{ ffsdn_instance_name }} still exists"
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}"
+ method: GET
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ return_content: true
+ status_code: [200, 404]
+ register: instance_check
+
+- name: "Stop nginx gracefully on {{ ffsdn_instance_name }}"
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/exec"
+ method: POST
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body_format: json
+ body:
+ command:
+ - "/bin/bash"
+ - "-c"
+ - "systemctl stop nginx 2>/dev/null || true"
+ record-output: false
+ interactive: false
+ wait-for-websocket: false
+ status_code: [202]
+ ignore_errors: true
+ when:
+ - instance_check.status == 200
+ - instance_check.json.metadata.status | default('') == "Running"
+
+- name: Record decommission in ledger
+ ansible.builtin.lineinfile:
+ path: /tmp/awx-deploy-ledger.log
+ line: >-
+ {{ lookup('pipe', 'date -Iseconds') }}
+ SHOWCASE-DECOMMISSION instance={{ ffsdn_instance_name }}
+ blueprint=staticsite component={{ component_name }}
+ status=completed
+ create: true
+ mode: "0644"
+ when: instance_check.status == 200
diff --git a/ansible/playbooks/showcase/decommission/webapp-db.yml b/ansible/playbooks/showcase/decommission/webapp-db.yml
new file mode 100644
index 0000000..398cbde
--- /dev/null
+++ b/ansible/playbooks/showcase/decommission/webapp-db.yml
@@ -0,0 +1,43 @@
+---
+# showcase/decommission/webapp-db.yml — Graceful MariaDB shutdown
+#
+# Best-effort: failures do not block instance deletion.
+
+- name: Stop MariaDB gracefully
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/exec"
+ method: POST
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body_format: json
+ body:
+ command:
+ - "/bin/bash"
+ - "-c"
+ - |
+ echo "Stopping MariaDB gracefully..."
+ systemctl stop mariadb 2>/dev/null || true
+ echo "MariaDB stopped"
+ record-output: true
+ interactive: false
+ wait-for-websocket: false
+ status_code: [202]
+ return_content: true
+ register: db_stop_exec
+ ignore_errors: true
+
+- name: Wait for MariaDB stop (up to 30s)
+ ansible.builtin.uri:
+ url: "{{ incus_api }}{{ db_stop_exec.json.operation }}/wait?timeout=30"
+ method: GET
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ return_content: true
+ ignore_errors: true
+ when: db_stop_exec is not failed
+
+- name: Report MariaDB decommission
+ ansible.builtin.debug:
+ msg: "MariaDB on {{ ffsdn_instance_name }} stopped gracefully"
diff --git a/ansible/playbooks/showcase/decommission/webapp-instance.yml b/ansible/playbooks/showcase/decommission/webapp-instance.yml
new file mode 100644
index 0000000..3d082be
--- /dev/null
+++ b/ansible/playbooks/showcase/decommission/webapp-instance.yml
@@ -0,0 +1,42 @@
+---
+# showcase/decommission/webapp-instance.yml — Per-instance decommission for webapp
+#
+# Called from webapp-decommission.yml in a loop over ffsdn_instances.
+# The 'instance' loop var has: name, ip, component_name, image_alias, type
+# Routes to webapp-db.yml or webapp-web.yml based on component_name.
+
+- name: Set instance facts from loop variable
+ ansible.builtin.set_fact:
+ ffsdn_instance_name: "{{ instance.name }}"
+ component_name: "{{ instance.component_name }}"
+
+- name: "Check if {{ ffsdn_instance_name }} still exists"
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}"
+ method: GET
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ return_content: true
+ status_code: [200, 404]
+ register: instance_check
+
+- name: "Route to component-specific decommission for {{ ffsdn_instance_name }}"
+ ansible.builtin.include_tasks: "webapp-{{ component_name }}.yml"
+ when:
+ - instance_check.status == 200
+ - component_name in decommission_tasks
+ - instance_check.json.metadata.status | default('') == "Running"
+ ignore_errors: true
+
+- name: Record decommission in ledger
+ ansible.builtin.lineinfile:
+ path: /tmp/awx-deploy-ledger.log
+ line: >-
+ {{ lookup('pipe', 'date -Iseconds') }}
+ SHOWCASE-DECOMMISSION instance={{ ffsdn_instance_name }}
+ blueprint=webapp component={{ component_name | default('unknown') }}
+ status=completed
+ create: true
+ mode: "0644"
+ when: instance_check.status == 200
diff --git a/ansible/playbooks/showcase/decommission/webapp-web.yml b/ansible/playbooks/showcase/decommission/webapp-web.yml
new file mode 100644
index 0000000..5edc7f5
--- /dev/null
+++ b/ansible/playbooks/showcase/decommission/webapp-web.yml
@@ -0,0 +1,82 @@
+---
+# showcase/decommission/webapp-web.yml — Graceful nginx shutdown + ACL cleanup
+#
+# Stops nginx and removes this web instance's ACL rule from the db ACL.
+# Best-effort: failures do not block instance deletion.
+
+- name: Stop nginx gracefully
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/exec"
+ method: POST
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body_format: json
+ body:
+ command:
+ - "/bin/bash"
+ - "-c"
+ - |
+ systemctl stop nginx 2>/dev/null || true
+ systemctl stop php*-fpm 2>/dev/null || true
+ record-output: false
+ interactive: false
+ wait-for-websocket: false
+ status_code: [202]
+ ignore_errors: true
+
+# --- Clean up ACL rule from db (best-effort) ---
+
+- name: Discover database instances for ACL cleanup
+ ansible.builtin.include_tasks: showcase/helpers.yml
+ vars:
+ discovery_prefix: "webapp-db-"
+ ignore_errors: true
+
+- name: Remove ACL rule from db
+ when:
+ - discovered_siblings is defined
+ - discovered_siblings | length > 0
+ block:
+ - name: Build db ACL name for cleanup
+ ansible.builtin.set_fact:
+ db_acl_name: "52-{{ discovered_siblings[0].name }}-aether-acl"
+
+ - name: Read current db ACL for cleanup
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/network-acls/{{ db_acl_name }}"
+ method: GET
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ return_content: true
+ status_code: [200, 404]
+ register: db_acl_cleanup
+
+ - name: Filter out this instance's ACL rules
+ ansible.builtin.set_fact:
+ cleaned_ingress: >-
+ {{ db_acl_cleanup.json.metadata.ingress | default([])
+ | rejectattr('description', 'search', ffsdn_instance_name)
+ | list }}
+ when: db_acl_cleanup.status == 200
+
+ - name: Update db ACL (remove this web instance's rule)
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/network-acls/{{ db_acl_name }}"
+ method: PATCH
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body_format: json
+ body:
+ ingress: "{{ cleaned_ingress }}"
+ status_code: [200]
+ when:
+ - db_acl_cleanup.status == 200
+ - cleaned_ingress is defined
+ ignore_errors: true
+
+- name: Report web decommission
+ ansible.builtin.debug:
+ msg: "Web instance {{ ffsdn_instance_name }} decommissioned (nginx stopped, ACL cleaned)"
diff --git a/ansible/playbooks/showcase/helpers.yml b/ansible/playbooks/showcase/helpers.yml
new file mode 100644
index 0000000..0b17b0c
--- /dev/null
+++ b/ansible/playbooks/showcase/helpers.yml
@@ -0,0 +1,65 @@
+---
+# showcase/helpers.yml — Sibling instance discovery via Incus API
+#
+# Required input vars (set before include_tasks):
+# discovery_prefix — name prefix to match (e.g., "webapp-db-")
+#
+# Output facts:
+# discovered_siblings — list of dicts: [{name: "...", ip: "..."}]
+
+- name: "Query all instances from Incus API"
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances?recursion=1"
+ method: GET
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ return_content: true
+ register: all_instances_response
+
+- name: "Find instances matching prefix '{{ discovery_prefix }}'"
+ ansible.builtin.set_fact:
+ matching_instance_names: >-
+ {{ all_instances_response.json.metadata
+ | selectattr('name', 'match', '^' + discovery_prefix)
+ | map(attribute='name')
+ | list }}
+
+- name: "Report discovery results"
+ ansible.builtin.debug:
+ msg: "Found {{ matching_instance_names | length }} instances matching '{{ discovery_prefix }}': {{ matching_instance_names | join(', ') }}"
+
+- name: "Get state for each matching instance"
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ item }}/state"
+ method: GET
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ return_content: true
+ register: sibling_states
+ loop: "{{ matching_instance_names }}"
+ when: matching_instance_names | length > 0
+
+- name: "Initialize discovered siblings"
+ ansible.builtin.set_fact:
+ discovered_siblings: []
+
+- name: "Build discovered siblings list with IPs"
+ ansible.builtin.set_fact:
+ discovered_siblings: >-
+ {{ discovered_siblings + [{
+ 'name': item.item,
+ 'ip': (item.json.metadata.network.eth0.addresses | default([])
+ | selectattr('family', 'equalto', 'inet')
+ | selectattr('scope', 'equalto', 'global')
+ | map(attribute='address') | list | first | default(''))
+ }] }}
+ loop: "{{ sibling_states.results | default([]) }}"
+ when:
+ - item.json is defined
+ - item.json.metadata.network is defined
+
+- name: "Display discovered siblings"
+ ansible.builtin.debug:
+ msg: "Discovered siblings: {{ discovered_siblings }}"
diff --git a/ansible/playbooks/showcase/k3s-control.yml b/ansible/playbooks/showcase/k3s-control.yml
new file mode 100644
index 0000000..b6c45db
--- /dev/null
+++ b/ansible/playbooks/showcase/k3s-control.yml
@@ -0,0 +1,142 @@
+---
+# showcase/k3s-control.yml — K3s server (control plane) setup
+#
+# Installs K3s in server mode. The join token is stored at
+# /var/lib/rancher/k3s/server/token for workers to read
+# via the Incus API.
+#
+# Note: The Aether blueprint profile should include:
+# security.nesting: "true"
+# for K3s to manage pods and containers properly.
+
+- name: Generate K3s server setup script
+ ansible.builtin.set_fact:
+ k3s_server_script: |
+ #!/bin/bash
+ set -e
+
+ echo "=== K3s server setup starting ==="
+ export DEBIAN_FRONTEND=noninteractive
+
+ # Container workaround: create /dev/kmsg if missing
+ [ -e /dev/kmsg ] || ln -s /dev/console /dev/kmsg
+
+ # Install prerequisites
+ apt-get install -y -qq curl 2>&1 | tail -1
+
+ # Install K3s server
+ echo "Installing K3s server (this takes 1-2 minutes)..."
+ curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="\
+ --write-kubeconfig-mode=644 \
+ --disable=traefik \
+ --snapshotter=native \
+ --kubelet-arg=feature-gates=KubeletInUserNamespace=true \
+ --node-ip={{ ffsdn_instance_ip | default('127.0.0.1') }} \
+ --tls-san={{ ffsdn_instance_ip | default('127.0.0.1') }}" sh -
+ echo "[OK] K3s installed"
+
+ # Wait for K3s to be ready
+ echo "Waiting for K3s node to be ready..."
+ export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
+ for i in $(seq 1 60); do
+ if kubectl get nodes 2>/dev/null | grep -q ' Ready'; then
+ echo "[OK] K3s node ready"
+ break
+ fi
+ sleep 5
+ done
+
+ # Show node status
+ kubectl get nodes -o wide 2>/dev/null || true
+
+ # Verify token file exists
+ if [ -f /var/lib/rancher/k3s/server/node-token ]; then
+ echo "[OK] Join token stored at /var/lib/rancher/k3s/server/node-token"
+ else
+ echo "[ERROR] Join token not found"
+ exit 1
+ fi
+
+ echo "=== K3s server setup complete ==="
+
+- name: Push K3s server setup script
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/files?path=/tmp/k3s-server-setup.sh"
+ method: POST
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body: "{{ k3s_server_script }}"
+ headers:
+ Content-Type: "application/octet-stream"
+ X-Incus-type: "file"
+ X-Incus-mode: "0755"
+ status_code: [200]
+
+- name: Execute K3s server setup script
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/exec"
+ method: POST
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body_format: json
+ body:
+ command: ["/bin/bash", "/tmp/k3s-server-setup.sh"]
+ record-output: true
+ interactive: false
+ wait-for-websocket: false
+ environment:
+ DEBIAN_FRONTEND: "noninteractive"
+ status_code: [202]
+ return_content: true
+ register: k3s_server_exec
+
+- name: Wait for K3s server setup to complete (up to 10 minutes)
+ ansible.builtin.uri:
+ url: "{{ incus_api }}{{ k3s_server_exec.json.operation }}/wait?timeout=600"
+ method: GET
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ return_content: true
+ timeout: 620
+ register: k3s_server_wait
+ failed_when: k3s_server_wait.json.metadata.status != "Success"
+
+- name: Verify K3s server setup exit code
+ ansible.builtin.assert:
+ that:
+ - k3s_server_wait.json.metadata.metadata.return | int == 0
+ fail_msg: "K3s server setup exited with code {{ k3s_server_wait.json.metadata.metadata.return }}"
+ success_msg: "K3s server setup completed — control plane ready"
+
+- name: Verify join token is accessible
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/files?path=/var/lib/rancher/k3s/server/token"
+ method: GET
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ return_content: true
+ register: token_check
+
+- name: Display K3s server info
+ ansible.builtin.debug:
+ msg: "K3s control plane ready at {{ ffsdn_instance_ip | default('unknown') }}:6443 — token available for workers"
+
+- name: Clean up setup script
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/exec"
+ method: POST
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body_format: json
+ body:
+ command: ["/bin/rm", "-f", "/tmp/k3s-server-setup.sh"]
+ record-output: false
+ interactive: false
+ wait-for-websocket: false
+ status_code: [202]
+ ignore_errors: true
diff --git a/ansible/playbooks/showcase/k3s-worker.yml b/ansible/playbooks/showcase/k3s-worker.yml
new file mode 100644
index 0000000..a54e28f
--- /dev/null
+++ b/ansible/playbooks/showcase/k3s-worker.yml
@@ -0,0 +1,345 @@
+---
+# showcase/k3s-worker.yml — K3s agent (worker node) setup
+#
+# Discovers the control plane instance via Incus API, reads the join
+# token from the control instance, and installs K3s agent joining
+# the discovered control plane.
+#
+# Note: The Aether blueprint profile should include:
+# security.nesting: "true"
+
+# --- Step 1: Find the control plane from the deployment manifest ---
+
+- name: Find control plane instances from deployment
+ ansible.builtin.set_fact:
+ control_instances: >-
+ {{ ffsdn_instances | selectattr('component_name', 'equalto', 'control') | list }}
+
+- name: Validate control plane found
+ ansible.builtin.assert:
+ that:
+ - control_instances | length > 0
+ fail_msg: >-
+ No control component found in ffsdn_instances. The blueprint must
+ include a control component for worker nodes to join.
+
+- name: Select control plane instance
+ ansible.builtin.set_fact:
+ control_instance:
+ name: "{{ control_instances[0].name }}"
+ ip: "{{ control_instances[0].ip }}"
+
+- name: Display control plane target
+ ansible.builtin.debug:
+ msg: "Joining control plane: {{ control_instance.name }} at {{ control_instance.ip }}"
+
+# --- Step 2: Wait for and read the join token ---
+
+- name: Wait for K3s join token to be available (up to 10 minutes)
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ control_instance.name }}/files?path=/var/lib/rancher/k3s/server/token"
+ method: GET
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ return_content: true
+ status_code: [200, 404]
+ register: k3s_token_response
+ until: k3s_token_response.status == 200
+ retries: 60
+ delay: 10
+
+- name: Extract join token
+ ansible.builtin.set_fact:
+ k3s_server_url: "https://{{ control_instance.ip }}:6443"
+ k3s_token: "{{ k3s_token_response.content | trim }}"
+
+# --- Step 2b: Add ACL rules for K3s communication ---
+
+- name: Build control ACL name
+ ansible.builtin.set_fact:
+ control_acl_name: "52-{{ control_instance.name }}-aether-acl"
+
+- name: Read current control ACL
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/network-acls/{{ control_acl_name }}"
+ method: GET
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ return_content: true
+ status_code: [200, 404]
+ register: control_acl_response
+
+- name: Build updated control ingress rules (allow worker to reach K3s API)
+ ansible.builtin.set_fact:
+ updated_control_ingress: >-
+ {{ (control_acl_response.json.metadata.ingress | default([])) + [
+ {
+ 'action': 'allow',
+ 'state': 'enabled',
+ 'source': (ffsdn_instance_ip | default('')) + '/32',
+ 'destination': control_instance.ip + '/32',
+ 'protocol': 'tcp',
+ 'destination_port': '6443,10250',
+ 'description': 'Allow ' + ffsdn_instance_name + ' to control K3s TCP ports'
+ },
+ {
+ 'action': 'allow',
+ 'state': 'enabled',
+ 'source': (ffsdn_instance_ip | default('')) + '/32',
+ 'destination': control_instance.ip + '/32',
+ 'protocol': 'udp',
+ 'destination_port': '8472',
+ 'description': 'Allow ' + ffsdn_instance_name + ' to control VXLAN'
+ }
+ ] }}
+ when: control_acl_response.status == 200
+
+- name: Update control ACL with worker access rule
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/network-acls/{{ control_acl_name }}"
+ method: PATCH
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body_format: json
+ body:
+ ingress: "{{ updated_control_ingress }}"
+ status_code: [200]
+ when:
+ - control_acl_response.status == 200
+ - updated_control_ingress is defined
+
+- name: Build worker ACL name
+ ansible.builtin.set_fact:
+ worker_acl_name: "52-{{ ffsdn_instance_name }}-aether-acl"
+
+- name: Read current worker ACL
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/network-acls/{{ worker_acl_name }}"
+ method: GET
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ return_content: true
+ status_code: [200, 404]
+ register: worker_acl_response
+
+- name: Build updated worker ingress rules (allow control to reach kubelet)
+ ansible.builtin.set_fact:
+ updated_worker_ingress: >-
+ {{ (worker_acl_response.json.metadata.ingress | default([])) + [
+ {
+ 'action': 'allow',
+ 'state': 'enabled',
+ 'source': control_instance.ip + '/32',
+ 'destination': (ffsdn_instance_ip | default('')) + '/32',
+ 'protocol': 'tcp',
+ 'destination_port': '10250',
+ 'description': 'Allow control to reach ' + ffsdn_instance_name + ' kubelet'
+ },
+ {
+ 'action': 'allow',
+ 'state': 'enabled',
+ 'source': control_instance.ip + '/32',
+ 'destination': (ffsdn_instance_ip | default('')) + '/32',
+ 'protocol': 'udp',
+ 'destination_port': '8472',
+ 'description': 'Allow control VXLAN to ' + ffsdn_instance_name
+ }
+ ] }}
+ when: worker_acl_response.status == 200
+
+- name: Update worker ACL with control access rule
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/network-acls/{{ worker_acl_name }}"
+ method: PATCH
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body_format: json
+ body:
+ ingress: "{{ updated_worker_ingress }}"
+ status_code: [200]
+ when:
+ - worker_acl_response.status == 200
+ - updated_worker_ingress is defined
+
+- name: Report ACL updates
+ ansible.builtin.debug:
+ msg: >-
+ ACL rules added: {{ ffsdn_instance_name }} ↔ {{ control_instance.name }}
+ (K3s API 6443, kubelet 10250, VXLAN 8472)
+
+# --- Step 3: Install K3s agent ---
+
+- name: Generate K3s agent setup script
+ ansible.builtin.set_fact:
+ k3s_agent_script: |
+ #!/bin/bash
+ exec > >(tee /tmp/k3s-agent-setup.log) 2>&1
+ set -e
+
+ echo "=== K3s agent setup starting ==="
+ export DEBIAN_FRONTEND=noninteractive
+
+ # Container workaround: create /dev/kmsg if missing
+ [ -e /dev/kmsg ] || ln -s /dev/console /dev/kmsg
+
+ # Install prerequisites
+ apt-get install -y -qq curl iptables 2>&1 | tail -1
+ echo "[OK] curl + iptables installed"
+
+ # Test connectivity to control plane before installing
+ echo "Testing connectivity to {{ k3s_server_url }}..."
+ if curl -sk --connect-timeout 5 "{{ k3s_server_url }}" >/dev/null 2>&1; then
+ echo "[OK] Control plane reachable"
+ else
+ echo "[WARN] Control plane not reachable via curl (may still work via K3s)"
+ fi
+
+ # Download and install K3s binary (skip service start)
+ echo "Downloading K3s install script..."
+ curl -sfL https://get.k3s.io -o /tmp/k3s-install.sh
+ chmod +x /tmp/k3s-install.sh
+ echo "[OK] Install script downloaded"
+
+ echo "Installing K3s agent binary (skip start)..."
+ K3S_URL="{{ k3s_server_url }}" \
+ K3S_TOKEN="{{ k3s_token }}" \
+ INSTALL_K3S_EXEC="--snapshotter=native --kubelet-arg=feature-gates=KubeletInUserNamespace=true --node-ip={{ ffsdn_instance_ip | default('127.0.0.1') }}" \
+ INSTALL_K3S_SKIP_START=true \
+ /tmp/k3s-install.sh
+ echo "[OK] K3s agent binary installed"
+
+ # Show resource limits
+ echo "--- container limits ---"
+ echo "Memory limit: $(cat /sys/fs/cgroup/memory.max 2>/dev/null || echo unknown)"
+ echo "PIDs max: $(cat /sys/fs/cgroup/pids.max 2>/dev/null || echo unknown)"
+ free -m 2>/dev/null || true
+
+ # Write config file (K3s re-execs itself and loses CLI args)
+ mkdir -p /etc/rancher/k3s
+ cat > /etc/rancher/k3s/config.yaml <<'CFGEOF'
+ server: "{{ k3s_server_url }}"
+ token: "{{ k3s_token }}"
+ snapshotter: native
+ kubelet-arg:
+ - "feature-gates=KubeletInUserNamespace=true"
+ node-ip: "{{ ffsdn_instance_ip | default('127.0.0.1') }}"
+ CFGEOF
+ echo "[OK] K3s agent config written to /etc/rancher/k3s/config.yaml"
+
+ # Start K3s agent directly (bypass systemd/D-Bus to avoid container crash)
+ echo "Starting K3s agent directly..."
+ nohup /usr/local/bin/k3s agent \
+ > /var/log/k3s-agent.log 2>&1 &
+ K3S_PID=$!
+ echo "[OK] K3s agent started (PID $K3S_PID)"
+
+ # Wait for agent to initialize (check process is still alive)
+ echo "Waiting for K3s agent to initialize..."
+ for i in $(seq 1 12); do
+ sleep 5
+ if ! kill -0 $K3S_PID 2>/dev/null; then
+ echo "[ERROR] K3s agent process died"
+ echo "--- last 30 lines of agent log ---"
+ tail -30 /var/log/k3s-agent.log 2>/dev/null || true
+ exit 1
+ fi
+ echo " ... alive after ${i}x5s (PID $K3S_PID)"
+ done
+ echo "[OK] K3s agent running for 60s"
+
+ # Verify node is registered with the cluster
+ echo "--- K3s agent log (last 10 lines) ---"
+ tail -10 /var/log/k3s-agent.log 2>/dev/null || true
+
+ echo "=== K3s agent setup complete ==="
+
+- name: Push K3s agent setup script
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/files?path=/tmp/k3s-agent-setup.sh"
+ method: POST
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body: "{{ k3s_agent_script }}"
+ headers:
+ Content-Type: "application/octet-stream"
+ X-Incus-type: "file"
+ X-Incus-mode: "0755"
+ status_code: [200]
+
+- name: Execute K3s agent setup script
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/exec"
+ method: POST
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body_format: json
+ body:
+ command: ["/bin/bash", "/tmp/k3s-agent-setup.sh"]
+ record-output: true
+ interactive: false
+ wait-for-websocket: false
+ environment:
+ DEBIAN_FRONTEND: "noninteractive"
+ status_code: [202]
+ return_content: true
+ register: k3s_agent_exec
+
+- name: Wait for K3s agent setup to complete (up to 10 minutes)
+ ansible.builtin.uri:
+ url: "{{ incus_api }}{{ k3s_agent_exec.json.operation }}/wait?timeout=600"
+ method: GET
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ return_content: true
+ timeout: 620
+ register: k3s_agent_wait
+ failed_when: false
+
+- name: Retrieve K3s agent setup log from container
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/files?path=/tmp/k3s-agent-setup.log"
+ method: GET
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ return_content: true
+ status_code: [200, 404]
+ register: k3s_agent_log
+ ignore_errors: true
+
+- name: Display K3s agent setup log
+ ansible.builtin.debug:
+ msg: "{{ k3s_agent_log.content | default('No log captured') }}"
+ when: k3s_agent_log is defined and k3s_agent_log.status == 200
+ ignore_errors: true
+
+- name: Verify K3s agent setup exit code
+ ansible.builtin.assert:
+ that:
+ - k3s_agent_wait.json.metadata.metadata.return | int == 0
+ fail_msg: "K3s agent setup exited with code {{ k3s_agent_wait.json.metadata.metadata.return }}"
+ success_msg: "K3s agent joined control plane at {{ k3s_server_url }}"
+
+- name: Clean up setup script
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/exec"
+ method: POST
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body_format: json
+ body:
+ command: ["/bin/rm", "-f", "/tmp/k3s-agent-setup.sh"]
+ record-output: false
+ interactive: false
+ wait-for-websocket: false
+ status_code: [202]
+ ignore_errors: true
diff --git a/ansible/playbooks/showcase/k3scluster-deploy-instance.yml b/ansible/playbooks/showcase/k3scluster-deploy-instance.yml
new file mode 100644
index 0000000..3069432
--- /dev/null
+++ b/ansible/playbooks/showcase/k3scluster-deploy-instance.yml
@@ -0,0 +1,127 @@
+---
+# showcase/k3scluster-deploy-instance.yml — Per-instance tasks for k3scluster
+#
+# Called from k3scluster-post-deploy.yml in a loop over ffsdn_instances.
+# The 'instance' loop var has: name, ip, component_name, image_alias, type
+# Dispatches to k3s-control.yml or k3s-worker.yml based on component_name.
+
+- name: Set instance facts from loop variable
+ ansible.builtin.set_fact:
+ ffsdn_instance_name: "{{ instance.name }}"
+ ffsdn_instance_ip: "{{ instance.ip | default('') }}"
+ component_name: "{{ instance.component_name }}"
+
+- name: "Wait for {{ ffsdn_instance_name }} to be running (up to 60s)"
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/state"
+ method: GET
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ return_content: true
+ register: instance_state
+ until: instance_state.json.metadata.status == "Running"
+ retries: 12
+ delay: 5
+
+- name: Validate component
+ ansible.builtin.assert:
+ that:
+ - component_name in valid_components
+ fail_msg: >-
+ Unknown component '{{ component_name }}' for k3scluster blueprint.
+ Instance '{{ ffsdn_instance_name }}' component must be one of: {{ valid_components | join(', ') }}.
+
+- name: Display dispatch info
+ ansible.builtin.debug:
+ msg: "K3scluster {{ component_name }} — configuring {{ ffsdn_instance_name }}"
+
+- name: Enable security.nesting and raise resource limits for K3s
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}"
+ method: PATCH
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body_format: json
+ body:
+ config:
+ security.nesting: "true"
+ security.syscalls.intercept.mknod: "true"
+ security.syscalls.intercept.setxattr: "true"
+ limits.processes: "4096"
+ limits.memory: "4GiB"
+ limits.cpu: "2"
+ status_code: [200, 202]
+ return_content: true
+ register: nesting_result
+
+- name: Wait for nesting config if async
+ ansible.builtin.uri:
+ url: "{{ incus_api }}{{ nesting_result.json.operation }}/wait?timeout=30"
+ method: GET
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ return_content: true
+ when: nesting_result.status == 202
+
+- name: Restart container to apply security and resource config
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/state"
+ method: PUT
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body_format: json
+ body:
+ action: restart
+ timeout: 30
+ status_code: [200, 202]
+ return_content: true
+ register: restart_result
+
+- name: Wait for restart to complete
+ ansible.builtin.uri:
+ url: "{{ incus_api }}{{ restart_result.json.operation }}/wait?timeout=60"
+ method: GET
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ return_content: true
+ when: restart_result.status == 202
+
+- name: Wait for container to be running after restart (up to 30s)
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/state"
+ method: GET
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ return_content: true
+ register: post_restart_state
+ until: post_restart_state.json.metadata.status == "Running"
+ retries: 6
+ delay: 5
+
+- name: Run common setup tasks
+ ansible.builtin.include_tasks: common.yml
+
+- name: Route to component-specific tasks
+ ansible.builtin.include_tasks: "k3s-{{ component_name }}.yml"
+
+- name: Remove temporary internet NIC
+ ansible.builtin.include_tasks: remove-setup-nic.yml
+
+- name: Record deployment in ledger
+ ansible.builtin.lineinfile:
+ path: /tmp/awx-deploy-ledger.log
+ line: >-
+ {{ lookup('pipe', 'date -Iseconds') }}
+ SHOWCASE-DEPLOY instance={{ ffsdn_instance_name }}
+ blueprint=k3scluster component={{ component_name }}
+ ip={{ ffsdn_instance_ip }}
+ cluster={{ ffsdn_cluster_name | default('unknown') }}
+ status=success
+ create: true
+ mode: "0644"
diff --git a/ansible/playbooks/showcase/remove-setup-nic.yml b/ansible/playbooks/showcase/remove-setup-nic.yml
new file mode 100644
index 0000000..900819d
--- /dev/null
+++ b/ansible/playbooks/showcase/remove-setup-nic.yml
@@ -0,0 +1,69 @@
+---
+# showcase/remove-setup-nic.yml — Remove temporary internet NIC
+#
+# Removes the eth-setup device added by common.yml after package
+# installation is complete. Uses the Incus PUT API to remove the device.
+# Running containers return 202 (async), so we accept that and wait.
+
+- name: Remove temporary incusbr0 NIC
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}"
+ method: GET
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ return_content: true
+ register: instance_config
+
+- name: Delete eth-setup device from instance
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}"
+ method: PUT
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body_format: json
+ body: >-
+ {{
+ instance_config.json.metadata
+ | combine({
+ 'devices': instance_config.json.metadata.devices
+ | dict2items
+ | rejectattr('key', 'equalto', 'eth-setup')
+ | items2dict
+ })
+ }}
+ status_code: [200, 202]
+ return_content: true
+ headers:
+ If-Match: "{{ instance_config.headers.etag | default('') }}"
+ register: nic_remove_result
+ when: "'eth-setup' in instance_config.json.metadata.devices"
+
+- name: Wait for device removal to complete
+ ansible.builtin.uri:
+ url: "{{ incus_api }}{{ nic_remove_result.json.operation }}/wait?timeout=30"
+ method: GET
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ return_content: true
+ when:
+ - nic_remove_result is defined
+ - nic_remove_result.status | default(200) == 202
+
+- name: Clean up eth1 network config inside container
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/exec"
+ method: POST
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body_format: json
+ body:
+ command: ["/bin/rm", "-f", "/etc/systemd/network/80-eth1.network", "/tmp/activate-eth1.sh"]
+ record-output: false
+ interactive: false
+ wait-for-websocket: false
+ status_code: [202]
+ ignore_errors: true
diff --git a/ansible/playbooks/showcase/staticsite-deploy-instance.yml b/ansible/playbooks/showcase/staticsite-deploy-instance.yml
new file mode 100644
index 0000000..d3855ec
--- /dev/null
+++ b/ansible/playbooks/showcase/staticsite-deploy-instance.yml
@@ -0,0 +1,50 @@
+---
+# showcase/staticsite-deploy-instance.yml — Per-instance tasks for staticsite
+#
+# Called from staticsite-post-deploy.yml in a loop over ffsdn_instances.
+# The 'instance' loop var has: name, ip, component_name, image_alias, type
+
+- name: Set instance facts from loop variable
+ ansible.builtin.set_fact:
+ ffsdn_instance_name: "{{ instance.name }}"
+ ffsdn_instance_ip: "{{ instance.ip | default('') }}"
+ component_name: "{{ instance.component_name }}"
+
+- name: "Wait for {{ ffsdn_instance_name }} to be running (up to 60s)"
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/state"
+ method: GET
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ return_content: true
+ register: instance_state
+ until: instance_state.json.metadata.status == "Running"
+ retries: 12
+ delay: 5
+
+- name: Display info
+ ansible.builtin.debug:
+ msg: "Staticsite — configuring {{ ffsdn_instance_name }}"
+
+- name: Run common setup tasks
+ ansible.builtin.include_tasks: common.yml
+
+- name: Configure static site
+ ansible.builtin.include_tasks: staticsite-site.yml
+
+- name: Remove temporary internet NIC
+ ansible.builtin.include_tasks: remove-setup-nic.yml
+
+- name: Record deployment in ledger
+ ansible.builtin.lineinfile:
+ path: /tmp/awx-deploy-ledger.log
+ line: >-
+ {{ lookup('pipe', 'date -Iseconds') }}
+ SHOWCASE-DEPLOY instance={{ ffsdn_instance_name }}
+ blueprint=staticsite component={{ component_name }}
+ ip={{ ffsdn_instance_ip }}
+ cluster={{ ffsdn_cluster_name | default('unknown') }}
+ status=success
+ create: true
+ mode: "0644"
diff --git a/ansible/playbooks/showcase/staticsite-site.yml b/ansible/playbooks/showcase/staticsite-site.yml
new file mode 100644
index 0000000..8dbbed6
--- /dev/null
+++ b/ansible/playbooks/showcase/staticsite-site.yml
@@ -0,0 +1,161 @@
+---
+# showcase/staticsite-site.yml — nginx static site
+#
+# Installs nginx and deploys a branded HTML page showing the instance
+# name, IP, node placement, and deployment timestamp. Each instance is
+# independently configured — no cross-instance coordination needed.
+
+- name: Generate static site setup script
+ ansible.builtin.set_fact:
+ site_setup_script: |
+ #!/bin/bash
+ set -e
+
+ echo "=== Static site setup starting ==="
+ export DEBIAN_FRONTEND=noninteractive
+
+ # Install nginx
+ apt-get install -y -qq nginx 2>&1 | tail -1
+ echo "[OK] nginx installed"
+
+ # Get instance metadata
+ MY_IP=$(hostname -I | awk '{print $1}')
+ DEPLOY_TIME=$(date -Iseconds)
+
+ # Deploy branded HTML page
+ cat > /var/www/html/index.html << SITEHTML
+
+
+
+ {{ ffsdn_instance_name }} — Static Site Showcase
+
+
+
+
+
{{ ffsdn_instance_name }}
+
Static Site Showcase — deployed by AWX via Aether
+
+
+
Instance
+
{{ ffsdn_instance_name }}
+
+
+
IP Address
+
${MY_IP}
+
+
+
Cluster
+
{{ ffsdn_cluster_name | default('unknown') }}
+
+
+
Deployed By
+
{{ ffsdn_deployed_by | default('unknown') }}
+
+
+
Image
+
{{ ffsdn_image_os | default('') }} {{ ffsdn_image_release | default('') }}
+
+
+
Deployed At
+
${DEPLOY_TIME}
+
+
+
staticsite blueprint
+
+
+
+
+ SITEHTML
+ echo "[OK] HTML page deployed"
+
+ # Remove default pages
+ rm -f /var/www/html/index.nginx-debian.html
+
+ # Enable and start nginx
+ systemctl enable nginx
+ systemctl restart nginx
+ echo "[OK] nginx started"
+
+ echo "=== Static site setup complete ==="
+
+- name: Push static site setup script
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/files?path=/tmp/staticsite-setup.sh"
+ method: POST
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body: "{{ site_setup_script }}"
+ headers:
+ Content-Type: "application/octet-stream"
+ X-Incus-type: "file"
+ X-Incus-mode: "0755"
+ status_code: [200]
+
+- name: Execute static site setup script
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/exec"
+ method: POST
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body_format: json
+ body:
+ command: ["/bin/bash", "/tmp/staticsite-setup.sh"]
+ record-output: true
+ interactive: false
+ wait-for-websocket: false
+ environment:
+ DEBIAN_FRONTEND: "noninteractive"
+ status_code: [202]
+ return_content: true
+ register: site_setup_exec
+
+- name: Wait for static site setup to complete (up to 3 minutes)
+ ansible.builtin.uri:
+ url: "{{ incus_api }}{{ site_setup_exec.json.operation }}/wait?timeout=180"
+ method: GET
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ return_content: true
+ timeout: 200
+ register: site_setup_wait
+ failed_when: site_setup_wait.json.metadata.status != "Success"
+
+- name: Verify static site setup exit code
+ ansible.builtin.assert:
+ that:
+ - site_setup_wait.json.metadata.metadata.return | int == 0
+ fail_msg: "Static site setup exited with code {{ site_setup_wait.json.metadata.metadata.return }}"
+ success_msg: "Static site setup completed — nginx serving on port 80"
+
+- name: Clean up setup script
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/exec"
+ method: POST
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body_format: json
+ body:
+ command: ["/bin/rm", "-f", "/tmp/staticsite-setup.sh"]
+ record-output: false
+ interactive: false
+ wait-for-websocket: false
+ status_code: [202]
+ ignore_errors: true
diff --git a/ansible/playbooks/showcase/webapp-db.yml b/ansible/playbooks/showcase/webapp-db.yml
new file mode 100644
index 0000000..e318ecd
--- /dev/null
+++ b/ansible/playbooks/showcase/webapp-db.yml
@@ -0,0 +1,148 @@
+---
+# showcase/webapp-db.yml — MariaDB setup for the webapp blueprint
+#
+# Installs MariaDB, creates webapp database and user with random password,
+# configures remote access, and stores credentials in /etc/webapp-db-creds
+# for the web tier to read via Incus API.
+
+- name: Generate random database password
+ ansible.builtin.set_fact:
+ db_password: "{{ lookup('password', '/dev/null chars=ascii_letters,digits length=20') }}"
+
+- name: Generate MariaDB setup script
+ ansible.builtin.set_fact:
+ db_setup_script: |
+ #!/bin/bash
+ set -e
+
+ echo "=== MariaDB setup starting ==="
+ export DEBIAN_FRONTEND=noninteractive
+
+ # Install MariaDB
+ apt-get install -y -qq mariadb-server 2>&1 | tail -3
+ echo "[OK] MariaDB installed"
+
+ # Start and enable
+ systemctl enable mariadb
+ systemctl start mariadb
+ echo "[OK] MariaDB started"
+
+ # Configure bind address for remote connections
+ sed -i 's/^bind-address.*=.*/bind-address = 0.0.0.0/' /etc/mysql/mariadb.conf.d/50-server.cnf
+ systemctl restart mariadb
+ echo "[OK] Bind address set to 0.0.0.0"
+
+ # Create database and user
+ mysql -u root << 'SQL'
+ CREATE DATABASE IF NOT EXISTS webapp;
+ CREATE USER IF NOT EXISTS 'webapp'@'%' IDENTIFIED BY '{{ db_password }}';
+ GRANT ALL PRIVILEGES ON webapp.* TO 'webapp'@'%';
+ FLUSH PRIVILEGES;
+
+ -- Create sample table for the web tier to query
+ USE webapp;
+ CREATE TABLE IF NOT EXISTS visits (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ visitor_ip VARCHAR(45),
+ visitor_host VARCHAR(255),
+ visited_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+ INSERT INTO visits (visitor_ip, visitor_host) VALUES ('setup', '{{ ffsdn_instance_name }}');
+ SQL
+ echo "[OK] Database 'webapp' and user created"
+
+ # Store credentials for the web tier to read
+ MY_IP=$(hostname -I | awk '{print $1}')
+ cat > /etc/webapp-db-creds << CREDS
+ DB_HOST=${MY_IP}
+ DB_PORT=3306
+ DB_NAME=webapp
+ DB_USER=webapp
+ DB_PASS={{ db_password }}
+ CREDS
+ chmod 644 /etc/webapp-db-creds
+ echo "[OK] Credentials stored in /etc/webapp-db-creds"
+
+ echo "=== MariaDB setup complete ==="
+
+- name: Push MariaDB setup script
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/files?path=/tmp/webapp-db-setup.sh"
+ method: POST
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body: "{{ db_setup_script }}"
+ headers:
+ Content-Type: "application/octet-stream"
+ X-Incus-type: "file"
+ X-Incus-mode: "0755"
+ status_code: [200]
+
+- name: Execute MariaDB setup script
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/exec"
+ method: POST
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body_format: json
+ body:
+ command: ["/bin/bash", "/tmp/webapp-db-setup.sh"]
+ record-output: true
+ interactive: false
+ wait-for-websocket: false
+ environment:
+ DEBIAN_FRONTEND: "noninteractive"
+ status_code: [202]
+ return_content: true
+ register: db_setup_exec
+
+- name: Wait for MariaDB setup to complete (up to 5 minutes)
+ ansible.builtin.uri:
+ url: "{{ incus_api }}{{ db_setup_exec.json.operation }}/wait?timeout=300"
+ method: GET
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ return_content: true
+ timeout: 320
+ register: db_setup_wait
+ failed_when: db_setup_wait.json.metadata.status != "Success"
+
+- name: Verify MariaDB setup exit code
+ ansible.builtin.assert:
+ that:
+ - db_setup_wait.json.metadata.metadata.return | int == 0
+ fail_msg: "MariaDB setup exited with code {{ db_setup_wait.json.metadata.metadata.return }}"
+ success_msg: "MariaDB setup completed successfully"
+
+- name: Verify credentials file was written
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/files?path=/etc/webapp-db-creds"
+ method: GET
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ return_content: true
+ register: creds_check
+
+- name: Display MariaDB configuration
+ ansible.builtin.debug:
+ msg: "MariaDB configured: {{ creds_check.content | trim }}"
+
+- name: Clean up setup script
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/exec"
+ method: POST
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body_format: json
+ body:
+ command: ["/bin/rm", "-f", "/tmp/webapp-db-setup.sh"]
+ record-output: false
+ interactive: false
+ wait-for-websocket: false
+ status_code: [202]
+ ignore_errors: true
diff --git a/ansible/playbooks/showcase/webapp-deploy-instance.yml b/ansible/playbooks/showcase/webapp-deploy-instance.yml
new file mode 100644
index 0000000..1876576
--- /dev/null
+++ b/ansible/playbooks/showcase/webapp-deploy-instance.yml
@@ -0,0 +1,59 @@
+---
+# showcase/webapp-deploy-instance.yml — Per-instance tasks for webapp
+#
+# Called from webapp-post-deploy.yml in a loop over ffsdn_instances.
+# The 'instance' loop var has: name, ip, component_name, image_alias, type
+# Dispatches to webapp-db.yml or webapp-web.yml based on component_name.
+
+- name: Set instance facts from loop variable
+ ansible.builtin.set_fact:
+ ffsdn_instance_name: "{{ instance.name }}"
+ ffsdn_instance_ip: "{{ instance.ip | default('') }}"
+ component_name: "{{ instance.component_name }}"
+
+- name: "Wait for {{ ffsdn_instance_name }} to be running (up to 60s)"
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/state"
+ method: GET
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ return_content: true
+ register: instance_state
+ until: instance_state.json.metadata.status == "Running"
+ retries: 12
+ delay: 5
+
+- name: Validate component
+ ansible.builtin.assert:
+ that:
+ - component_name in valid_components
+ fail_msg: >-
+ Unknown component '{{ component_name }}' for webapp blueprint.
+ Instance '{{ ffsdn_instance_name }}' component must be one of: {{ valid_components | join(', ') }}.
+
+- name: Display dispatch info
+ ansible.builtin.debug:
+ msg: "Webapp {{ component_name }} — configuring {{ ffsdn_instance_name }}"
+
+- name: Run common setup tasks
+ ansible.builtin.include_tasks: common.yml
+
+- name: Route to component-specific tasks
+ ansible.builtin.include_tasks: "webapp-{{ component_name }}.yml"
+
+- name: Remove temporary internet NIC
+ ansible.builtin.include_tasks: remove-setup-nic.yml
+
+- name: Record deployment in ledger
+ ansible.builtin.lineinfile:
+ path: /tmp/awx-deploy-ledger.log
+ line: >-
+ {{ lookup('pipe', 'date -Iseconds') }}
+ SHOWCASE-DEPLOY instance={{ ffsdn_instance_name }}
+ blueprint=webapp component={{ component_name }}
+ ip={{ ffsdn_instance_ip }}
+ cluster={{ ffsdn_cluster_name | default('unknown') }}
+ status=success
+ create: true
+ mode: "0644"
diff --git a/ansible/playbooks/showcase/webapp-web.yml b/ansible/playbooks/showcase/webapp-web.yml
new file mode 100644
index 0000000..ff40398
--- /dev/null
+++ b/ansible/playbooks/showcase/webapp-web.yml
@@ -0,0 +1,351 @@
+---
+# showcase/webapp-web.yml — nginx + PHP-FPM setup for the webapp blueprint
+#
+# Discovers the db sibling via Incus API, reads db credentials from the
+# db instance, deploys a PHP app that shows DB connectivity, and adds
+# an ACL rule to the db's ACL allowing this web instance on port 3306.
+
+# --- Step 1: Find the database instance from the deployment manifest ---
+
+- name: Find database instances from deployment
+ ansible.builtin.set_fact:
+ db_instances: >-
+ {{ ffsdn_instances | selectattr('component_name', 'equalto', 'db') | list }}
+
+- name: Validate database found
+ ansible.builtin.assert:
+ that:
+ - db_instances | length > 0
+ fail_msg: >-
+ No db component found in ffsdn_instances. The blueprint must
+ include a db component for the web tier to connect to.
+
+- name: Select primary database instance
+ ansible.builtin.set_fact:
+ db_instance:
+ name: "{{ db_instances[0].name }}"
+ ip: "{{ db_instances[0].ip }}"
+
+- name: Display database target
+ ansible.builtin.debug:
+ msg: "Using database: {{ db_instance.name }} at {{ db_instance.ip }}"
+
+# --- Step 2: Wait for and read database credentials ---
+
+- name: Wait for db credentials to be available (up to 5 minutes)
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ db_instance.name }}/files?path=/etc/webapp-db-creds"
+ method: GET
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ return_content: true
+ status_code: [200, 404]
+ register: db_creds_response
+ until: db_creds_response.status == 200
+ retries: 30
+ delay: 10
+
+- name: Parse database credentials
+ ansible.builtin.set_fact:
+ db_host: "{{ db_creds_response.content | regex_search('DB_HOST=(\\S+)', '\\1') | first }}"
+ db_port: "{{ db_creds_response.content | regex_search('DB_PORT=(\\S+)', '\\1') | first }}"
+ db_name: "{{ db_creds_response.content | regex_search('DB_NAME=(\\S+)', '\\1') | first }}"
+ db_user: "{{ db_creds_response.content | regex_search('DB_USER=(\\S+)', '\\1') | first }}"
+ db_pass: "{{ db_creds_response.content | regex_search('DB_PASS=(\\S+)', '\\1') | first }}"
+
+- name: Display parsed credentials
+ ansible.builtin.debug:
+ msg: "DB connection: {{ db_user }}@{{ db_host }}:{{ db_port }}/{{ db_name }}"
+
+# --- Step 3: Install nginx + PHP-FPM and deploy app ---
+
+- name: Generate web setup script
+ ansible.builtin.set_fact:
+ web_setup_script: |
+ #!/bin/bash
+ set -e
+
+ echo "=== Web tier setup starting ==="
+ export DEBIAN_FRONTEND=noninteractive
+
+ # Install nginx + PHP-FPM + MySQL extension
+ apt-get install -y -qq nginx php-fpm php-mysql 2>&1 | tail -3
+ echo "[OK] nginx + PHP-FPM + php-mysql installed"
+
+ # Find PHP-FPM socket path (varies by PHP version)
+ PHP_SOCK=$(find /run/php/ -name 'php*-fpm.sock' 2>/dev/null | head -1)
+ if [ -z "$PHP_SOCK" ]; then
+ PHP_VERSION=$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;')
+ PHP_SOCK="/run/php/php${PHP_VERSION}-fpm.sock"
+ fi
+ echo "[OK] PHP-FPM socket: $PHP_SOCK"
+
+ # Configure nginx with PHP
+ cat > /etc/nginx/sites-available/default << NGINX
+ server {
+ listen 80 default_server;
+ root /var/www/html;
+ index index.php index.html;
+ server_name _;
+
+ location / {
+ try_files \$uri \$uri/ =404;
+ }
+
+ location ~ \\.php\$ {
+ include snippets/fastcgi-php.conf;
+ fastcgi_pass unix:${PHP_SOCK};
+ }
+ }
+ NGINX
+ echo "[OK] nginx configured"
+
+ # Deploy PHP application
+ cat > /var/www/html/index.php << 'PHPAPP'
+ 5]
+ );
+ $db_status = 'connected';
+
+ // Record this visit
+ $stmt = $pdo->prepare('INSERT INTO visits (visitor_ip, visitor_host) VALUES (?, ?)');
+ $stmt->execute([$_SERVER['REMOTE_ADDR'] ?? 'direct', $instance]);
+
+ // Count total visits
+ $visit_count = $pdo->query('SELECT COUNT(*) FROM visits')->fetchColumn();
+ } catch (PDOException $e) {
+ $error = $e->getMessage();
+ $db_status = 'error';
+ }
+ ?>
+
+
+
+ Webapp Showcase — = htmlspecialchars($instance) ?>
+
+
+
+ Webapp Showcase
+
+
Instance Info
+
+ | Instance | = htmlspecialchars($instance) ?> |
+ | IP Address | = htmlspecialchars($ip) ?> |
+ | Component | web |
+
+
+
+
Database Connection = $db_status ?>
+
+ | Host | = htmlspecialchars($db_host) ?>:= htmlspecialchars($db_port) ?> |
+ | Database | = htmlspecialchars($db_name) ?> |
+ | Total Visits | = $visit_count ?> |
+
+ | Error | = htmlspecialchars($error) ?> |
+
+
+
+
+
+
+ PHPAPP
+ echo "[OK] PHP application deployed"
+
+ # Remove default HTML page
+ rm -f /var/www/html/index.html /var/www/html/index.nginx-debian.html
+
+ # Detect PHP-FPM service name (e.g., php8.2-fpm)
+ PHP_VERSION=$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;')
+ PHP_FPM_SVC="php${PHP_VERSION}-fpm"
+
+ # Restart services
+ systemctl enable nginx "$PHP_FPM_SVC"
+ systemctl restart "$PHP_FPM_SVC"
+ systemctl restart nginx
+ echo "[OK] Services started (PHP-FPM: $PHP_FPM_SVC)"
+
+ echo "=== Web tier setup complete ==="
+
+- name: Generate web config file content
+ ansible.builtin.set_fact:
+ web_config_content: |
+ DB_HOST={{ db_host }}
+ DB_PORT={{ db_port }}
+ DB_NAME={{ db_name }}
+ DB_USER={{ db_user }}
+ DB_PASS={{ db_pass }}
+ INSTANCE_IP={{ ffsdn_instance_ip | default('unknown') }}
+
+- name: Push web config to instance
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/files?path=/etc/webapp-web-config"
+ method: POST
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body: "{{ web_config_content }}"
+ headers:
+ Content-Type: "application/octet-stream"
+ X-Incus-type: "file"
+ X-Incus-mode: "0644"
+ status_code: [200]
+
+- name: Push web setup script
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/files?path=/tmp/webapp-web-setup.sh"
+ method: POST
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body: "{{ web_setup_script }}"
+ headers:
+ Content-Type: "application/octet-stream"
+ X-Incus-type: "file"
+ X-Incus-mode: "0755"
+ status_code: [200]
+
+- name: Execute web setup script
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/exec"
+ method: POST
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body_format: json
+ body:
+ command: ["/bin/bash", "/tmp/webapp-web-setup.sh"]
+ record-output: true
+ interactive: false
+ wait-for-websocket: false
+ environment:
+ DEBIAN_FRONTEND: "noninteractive"
+ status_code: [202]
+ return_content: true
+ register: web_setup_exec
+
+- name: Wait for web setup to complete (up to 5 minutes)
+ ansible.builtin.uri:
+ url: "{{ incus_api }}{{ web_setup_exec.json.operation }}/wait?timeout=300"
+ method: GET
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ return_content: true
+ timeout: 320
+ register: web_setup_wait
+ failed_when: web_setup_wait.json.metadata.status != "Success"
+
+- name: Verify web setup exit code
+ ansible.builtin.assert:
+ that:
+ - web_setup_wait.json.metadata.metadata.return | int == 0
+ fail_msg: "Web setup exited with code {{ web_setup_wait.json.metadata.metadata.return }}"
+ success_msg: "Web tier setup completed successfully"
+
+# --- Step 4: Add ACL rule to allow this web instance to reach the DB ---
+
+- name: Build db ACL name
+ ansible.builtin.set_fact:
+ db_acl_name: "52-{{ db_instance.name }}-aether-acl"
+
+- name: Read current db ACL
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/network-acls/{{ db_acl_name }}"
+ method: GET
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ return_content: true
+ status_code: [200, 404]
+ register: db_acl_response
+
+- name: Build updated ingress rules (append web access)
+ ansible.builtin.set_fact:
+ updated_ingress: >-
+ {{ (db_acl_response.json.metadata.ingress | default([])) + [{
+ 'action': 'allow',
+ 'state': 'enabled',
+ 'source': (ffsdn_instance_ip | default('')) + '/32',
+ 'destination': db_instance.ip + '/32',
+ 'protocol': 'tcp',
+ 'destination_port': '3306',
+ 'description': 'Allow ' + ffsdn_instance_name + ' to db on 3306'
+ }] }}
+ when: db_acl_response.status == 200
+
+- name: Update db ACL with web access rule
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/network-acls/{{ db_acl_name }}"
+ method: PATCH
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body_format: json
+ body:
+ ingress: "{{ updated_ingress }}"
+ status_code: [200]
+ when:
+ - db_acl_response.status == 200
+ - updated_ingress is defined
+
+- name: Report ACL update
+ ansible.builtin.debug:
+ msg: >-
+ {% if db_acl_response.status == 200 %}ACL '{{ db_acl_name }}' updated:
+ {{ ffsdn_instance_name }} ({{ ffsdn_instance_ip | default('?') }}) → {{ db_instance.name }}:3306
+ {% else %}ACL '{{ db_acl_name }}' not found — skipping ACL rule (Aether may not have created it yet){% endif %}
+
+- name: Clean up setup script
+ ansible.builtin.uri:
+ url: "{{ incus_api }}/1.0/instances/{{ ffsdn_instance_name }}/exec"
+ method: POST
+ client_cert: "{{ incus_cert }}"
+ client_key: "{{ incus_key }}"
+ validate_certs: false
+ body_format: json
+ body:
+ command: ["/bin/rm", "-f", "/tmp/webapp-web-setup.sh"]
+ record-output: false
+ interactive: false
+ wait-for-websocket: false
+ status_code: [202]
+ ignore_errors: true
diff --git a/ansible/playbooks/staticsite-decommission.yml b/ansible/playbooks/staticsite-decommission.yml
new file mode 100644
index 0000000..7dca184
--- /dev/null
+++ b/ansible/playbooks/staticsite-decommission.yml
@@ -0,0 +1,29 @@
+---
+# staticsite-decommission.yml — Decommission staticsite blueprint instances
+#
+# Aether sends ffsdn_instances (a list of all instances in the deployment).
+# This playbook loops over each instance for graceful cleanup.
+
+- name: Staticsite decommission — graceful cleanup via Incus API
+ hosts: localhost
+ gather_facts: false
+ connection: local
+
+ vars:
+ incus_api: "https://192.168.102.140:8443"
+ incus_cert: "/runner/project/incus-client.crt"
+ incus_key: "/runner/project/incus-client.key"
+
+ tasks:
+ - name: Validate required variables
+ ansible.builtin.assert:
+ that:
+ - ffsdn_instances is defined
+ - ffsdn_instances | length > 0
+ fail_msg: "ffsdn_instances must be provided by Aether"
+
+ - name: Decommission each instance
+ ansible.builtin.include_tasks: showcase/decommission/staticsite-instance.yml
+ loop: "{{ ffsdn_instances }}"
+ loop_control:
+ loop_var: instance
diff --git a/ansible/playbooks/staticsite-post-deploy.yml b/ansible/playbooks/staticsite-post-deploy.yml
new file mode 100644
index 0000000..068bc11
--- /dev/null
+++ b/ansible/playbooks/staticsite-post-deploy.yml
@@ -0,0 +1,31 @@
+---
+# staticsite-post-deploy.yml — Configure staticsite blueprint instances
+#
+# Aether sends ffsdn_instances (a list of all instances in the deployment).
+# This playbook loops over each instance and includes the per-instance tasks.
+
+- name: Staticsite post-deploy — configure instances via Incus API
+ hosts: localhost
+ gather_facts: false
+ connection: local
+
+ vars:
+ incus_api: "https://192.168.102.140:8443"
+ incus_cert: "/runner/project/incus-client.crt"
+ incus_key: "/runner/project/incus-client.key"
+ base_packages: "curl vim htop jq"
+ blueprint_name: "staticsite"
+
+ tasks:
+ - name: Validate required variables
+ ansible.builtin.assert:
+ that:
+ - ffsdn_instances is defined
+ - ffsdn_instances | length > 0
+ fail_msg: "ffsdn_instances must be provided by Aether"
+
+ - name: Configure each instance
+ ansible.builtin.include_tasks: showcase/staticsite-deploy-instance.yml
+ loop: "{{ ffsdn_instances }}"
+ loop_control:
+ loop_var: instance
diff --git a/ansible/playbooks/webapp-decommission.yml b/ansible/playbooks/webapp-decommission.yml
new file mode 100644
index 0000000..350dc89
--- /dev/null
+++ b/ansible/playbooks/webapp-decommission.yml
@@ -0,0 +1,33 @@
+---
+# webapp-decommission.yml — Decommission webapp blueprint instances
+#
+# Aether sends ffsdn_instances (a list of all instances in the deployment).
+# This playbook loops over each instance and routes to component-specific cleanup.
+
+- name: Webapp decommission — graceful cleanup via Incus API
+ hosts: localhost
+ gather_facts: false
+ connection: local
+
+ vars:
+ incus_api: "https://192.168.102.140:8443"
+ incus_cert: "/runner/project/incus-client.crt"
+ incus_key: "/runner/project/incus-client.key"
+ blueprint_name: "webapp"
+ decommission_tasks:
+ db: "showcase/decommission/webapp-db.yml"
+ web: "showcase/decommission/webapp-web.yml"
+
+ tasks:
+ - name: Validate required variables
+ ansible.builtin.assert:
+ that:
+ - ffsdn_instances is defined
+ - ffsdn_instances | length > 0
+ fail_msg: "ffsdn_instances must be provided by Aether"
+
+ - name: Decommission each instance
+ ansible.builtin.include_tasks: showcase/decommission/webapp-instance.yml
+ loop: "{{ ffsdn_instances }}"
+ loop_control:
+ loop_var: instance
diff --git a/ansible/playbooks/webapp-post-deploy.yml b/ansible/playbooks/webapp-post-deploy.yml
new file mode 100644
index 0000000..fc79d60
--- /dev/null
+++ b/ansible/playbooks/webapp-post-deploy.yml
@@ -0,0 +1,32 @@
+---
+# webapp-post-deploy.yml — Configure webapp blueprint instances
+#
+# Aether sends ffsdn_instances (a list of all instances in the deployment).
+# This playbook loops over each instance and dispatches to db or web tasks.
+
+- name: Webapp post-deploy — configure instances via Incus API
+ hosts: localhost
+ gather_facts: false
+ connection: local
+
+ vars:
+ incus_api: "https://192.168.102.140:8443"
+ incus_cert: "/runner/project/incus-client.crt"
+ incus_key: "/runner/project/incus-client.key"
+ base_packages: "curl vim htop jq"
+ blueprint_name: "webapp"
+ valid_components: ["db", "web"]
+
+ tasks:
+ - name: Validate required variables
+ ansible.builtin.assert:
+ that:
+ - ffsdn_instances is defined
+ - ffsdn_instances | length > 0
+ fail_msg: "ffsdn_instances must be provided by Aether"
+
+ - name: Configure each instance
+ ansible.builtin.include_tasks: showcase/webapp-deploy-instance.yml
+ loop: "{{ ffsdn_instances }}"
+ loop_control:
+ loop_var: instance
diff --git a/incusos/deploy-awx b/incusos/deploy-awx
index 771de01..316d888 100755
--- a/incusos/deploy-awx
+++ b/incusos/deploy-awx
@@ -797,7 +797,7 @@ action_configure() {
if [[ -f "$incus_cert" && -f "$incus_key" ]]; then
local task_pod
task_pod=$(vm_exec kubectl -n "$AWX_NAMESPACE" get pods \
- -l app.kubernetes.io/component=awx-task -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true)
+ -l app.kubernetes.io/name=awx-task -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true)
if [[ -n "$task_pod" ]]; then
local project_dir="/var/lib/awx/projects/incus-contrib"
vm_exec kubectl -n "$AWX_NAMESPACE" exec "$task_pod" -- mkdir -p "$project_dir"
@@ -841,11 +841,17 @@ action_configure() {
if [[ -n "$project_id" ]]; then
local task_pod
task_pod=$(vm_exec kubectl -n "$AWX_NAMESPACE" get pods \
- -l app.kubernetes.io/component=awx-task -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true)
+ -l app.kubernetes.io/name=awx-task -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true)
if [[ -n "$task_pod" ]]; then
local project_dir="/var/lib/awx/projects/incus-contrib"
- vm_exec kubectl -n "$AWX_NAMESPACE" exec "$task_pod" -- mkdir -p "${project_dir}/playbooks"
- for playbook in post-deploy.yml decommission.yml; do
+ vm_exec kubectl -n "$AWX_NAMESPACE" exec "$task_pod" -- \
+ mkdir -p "${project_dir}/playbooks" \
+ "${project_dir}/playbooks/showcase" \
+ "${project_dir}/playbooks/showcase/decommission"
+ for playbook in post-deploy.yml decommission.yml \
+ webapp-post-deploy.yml webapp-decommission.yml \
+ staticsite-post-deploy.yml staticsite-decommission.yml \
+ k3scluster-post-deploy.yml k3scluster-decommission.yml; do
local pb_path="${SCRIPT_DIR}/../ansible/playbooks/${playbook}"
if [[ -f "$pb_path" ]]; then
local pb_b64
@@ -857,6 +863,32 @@ action_configure() {
warn "Playbook not found: ${pb_path}"
fi
done
+ # Push showcase task files
+ for taskfile in showcase/common.yml showcase/helpers.yml \
+ showcase/remove-setup-nic.yml \
+ showcase/webapp-db.yml showcase/webapp-web.yml \
+ showcase/staticsite-site.yml \
+ showcase/k3s-control.yml showcase/k3s-worker.yml \
+ showcase/staticsite-deploy-instance.yml \
+ showcase/webapp-deploy-instance.yml \
+ showcase/k3scluster-deploy-instance.yml \
+ showcase/decommission/webapp-db.yml \
+ showcase/decommission/webapp-web.yml \
+ showcase/decommission/k3s.yml \
+ showcase/decommission/staticsite-instance.yml \
+ showcase/decommission/webapp-instance.yml \
+ showcase/decommission/k3scluster-instance.yml; do
+ local tf_path="${SCRIPT_DIR}/../ansible/playbooks/${taskfile}"
+ if [[ -f "$tf_path" ]]; then
+ local tf_b64
+ tf_b64=$(base64 -w0 "$tf_path")
+ vm_exec kubectl -n "$AWX_NAMESPACE" exec "$task_pod" -- \
+ bash -c "echo '${tf_b64}' | base64 -d > ${project_dir}/playbooks/${taskfile}"
+ detail "Pushed ${taskfile}"
+ else
+ warn "Task file not found: ${tf_path}"
+ fi
+ done
success "Playbooks pushed to AWX project directory"
else
warn "Cannot find AWX task pod — push playbooks manually"
@@ -934,11 +966,56 @@ print(json.dumps(d))")
fi
done
+ # 6. Create per-blueprint job templates
+ step "Creating blueprint job templates"
+ for tmpl_name in webapp-post-deploy webapp-decommission \
+ staticsite-post-deploy staticsite-decommission \
+ k3scluster-post-deploy k3scluster-decommission; do
+ local tmpl_id
+ tmpl_id=$(awx_auth GET "/api/v2/job_templates/?name=${tmpl_name}" 2>/dev/null \
+ | python3 -c "import sys,json; r=json.load(sys.stdin); print(r['results'][0]['id'] if r.get('count',0)>0 else '')" 2>/dev/null || true)
+
+ if [[ -z "$tmpl_id" ]]; then
+ local tmpl_payload
+ tmpl_payload=$(python3 -c "
+import json
+d = {
+ 'name': '${tmpl_name}',
+ 'organization': 1,
+ 'job_type': 'run',
+ 'ask_variables_on_launch': True,
+ 'playbook': '${PLAYBOOK_DIR}/${tmpl_name}.yml'
+}
+pid = '${project_id}'
+iid = '${inventory_id}'
+cid = '${machine_cred_id}'
+if pid: d['project'] = int(pid)
+if iid: d['inventory'] = int(iid)
+print(json.dumps(d))")
+
+ tmpl_id=$(awx_auth POST "/api/v2/job_templates/" -d "$tmpl_payload" 2>/dev/null \
+ | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
+
+ # Associate credential
+ if [[ -n "$tmpl_id" && -n "$machine_cred_id" ]]; then
+ awx_auth POST "/api/v2/job_templates/${tmpl_id}/credentials/" \
+ -d "{\"id\":${machine_cred_id}}" 2>/dev/null || true
+ fi
+
+ [[ -n "$tmpl_id" ]] && success "Template '${tmpl_name}' created (ID: ${tmpl_id})" || warn "Failed to create template '${tmpl_name}'"
+ else
+ success "Template '${tmpl_name}' exists (ID: ${tmpl_id})"
+ fi
+ done
+
echo
success "AWX configuration complete"
echo
info "Template IDs (for Aether binding):"
- for tmpl_name in post-deploy decommission; do
+ for tmpl_name in post-deploy decommission \
+ webapp-post-deploy webapp-decommission \
+ staticsite-post-deploy staticsite-decommission \
+ k3scluster-post-deploy k3scluster-decommission; do
local tid
tid=$(awx_auth GET "/api/v2/job_templates/?name=${tmpl_name}" 2>/dev/null \
| python3 -c "import sys,json; r=json.load(sys.stdin); print(r['results'][0]['id'] if r.get('count',0)>0 else '')" 2>/dev/null || true)