From a526c4ea9f0e9acdf5413ba1f081378b24f044f1 Mon Sep 17 00:00:00 2001 From: Maarten Date: Thu, 26 Feb 2026 15:16:49 +0100 Subject: [PATCH] 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 --- ansible/playbooks/k3scluster-decommission.yml | 29 ++ ansible/playbooks/k3scluster-post-deploy.yml | 32 ++ ansible/playbooks/showcase/common.yml | 231 ++++++++++++ .../playbooks/showcase/decommission/k3s.yml | 87 +++++ .../decommission/k3scluster-instance.yml | 42 +++ .../decommission/staticsite-instance.yml | 56 +++ .../showcase/decommission/webapp-db.yml | 43 +++ .../showcase/decommission/webapp-instance.yml | 42 +++ .../showcase/decommission/webapp-web.yml | 82 ++++ ansible/playbooks/showcase/helpers.yml | 65 ++++ ansible/playbooks/showcase/k3s-control.yml | 142 +++++++ ansible/playbooks/showcase/k3s-worker.yml | 345 +++++++++++++++++ .../showcase/k3scluster-deploy-instance.yml | 127 +++++++ .../playbooks/showcase/remove-setup-nic.yml | 69 ++++ .../showcase/staticsite-deploy-instance.yml | 50 +++ .../playbooks/showcase/staticsite-site.yml | 161 ++++++++ ansible/playbooks/showcase/webapp-db.yml | 148 ++++++++ .../showcase/webapp-deploy-instance.yml | 59 +++ ansible/playbooks/showcase/webapp-web.yml | 351 ++++++++++++++++++ ansible/playbooks/staticsite-decommission.yml | 29 ++ ansible/playbooks/staticsite-post-deploy.yml | 31 ++ ansible/playbooks/webapp-decommission.yml | 33 ++ ansible/playbooks/webapp-post-deploy.yml | 32 ++ incusos/deploy-awx | 87 ++++- 24 files changed, 2368 insertions(+), 5 deletions(-) create mode 100644 ansible/playbooks/k3scluster-decommission.yml create mode 100644 ansible/playbooks/k3scluster-post-deploy.yml create mode 100644 ansible/playbooks/showcase/common.yml create mode 100644 ansible/playbooks/showcase/decommission/k3s.yml create mode 100644 ansible/playbooks/showcase/decommission/k3scluster-instance.yml create mode 100644 ansible/playbooks/showcase/decommission/staticsite-instance.yml create mode 100644 ansible/playbooks/showcase/decommission/webapp-db.yml create mode 100644 ansible/playbooks/showcase/decommission/webapp-instance.yml create mode 100644 ansible/playbooks/showcase/decommission/webapp-web.yml create mode 100644 ansible/playbooks/showcase/helpers.yml create mode 100644 ansible/playbooks/showcase/k3s-control.yml create mode 100644 ansible/playbooks/showcase/k3s-worker.yml create mode 100644 ansible/playbooks/showcase/k3scluster-deploy-instance.yml create mode 100644 ansible/playbooks/showcase/remove-setup-nic.yml create mode 100644 ansible/playbooks/showcase/staticsite-deploy-instance.yml create mode 100644 ansible/playbooks/showcase/staticsite-site.yml create mode 100644 ansible/playbooks/showcase/webapp-db.yml create mode 100644 ansible/playbooks/showcase/webapp-deploy-instance.yml create mode 100644 ansible/playbooks/showcase/webapp-web.yml create mode 100644 ansible/playbooks/staticsite-decommission.yml create mode 100644 ansible/playbooks/staticsite-post-deploy.yml create mode 100644 ansible/playbooks/webapp-decommission.yml create mode 100644 ansible/playbooks/webapp-post-deploy.yml 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
IP Address
Componentweb
+
+
+

Database Connection

+ + + + + + + +
Host:
Database
Total Visits
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)