Add AWX showcase blueprints: webapp, staticsite, k3scluster

Three end-to-end blueprint examples demonstrating AWX configuring real
applications via Aether lifecycle hooks:

- webapp: nginx+PHP web tier + MariaDB with cross-instance service
  discovery, credential sharing, and OVN ACL management
- staticsite: horizontally scaled nginx with per-instance branding
- k3scluster: K3s control plane + workers with automatic token
  discovery and cluster join (fixes Incus files API symlink issue
  where node-token symlink returns target path instead of content)

Includes dispatcher playbooks, shared helpers (sibling discovery,
temporary internet NIC), decommission handlers, and deploy-awx
changes to push showcase playbooks and create AWX templates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maarten 2026-02-26 15:16:49 +01:00
parent 5ee32fe08c
commit a526c4ea9f
24 changed files with 2368 additions and 5 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
<!DOCTYPE html>
<html>
<head>
<title>{{ ffsdn_instance_name }} — Static Site Showcase</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; }
.container { background: white; border-radius: 16px; padding: 48px; max-width: 600px; width: 90%; box-shadow: 0 20px 60px rgba(0,0,0,0.3); }
h1 { font-size: 28px; color: #1a1a2e; margin-bottom: 8px; }
.subtitle { color: #888; font-size: 14px; margin-bottom: 32px; }
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.info-card { background: #f8f9fa; border-radius: 8px; padding: 16px; }
.info-label { font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
.info-value { font-size: 18px; font-weight: 600; color: #1a1a2e; word-break: break-all; }
.badge { display: inline-block; background: #667eea; color: white; padding: 4px 12px; border-radius: 12px; font-size: 13px; font-weight: 600; margin-top: 24px; }
.footer { text-align: center; margin-top: 32px; color: #aaa; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<h1>{{ ffsdn_instance_name }}</h1>
<div class="subtitle">Static Site Showcase &mdash; deployed by AWX via Aether</div>
<div class="info-grid">
<div class="info-card">
<div class="info-label">Instance</div>
<div class="info-value">{{ ffsdn_instance_name }}</div>
</div>
<div class="info-card">
<div class="info-label">IP Address</div>
<div class="info-value">${MY_IP}</div>
</div>
<div class="info-card">
<div class="info-label">Cluster</div>
<div class="info-value">{{ ffsdn_cluster_name | default('unknown') }}</div>
</div>
<div class="info-card">
<div class="info-label">Deployed By</div>
<div class="info-value">{{ ffsdn_deployed_by | default('unknown') }}</div>
</div>
<div class="info-card">
<div class="info-label">Image</div>
<div class="info-value">{{ ffsdn_image_os | default('') }} {{ ffsdn_image_release | default('') }}</div>
</div>
<div class="info-card">
<div class="info-label">Deployed At</div>
<div class="info-value">${DEPLOY_TIME}</div>
</div>
</div>
<div style="text-align:center"><span class="badge">staticsite blueprint</span></div>
<div class="footer">
Horizontal scaling demo &mdash; each instance independently configured
</div>
</div>
</body>
</html>
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

View File

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

View File

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

View File

@ -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'
<?php
$config = [];
foreach (file('/etc/webapp-web-config') as $line) {
$line = trim($line);
if ($line && strpos($line, '=') !== false) {
list($key, $value) = explode('=', $line, 2);
$config[trim($key)] = trim($value);
}
}
$db_host = $config['DB_HOST'] ?? 'unknown';
$db_port = $config['DB_PORT'] ?? '3306';
$db_name = $config['DB_NAME'] ?? 'webapp';
$db_user = $config['DB_USER'] ?? 'webapp';
$db_pass = $config['DB_PASS'] ?? '';
$instance = gethostname();
$ip = $config['INSTANCE_IP'] ?? 'unknown';
$db_status = 'disconnected';
$visit_count = 0;
$error = '';
try {
$pdo = new PDO(
"mysql:host={$db_host};port={$db_port};dbname={$db_name}",
$db_user, $db_pass,
[PDO::ATTR_TIMEOUT => 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';
}
?>
<!DOCTYPE html>
<html>
<head>
<title>Webapp Showcase — <?= htmlspecialchars($instance) ?></title>
<style>
body { font-family: -apple-system, sans-serif; max-width: 700px; margin: 40px auto; padding: 0 20px; background: #f5f5f5; }
.card { background: white; border-radius: 8px; padding: 24px; margin: 16px 0; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
h1 { color: #1a1a2e; }
.status { display: inline-block; padding: 4px 12px; border-radius: 12px; font-size: 14px; font-weight: 600; }
.connected { background: #d4edda; color: #155724; }
.error, .disconnected { background: #f8d7da; color: #721c24; }
table { width: 100%; border-collapse: collapse; }
td { padding: 8px; border-bottom: 1px solid #eee; }
td:first-child { font-weight: 600; width: 40%; color: #555; }
.footer { text-align: center; color: #888; font-size: 13px; margin-top: 24px; }
</style>
</head>
<body>
<h1>Webapp Showcase</h1>
<div class="card">
<h2>Instance Info</h2>
<table>
<tr><td>Instance</td><td><?= htmlspecialchars($instance) ?></td></tr>
<tr><td>IP Address</td><td><?= htmlspecialchars($ip) ?></td></tr>
<tr><td>Component</td><td>web</td></tr>
</table>
</div>
<div class="card">
<h2>Database Connection <span class="status <?= $db_status ?>"><?= $db_status ?></span></h2>
<table>
<tr><td>Host</td><td><?= htmlspecialchars($db_host) ?>:<?= htmlspecialchars($db_port) ?></td></tr>
<tr><td>Database</td><td><?= htmlspecialchars($db_name) ?></td></tr>
<tr><td>Total Visits</td><td><?= $visit_count ?></td></tr>
<?php if ($error): ?>
<tr><td>Error</td><td style="color:#721c24"><?= htmlspecialchars($error) ?></td></tr>
<?php endif; ?>
</table>
</div>
<div class="footer">
Deployed by AWX via Aether &mdash; <?= date('Y-m-d H:i:s T') ?>
</div>
</body>
</html>
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

View File

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

View File

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

View File

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

View File

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

View File

@ -797,7 +797,7 @@ action_configure() {
if [[ -f "$incus_cert" && -f "$incus_key" ]]; then if [[ -f "$incus_cert" && -f "$incus_key" ]]; then
local task_pod local task_pod
task_pod=$(vm_exec kubectl -n "$AWX_NAMESPACE" get pods \ 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 if [[ -n "$task_pod" ]]; then
local project_dir="/var/lib/awx/projects/incus-contrib" local project_dir="/var/lib/awx/projects/incus-contrib"
vm_exec kubectl -n "$AWX_NAMESPACE" exec "$task_pod" -- mkdir -p "$project_dir" vm_exec kubectl -n "$AWX_NAMESPACE" exec "$task_pod" -- mkdir -p "$project_dir"
@ -841,11 +841,17 @@ action_configure() {
if [[ -n "$project_id" ]]; then if [[ -n "$project_id" ]]; then
local task_pod local task_pod
task_pod=$(vm_exec kubectl -n "$AWX_NAMESPACE" get pods \ 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 if [[ -n "$task_pod" ]]; then
local project_dir="/var/lib/awx/projects/incus-contrib" local project_dir="/var/lib/awx/projects/incus-contrib"
vm_exec kubectl -n "$AWX_NAMESPACE" exec "$task_pod" -- mkdir -p "${project_dir}/playbooks" vm_exec kubectl -n "$AWX_NAMESPACE" exec "$task_pod" -- \
for playbook in post-deploy.yml decommission.yml; do 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}" local pb_path="${SCRIPT_DIR}/../ansible/playbooks/${playbook}"
if [[ -f "$pb_path" ]]; then if [[ -f "$pb_path" ]]; then
local pb_b64 local pb_b64
@ -857,6 +863,32 @@ action_configure() {
warn "Playbook not found: ${pb_path}" warn "Playbook not found: ${pb_path}"
fi fi
done 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" success "Playbooks pushed to AWX project directory"
else else
warn "Cannot find AWX task pod — push playbooks manually" warn "Cannot find AWX task pod — push playbooks manually"
@ -934,11 +966,56 @@ print(json.dumps(d))")
fi fi
done 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 echo
success "AWX configuration complete" success "AWX configuration complete"
echo echo
info "Template IDs (for Aether binding):" 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 local tid
tid=$(awx_auth GET "/api/v2/job_templates/?name=${tmpl_name}" 2>/dev/null \ 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) | 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)