From 1020c4559a32fa5b669fde68cb0ea9d0ba280b4b Mon Sep 17 00:00:00 2001 From: Thomas Rijpstra Date: Thu, 25 Jun 2026 09:55:12 +0200 Subject: [PATCH] feat(ships): add mars ship and shipyard bootstrap assets Introduce ships/mars wiring all new quadlet modules to the host, and add the shipyard/ assets (cloud-init and the noble cloud image) used to provision new ships. --- ships/mars/main.tf | 288 +++++++++++++++++++++ shipyard/cloud-init.yml | 561 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 849 insertions(+) create mode 100644 ships/mars/main.tf create mode 100644 shipyard/cloud-init.yml diff --git a/ships/mars/main.tf b/ships/mars/main.tf new file mode 100644 index 0000000..4554d71 --- /dev/null +++ b/ships/mars/main.tf @@ -0,0 +1,288 @@ +variable "hcloud_token" { + type = string + sensitive = true +} + +variable "hdns_token" { + type = string + sensitive = true +} + +variable "ssh_public_key_path" { + description = "Path to SSH public key" + type = string +} + +variable "ssh_private_key_path" { + description = "Path to SSH private key" + type = string +} + +variable "server" { + default = "mars" +} + +variable "server_domain" { + default = "mars.fourlights.dev" +} + +variable "ghcr_username" {} +variable "ghcr_token" {} + +resource "null_resource" "is_up" { + connection { + type = "ssh" + host = var.server + user = "fourlights" + timeout = "10m" + agent = true + agent_identity = var.ssh_private_key_path + } + + provisioner "remote-exec" { + inline = [ + "whoami", + ] + } +} + +module "containers-network" { + wait_on = null_resource.is_up + source = "../../quadlets/network" + + server_ip = var.server + ssh_private_key_path = var.ssh_private_key_path + + name = "containers" +} + +module "minio" { + wait_on = null_resource.is_up + source = "../../quadlets/modules/minio" + server_ip = var.server + server_domain = var.server_domain + ssh_private_key_path = var.ssh_private_key_path + https = true +} + +module "valkey" { + wait_on = null_resource.is_up + source = "../../quadlets/modules/valkey" + server_ip = var.server + ssh_private_key_path = var.ssh_private_key_path +} + +module "affine" { + wait_on = module.postgres.installed + source = "../../quadlets/modules/affine" + server_ip = var.server + server_domain = var.server_domain + ssh_private_key_path = var.ssh_private_key_path + + # Pass postgres password for database connection + postgres_password = module.postgres.password +} + +module "oci-proxy" { + wait_on = null_resource.is_up + source = "../../quadlets/modules/oci-proxy" + server_ip = var.server + ssh_private_key_path = var.ssh_private_key_path +} + +module "arcane" { + wait_on = module.oci-proxy.installed + source = "../../quadlets/modules/arcane" + server_domain = var.server_domain + server_ip = var.server + ssh_private_key_path = var.ssh_private_key_path +} + +module "qdrant" { + wait_on = null_resource.is_up + source = "../../quadlets/modules/qdrant" + server_ip = var.server + ssh_private_key_path = var.ssh_private_key_path + server_domain = var.server_domain +} + +module "documentdb" { + wait_on = null_resource.is_up + source = "../../quadlets/modules/documentdb" + server_ip = var.server + ssh_private_key_path = var.ssh_private_key_path +} + +#module "opensign" { +# wait_on = module.documentdb.installed +# source = "../../quadlets/modules/opensign" +# server_ip = var.server +# server_domain = var.server_domain +# ssh_private_key_path = var.ssh_private_key_path +#} + +module "postgres" { + wait_on = null_resource.is_up + source = "../../quadlets/modules/postgres" + server_ip = var.server + ssh_private_key_path = var.ssh_private_key_path +} + +module "rabbitmq" { + wait_on = null_resource.is_up + source = "../../quadlets/modules/rabbitmq" + server_ip = var.server + ssh_private_key_path = var.ssh_private_key_path +} + +#module "plane" { +# count = 0 +# wait_on = null_resource.is_up +# source = "../../quadlets/modules/plane" +# server_ip = var.server +# ssh_private_key_path = var.ssh_private_key_path +# server_domain = var.server_domain +#} + +#module "airsonic-advanced" { +# wait_on = null_resource.is_up +# source = "../../quadlets/modules/airsonic-advanced" +# server_ip = var.server +# ssh_private_key_path = var.ssh_private_key_path +# server_domain = var.server_domain +#} + +module "gonic" { + wait_on = null_resource.is_up + source = "../../quadlets/modules/gonic" + server_ip = var.server + server_domain = var.server_domain + ssh_private_key_path = var.ssh_private_key_path +} + +module "tmail-web" { + wait_on = null_resource.is_up + source = "../../quadlets/modules/tmail-web" + server_ip = var.server + server_domain = var.server_domain + ssh_private_key_path = var.ssh_private_key_path + server_url = "https://mail.binarysunset.dev" +} + +module "navidrome" { + wait_on = null_resource.is_up + source = "../../quadlets/modules/navidrome" + server_ip = var.server + ssh_private_key_path = var.ssh_private_key_path + server_domain = var.server_domain +} + +module "mopidy" { + wait_on = null_resource.is_up + source = "../../quadlets/modules/mopidy" + server_ip = var.server + ssh_private_key_path = var.ssh_private_key_path + server_domain = var.server_domain +} + +module "zot" { + wait_on = null_resource.is_up + source = "../../quadlets/modules/zot" + server_ip = var.server + ssh_private_key_path = var.ssh_private_key_path + server_domain = var.server_domain +} + +# Deploy Plane after all dependencies are ready +#module "plane" { +# wait_on = null_resource.is_up +# +# source = "../../quadlets/modules/plane" +# server_ip = var.server +# server_domain = var.server_domain +# ssh_private_key_path = var.ssh_private_key_path +# +# # Pass credentials from existing services +# postgres_password = module.postgres.password +# minio_server = module.minio.server +# minio_access_key = module.minio.access_key +# minio_secret_key = module.minio.secret_key +# rabbitmq_username = module.rabbitmq.username +# rabbitmq_password = module.rabbitmq.password +#} + +module "beets" { + wait_on = null_resource.is_up + source = "../../quadlets/modules/beets" + server_ip = var.server + server_domain = var.server_domain + ssh_private_key_path = var.ssh_private_key_path +} + +module "deeptutor" { + count = 0 + wait_on = null_resource.is_up + source = "../../quadlets/modules/deeptutor" + server_ip = var.server + server_domain = var.server_domain + ssh_private_key_path = var.ssh_private_key_path +} + +output "psql_pw" { + value = module.postgres.password + sensitive = true +} + +module "forgejo" { + wait_on = module.postgres.installed + source = "../../quadlets/modules/forgejo" + server_ip = var.server + server_domain = var.server_domain + ssh_private_key_path = var.ssh_private_key_path + + # Pass postgres password for database connection + postgres_password = module.postgres.password +} + +output "minio_app_urls" { + value = module.minio.app_urls +} + +output "qdrant_api_key" { + value = module.qdrant.api_key + sensitive = true +} + +#output "plane_app_urls" { +# value = module.plane.app_urls +#} +# +#output "plane_credentials" { +# value = module.plane.credentials +# sensitive = true +#} +# +#output "plane_main_url" { +# value = module.plane.main_url +#} + +output "forgejo_app_urls" { + value = module.forgejo.app_urls +} + +output "affine_app_urls" { + value = module.affine.app_urls +} + +#output "deeptutor_app_urls" { +# value = module.deeptutor.app_urls +#} + +output "tmail_web_app_urls" { + value = module.tmail-web.app_urls +} + +#output "plane_credentials" { +#value = module.plane.credentials +#sensitive = true +#} \ No newline at end of file diff --git a/shipyard/cloud-init.yml b/shipyard/cloud-init.yml new file mode 100644 index 0000000..199790a --- /dev/null +++ b/shipyard/cloud-init.yml @@ -0,0 +1,561 @@ +#cloud-config +users: + - name: fourlights + sudo: ALL=(ALL) NOPASSWD:ALL + groups: users,admin,sudo + shell: /bin/bash + lock_passwd: false + ssh_authorized_keys: + - ${ssh_public_key} + +packages: + - podman + - haproxy + - python3 + - python3-requests + - curl + - wget + - jq + - socat + - nmap + +package_update: true +package_upgrade: true + +write_files: + - path: /etc/sudoers.d/fourlights-haproxy + permissions: '0440' + content: | + fourlights ALL=(root) NOPASSWD: /bin/systemctl reload haproxy + fourlights ALL=(root) NOPASSWD: /bin/systemctl restart haproxy + fourlights ALL=(root) NOPASSWD: /bin/systemctl stop haproxy + fourlights ALL=(root) NOPASSWD: /bin/systemctl start haproxy + fourlights ALL=(root) NOPASSWD: /bin/chown -R haproxy\:haproxy /etc/ssl/haproxy/* + fourlights ALL=(root) NOPASSWD: /bin/chmod 600 /etc/ssl/haproxy/* + # HAProxy main configuration + - path: /etc/haproxy/haproxy.cfg + content: | + global + daemon + stats socket /var/run/haproxy/admin.sock mode 660 level admin expose-fd listeners + stats timeout 30s + user haproxy + group haproxy + log stdout local0 info + + defaults + mode http + timeout connect 5000ms + timeout client 50000ms + timeout server 50000ms + option httplog + log global + + # Stats interface + frontend stats + bind *:8404 + http-request use-service prometheus-exporter if { path /metrics } + stats enable + stats uri /stats + stats refresh 10s + + # HTTP Frontend + frontend main + bind *:80 + # ACL to detect ACME challenge requests + acl is_acme_challenge path_beg /.well-known/acme-challenge/ + # Route ACME challenges to the acme_challenge backend + use_backend acme_challenge if is_acme_challenge + default_backend no_match + + # HTTPS Frontend + frontend https_main + bind *:443 + default_backend no_match + + # ACME Challenge Backend + backend acme_challenge + mode http + server acme_server 127.0.0.1:8888 + + # Default backend + backend no_match + http-request return status 404 content-type text/plain string "No matching service found" + + - path: /etc/dataplaneapi/dataplaneapi.yml + content: | + dataplaneapi: + host: 0.0.0.0 + port: 5555 + user: + - insecure: true + password: admin + username: admin + + haproxy: + config_file: /etc/haproxy/haproxy.cfg + haproxy_bin: /usr/sbin/haproxy + reload: + reload_cmd: systemctl reload haproxy + restart_cmd: systemctl restart haproxy + stats_socket: /var/run/haproxy/admin.sock + + - path: /usr/local/bin/podman-haproxy-acme-sync-wrapper.sh + permissions: '0755' + content: | + #!/bin/bash + + set -e + + MAX_WAIT=60 + ELAPSED=0 + + # Wait for HAProxy + echo "Checking HAProxy status..." + while ! systemctl is-active --quiet haproxy; do + echo "Waiting for HAProxy to start..." + sleep 2 + ELAPSED=$($ELAPSED + 2) + [ $ELAPSED -ge $MAX_WAIT ] && { echo "ERROR: HAProxy timeout"; exit 1; } + done + echo "HAProxy is active" + + # Reset and wait for Data Plane API to actually respond + ELAPSED=0 + echo "Checking Data Plane API readiness..." + while true; do + HTTP_CODE=$(curl -s -w "%%{http_code}" -o /dev/null \ + --connect-timeout 5 \ + --max-time 10 \ + -u :admin \ + http://localhost:5555/v3/services/haproxy/configuration/version 2>/dev/null || echo "000") + + [ "$HTTP_CODE" = "200" ] && { echo "Data Plane API ready"; break; } + + echo "Waiting for Data Plane API... (HTTP $HTTP_CODE)" + sleep 2 + ELAPSED=$((ELAPSED + 2)) + + if [ $ELAPSED -ge $MAX_WAIT ]; then + echo "ERROR: Data Plane API not ready within $MAX_WAITs (HTTP $HTTP_CODE)" + journalctl -u dataplaneapi -n 50 --no-pager + exit 1 + fi + done + + sleep 2 + exec /usr/local/bin/podman-haproxy-acme-sync.py + + # Podman HAProxy ACME Sync Script + - path: /usr/local/bin/podman-haproxy-acme-sync.py + permissions: '0755' + content: | + #!/usr/bin/env python3 + + import json + import subprocess + import requests + import time + import os + import sys + + HAPROXY_API_BASE = "http://:admin@127.0.0.1:5555/v3" + CERT_DIR = "/home/fourlights/.acme.sh" + ACME_SCRIPT = "/usr/local/bin/acme.sh" + + class PodmanHAProxyACMESync: + def __init__(self): + self.ssl_services = set() + self.session = requests.Session() + self.session.headers.update({'Content-Type': 'application/json'}) + + def get_next_index(self, path): + response = self.session.get(f"{HAPROXY_API_BASE}/services/haproxy/configuration/{path}") + return len(response.json()) if response.status_code == 200 else None + + def get_dataplaneapi_version(self): + response = self.session.get(f"{HAPROXY_API_BASE}/services/haproxy/configuration/version") + return response.json() if response.status_code == 200 else None + + def get_container_labels(self, container_id): + try: + result = subprocess.run(['podman', 'inspect', container_id], + capture_output=True, text=True) + if result.returncode == 0: + data = json.loads(result.stdout) + return data[0]['Config']['Labels'] or {} + except Exception as e: + print(f"Error getting labels for {container_id}: {e}") + return {} + + def request_certificate(self, domain): + print(f"[CERT-REQUEST] About to request certificate for {domain}") + sys.stdout.flush() + + try: + cmd = [ + ACME_SCRIPT, + "--issue", + "-d", domain, + "--standalone", + "--httpport", "8888", + "--server", "letsencrypt", + "--listen-v4", + "--debug", "2" + ] + + # Log the command being executed + print(f"[CERT-REQUEST] Executing: {' '.join(cmd)}") + sys.stdout.flush() + + result = subprocess.run(cmd, capture_output=True, text=True) + + # Log both stdout and stderr for complete debugging + if result.stdout: + print(f"[CERT-STDOUT] {result.stdout}") + sys.stdout.flush() + if result.stderr: + print(f"[CERT-STDERR] {result.stderr}") + sys.stderr.flush() + + if result.returncode == 0: + print(f"[CERT-SUCCESS] Certificate obtained for {domain}") + sys.stdout.flush() + self.install_certificate(domain) + return True + else: + print(f"[CERT-FAILED] Failed to obtain certificate for {domain}") + print(f"[CERT-FAILED] Return code: {result.returncode}") + sys.stdout.flush() + return False + + except Exception as e: + print(f"[CERT-ERROR] Error requesting certificate: {e}") + sys.stdout.flush() + return False + + def install_certificate(self, domain): + cert_file = f"{CERT_DIR}/{domain}.pem" + + try: + acme_cert_dir = f"/home/fourlights/.acme.sh/{domain}_ecc" + + with open(cert_file, 'w') as outfile: + with open(f"{acme_cert_dir}/fullchain.cer") as cert: + outfile.write(cert.read()) + with open(f"{acme_cert_dir}/{domain}.key") as key: + outfile.write(key.read()) + try: + with open(f"{acme_cert_dir}/ca.cer") as ca: + outfile.write(ca.read()) + except FileNotFoundError: + pass + + os.chmod(cert_file, 0o600) + print(f"Certificate installed at {cert_file}") + + self.update_haproxy_ssl_bind(domain) + + except Exception as e: + print(f"Error installing certificate for {domain}: {e}") + + def update_haproxy_ssl_bind(self, domain): + print(f"Updating ssl bind for {domain}") + try: + ssl_bind_data = { + "address": "*", + "port": 443, + "ssl": True, + "ssl_certificate": f"{CERT_DIR}/{domain}.pem", + } + + response = self.session.post(f"{HAPROXY_API_BASE}/services/haproxy/configuration/frontends/https_main/binds?version={self.get_dataplaneapi_version()}", + json=ssl_bind_data) + print(response.json()) + + if response.status_code in [200, 201]: + print(f"Updated HAProxy SSL bind for {domain}") + + except Exception as e: + print(f"Error updating HAProxy SSL bind: {e}") + + def setup_certificate_renewal(self, domain): + renewal_script = f"/etc/cron.d/acme-{domain.replace('.', '-')}" + + cron_content = f"""0 0 * * * root {ACME_SCRIPT} --renew -d {domain} --post-hook "systemctl reload haproxy" >/dev/null 2>&1 + """ + + with open(renewal_script, 'w') as f: + f.write(cron_content) + + print(f"Setup automatic renewal for {domain}") + + def update_haproxy_backend(self, service_name, host, port, action='add'): + backend_name = f"backend_{service_name}" + server_name = f"{service_name}_server" + + if action == 'add': + backend_data = { + "name": backend_name, + "mode": "http", + "balance": {"algorithm": "roundrobin"}, + } + backends = self.session.post(f"{HAPROXY_API_BASE}/services/haproxy/configuration/backends?version={self.get_dataplaneapi_version()}", + json=backend_data) + print(backends.json()) + + server_data = { + "name": server_name, + "address": host, + "port": int(port), + "check": "enabled", + } + tweak = self.session.post(f"{HAPROXY_API_BASE}/services/haproxy/configuration/backends/{backend_name}/servers?version={self.get_dataplaneapi_version()}", + json=server_data) + print(tweak.json()) + + elif action == 'remove': + self.session.delete(f"{HAPROXY_API_BASE}/services/haproxy/configuration/backends/{backend_name}/servers/{server_name}?version={self.get_dataplaneapi_version()}") + + def update_haproxy_frontend_rule(self, service_name, domain, ssl_enabled=False, action='add'): + if action == 'add': + if ssl_enabled and domain and domain not in self.ssl_services: + print(f"Setting up SSL for {domain}") + if self.request_certificate(domain): + self.setup_certificate_renewal(domain) + self.ssl_services.add(domain) + + acl_data = { + "acl_name": f"is_{service_name}", + "criterion": "hdr(host)", + "value": domain, + } + self.session.post(f"{HAPROXY_API_BASE}/services/haproxy/configuration/frontends/main/acls/{self.get_next_index('frontends/main/acls')}?version={self.get_dataplaneapi_version()}", + json=acl_data) + + if ssl_enabled: + self.session.post(f"{HAPROXY_API_BASE}/services/haproxy/configuration/frontends/https_main/acls/{self.get_next_index('frontends/https_main/acls')}?version={self.get_dataplaneapi_version()}", + json=acl_data) + + rule_data = { + "name": f"backend_{service_name}", + "cond": "if", + "cond_test": f"is_{service_name}", + } + self.session.post(f"{HAPROXY_API_BASE}/services/haproxy/configuration/frontends/main/backend_switching_rules/{self.get_next_index('frontends/main/backend_switching_rules')}?version={self.get_dataplaneapi_version()}", + json=rule_data) + + if ssl_enabled: + self.session.post(f"{HAPROXY_API_BASE}/services/haproxy/configuration/frontends/https_main/backend_switching_rules/{self.get_next_index('frontends/https_main/backend_switching_rules')}?version={self.get_dataplaneapi_version()}", + json=rule_data) + + redirect_rule = { + "type": "redirect", + "redirect_rule": { + "type": "scheme", + "value": "https", + "code": 301 + }, + "cond": "if", + "cond_test": f"is_{service_name}", + } + self.session.post(f"{HAPROXY_API_BASE}/services/haproxy/configuration/frontends/main/http_request_rules/{self.get_next_index('frontends/main/http_request_rules')}?version={self.get_dataplaneapi_version()}", + json=redirect_rule) + + def process_container_event(self, event): + # DIAGNOSTIC: Log raw event structure + print(f"[EVENT-DEBUG] Received event - Type: {event.get('Type', 'MISSING')}, Action: {event.get('Action', 'MISSING')}") + sys.stdout.flush() + + # DIAGNOSTIC: Check for Actor key + if 'Actor' not in event: + print(f"[EVENT-SKIP] Skipping event without 'Actor' key - Full event: {json.dumps(event)}") + sys.stdout.flush() + return + + # DIAGNOSTIC: Check for ID in Actor + if 'ID' not in event['Actor']: + print(f"[EVENT-SKIP] Skipping event without 'Actor.ID' - Actor content: {json.dumps(event['Actor'])}") + sys.stdout.flush() + return + + container_id = event['Actor']['ID'][:12] + action = event['Action'] + + print(f"[EVENT-PROCESS] Processing '{action}' event for container {container_id}") + sys.stdout.flush() + + labels = self.get_container_labels(container_id) + + # Dictionary to store discovered services + services = {} + + # First, check for namespaced labels (haproxy.{service_name}.enable) + for label_key, label_value in labels.items(): + if label_key.startswith('haproxy.') and label_key.endswith('.enable') and label_value.lower() == 'true': + # Extract service name from label key + parts = label_key.split('.') + if len(parts) == 3: # haproxy.{service_name}.enable + service_name = parts[1] + + # Extract properties for this service namespace + service_config = { + 'service_name': service_name, + 'host': labels.get(f'haproxy.{service_name}.host', '127.0.0.1'), + 'port': labels.get(f'haproxy.{service_name}.port', '8080'), + 'domain': labels.get(f'haproxy.{service_name}.domain', None), + 'ssl_enabled': labels.get(f'haproxy.{service_name}.tls', 'false').lower() == 'true' + } + services[service_name] = service_config + + # Backward compatibility: If no namespaced labels found, check for flat labels + if not services and 'haproxy.enable' in labels and labels['haproxy.enable'].lower() == 'true': + service_name = labels.get('haproxy.service', container_id) + services[service_name] = { + 'service_name': service_name, + 'host': labels.get('haproxy.host', '127.0.0.1'), + 'port': labels.get('haproxy.port', '8080'), + 'domain': labels.get('haproxy.domain', None), + 'ssl_enabled': labels.get('haproxy.tls', 'false').lower() == 'true' + } + + # Process each discovered service + for service_name, config in services.items(): + if action in ['start', 'restart']: + print(f"Adding service {config['service_name']} to HAProxy (SSL: {config['ssl_enabled']}, Domain: {config['domain']})") + sys.stdout.flush() + self.update_haproxy_backend(config['service_name'], config['host'], config['port'], 'add') + if config['domain']: + self.update_haproxy_frontend_rule(config['service_name'], config['domain'], config['ssl_enabled'], 'add') + + elif action in ['stop', 'remove', 'died']: + print(f"Removing service {config['service_name']} from HAProxy") + sys.stdout.flush() + self.update_haproxy_backend(config['service_name'], config['host'], config['port'], 'remove') + + def watch_events(self): + print("Starting Podman-HAProxy-ACME sync...") + + # Track last sync time + last_full_sync = 0 + SYNC_INTERVAL = 60 # Re-scan all containers every 60 seconds + + def do_full_sync(): + """Perform a full sync of all running containers""" + print("Performing full container sync...") + try: + result = subprocess.run(['podman', 'ps', '--format', 'json'], + capture_output=True, text=True) + if result.returncode == 0: + containers = json.loads(result.stdout) + for container in containers: + event = { + 'Type': 'container', + 'Action': 'start', + 'Actor': {'ID': container.get('Id', '')} + } + self.process_container_event(event) + print(f"Synced {len(containers)} containers") + except Exception as e: + print(f"Error during full sync: {e}") + + # Initial sync + do_full_sync() + last_full_sync = time.time() + + print("Watching for container events...") + + cmd = ['podman', 'events', '--format', 'json'] + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, text=True) + + # Use select/poll for non-blocking read so we can do periodic syncs + import select + + while True: + # Check if it's time for periodic sync + if time.time() - last_full_sync >= SYNC_INTERVAL: + do_full_sync() + last_full_sync = time.time() + + # Check for events with timeout + ready, _, _ = select.select([process.stdout], [], [], 5) + + if ready: + line = process.stdout.readline() + if line: + try: + event = json.loads(line.strip()) + if event['Type'] == 'container': + self.process_container_event(event) + except json.JSONDecodeError as e: + print(f"[EVENT-ERROR] JSON decode error: {e} - Line: {line[:100]}") + sys.stdout.flush() + except KeyError as e: + print(f"[EVENT-ERROR] Missing key {e} in event: {json.dumps(event)}") + sys.stdout.flush() + except Exception as e: + print(f"[EVENT-ERROR] Error processing event: {e}") + print(f"[EVENT-ERROR] Event structure: {json.dumps(event)}") + sys.stdout.flush() + + if __name__ == "__main__": + os.makedirs(CERT_DIR, exist_ok=True) + sync = PodmanHAProxyACMESync() + sync.watch_events() + +runcmd: + # Create necessary directories + - mkdir -p /var/run/haproxy /etc/ssl/haproxy /etc/containers/systemd /etc/haproxy/dataplane /etc/dataplaneapi + - chown haproxy:haproxy /var/run/haproxy + + # Install Data Plane API + - cd /tmp && curl -LO https://github.com/haproxytech/dataplaneapi/releases/download/v3.2.4/dataplaneapi_3.2.4_linux_amd64.deb + - env DEBIAN_FRONTEND=noninteractive apt install -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" /tmp/dataplaneapi_3.2.4_linux_amd64.deb + - rm /tmp/dataplaneapi_3.2.4_linux_amd64.deb + + - mkdir -p /home/fourlights/.config/containers/systemd + - mkdir -p /home/fourlights/.config/systemd/user + - | + cat > /home/fourlights/.config/systemd/user/podman-haproxy-acme-sync.service << 'EOF' + [Unit] + Description=Podman HAProxy ACME Sync Service + After=network.target + + [Service] + Type=simple + Environment="XDG_RUNTIME_DIR=/run/user/1000" + Environment="DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus" + ExecStart=/usr/local/bin/podman-haproxy-acme-sync-wrapper.sh + StandardOutput=journal + StandardError=journal + Restart=always + RestartSec=10 + + [Install] + WantedBy=default.target + EOF + - chown -R fourlights:fourlights /home/fourlights + + # Install ACME.sh + - su - fourlights -c 'curl https://get.acme.sh | sh -s email=${acme_email}' + - ln -sf /home/fourlights/.acme.sh/acme.sh /usr/local/bin/acme.sh + + # Setup data directory and mount volume + - mkdir -p /opt/storage/data + - mkfs.ext4 -F /dev/sdb + - mount /dev/sdb /opt/storage/data + - echo '/dev/sdb /opt/storage/data ext4 defaults 0 2' >> /etc/fstab + - chown -R fourlights:fourlights /opt/storage/data + + # Enable Podman for user services + - loginctl enable-linger fourlights + - su - fourlights -c 'podman login ghcr.io -u ${ghcr_username} -p ${ghcr_token}' + + # Enable and start services + - systemctl daemon-reload + - systemctl enable --now haproxy + - systemctl enable --now dataplaneapi + - su - fourlights -c 'systemctl --user daemon-reload' + - su - fourlights -c 'systemctl --user enable --now podman-haproxy-acme-sync' + +final_message: "Server setup complete with HAProxy, Podman, and ACME sync configured"