diff --git a/quadlets/.gitignore b/quadlets/.gitignore
new file mode 100644
index 0000000..40d89f9
--- /dev/null
+++ b/quadlets/.gitignore
@@ -0,0 +1,40 @@
+# Local .terraform directories
+.terraform/
+
+# .tfstate files
+*.tfstate
+*.tfstate.*
+
+# Crash log files
+crash.log
+crash.*.log
+
+# Exclude all .tfvars files, which are likely to contain sensitive data, such as
+# password, private keys, and other secrets. These should not be part of version
+# control as they are data points which are potentially sensitive and subject
+# to change depending on the environment.
+*.tfvars
+*.tfvars.json
+
+# Ignore override files as they are usually used to override resources locally and so
+# are not checked in
+override.tf
+override.tf.json
+*_override.tf
+*_override.tf.json
+
+# Ignore transient lock info files created by terraform apply
+.terraform.tfstate.lock.info
+
+# Include override files you do wish to add to version control using negated pattern
+# !example_override.tf
+
+# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan
+# example: *tfplan*
+
+# Ignore CLI configuration files
+.terraformrc
+terraform.rc
+
+# Optional: ignore graph output files generated by `terraform graph`
+*.dot
diff --git a/quadlets/.idea/.gitignore b/quadlets/.idea/.gitignore
new file mode 100644
index 0000000..ab1f416
--- /dev/null
+++ b/quadlets/.idea/.gitignore
@@ -0,0 +1,10 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Ignored default folder with query files
+/queries/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
+# Editor-based HTTP Client requests
+/httpRequests/
diff --git a/quadlets/.idea/encodings.xml b/quadlets/.idea/encodings.xml
new file mode 100644
index 0000000..df87cf9
--- /dev/null
+++ b/quadlets/.idea/encodings.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/quadlets/.idea/modules.xml b/quadlets/.idea/modules.xml
new file mode 100644
index 0000000..d241f84
--- /dev/null
+++ b/quadlets/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/quadlets/.idea/quadlets.iml b/quadlets/.idea/quadlets.iml
new file mode 100644
index 0000000..c956989
--- /dev/null
+++ b/quadlets/.idea/quadlets.iml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/quadlets/.idea/vcs.xml b/quadlets/.idea/vcs.xml
new file mode 100644
index 0000000..6c0b863
--- /dev/null
+++ b/quadlets/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/quadlets/main.tf b/quadlets/main.tf
new file mode 100644
index 0000000..5de1233
--- /dev/null
+++ b/quadlets/main.tf
@@ -0,0 +1,66 @@
+variable "hcloud_token" {
+ description = "Hetzner Cloud API 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 "ghcr_username" {}
+variable "ghcr_token" {}
+
+module "hetzner" {
+ source = "./modules/hetzner"
+ hcloud_token = var.hcloud_token
+ ssh_public_key_path = var.ssh_public_key_path
+ ssh_private_key_path = var.ssh_private_key_path
+ name = "vw-hub"
+ datacenter = "nbg1-dc3"
+ hdns_token = var.hdns_token
+ ghcr_token = var.ghcr_token
+ ghcr_username = var.ghcr_username
+}
+
+module "minio" {
+ wait_on = module.hetzner.installed
+ source = "./modules/minio"
+ server_ip = module.hetzner.server_ip
+ server_domain = module.hetzner.server_domain
+ ssh_private_key_path = var.ssh_private_key_path
+}
+
+module "valkey" {
+ wait_on = module.hetzner.installed
+ source = "./modules/valkey"
+ server_ip = module.hetzner.server_ip
+ ssh_private_key_path = var.ssh_private_key_path
+}
+
+# module "vw-hub" {
+# wait_on = module.minio.installed
+#
+# source = "./modules/vw-hub"
+# server_ip = module.hetzner.server_ip
+# ssh_private_key_path = var.ssh_private_key_path
+# domain = "hub.${module.hetzner.server_domain}"
+# s3_access_key = module.minio.access_key
+# s3_secret_key = module.minio.secret_key
+# s3_server = module.minio.server
+# }
+
+output "minio_app_urls" {
+ value = module.minio.app_urls
+}
\ No newline at end of file
diff --git a/quadlets/modules/arcane/main.tf b/quadlets/modules/arcane/main.tf
new file mode 100644
index 0000000..0cf13a1
--- /dev/null
+++ b/quadlets/modules/arcane/main.tf
@@ -0,0 +1,36 @@
+variable "wait_on" {
+ type = any
+ description = "Resources to wait on"
+ default = true
+}
+
+variable "server_ip" {
+ type = string
+}
+
+variable "ssh_private_key_path" {
+ type = string
+}
+
+module "redis" {
+ source = "../quadlet-app"
+ wait_on = var.wait_on
+
+ server_ip = var.server_ip
+ ssh_private_key_path = var.ssh_private_key_path
+
+ app_name = "redis"
+ image = "docker.io/redis:7-alpine"
+ ports = ["6379:6379"]
+ volumes = ["/opt/storage/data/redis:/data:Z"]
+ command = ["redis-server", "--appendonly", "yes"]
+}
+
+output "app_urls" {
+ value = module.redis.app_urls
+}
+
+output "installed" {
+ value = true
+ depends_on = [module.redis.installed]
+}
\ No newline at end of file
diff --git a/quadlets/modules/hetzner/cloud-init.yml b/quadlets/modules/hetzner/cloud-init.yml
new file mode 100644
index 0000000..199790a
--- /dev/null
+++ b/quadlets/modules/hetzner/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"
diff --git a/quadlets/modules/hetzner/dns/main.tf b/quadlets/modules/hetzner/dns/main.tf
new file mode 100644
index 0000000..66b3cad
--- /dev/null
+++ b/quadlets/modules/hetzner/dns/main.tf
@@ -0,0 +1,53 @@
+variable "hdns_token" {}
+variable "zone" { default = "fourlights.dev" }
+variable "ipv4_address" {}
+variable "ipv6_address" {}
+
+variable "root" {}
+
+terraform {
+ required_providers {
+ hetznerdns = {
+ source = "timohirt/hetznerdns"
+ version = "2.2.0"
+ }
+ }
+}
+
+provider "hetznerdns" {
+ apitoken = var.hdns_token
+}
+
+resource "hetznerdns_zone" "zone" {
+ name = var.zone
+ ttl = 300
+}
+
+resource "hetznerdns_record" "server_root_ipv4" {
+ zone_id = hetznerdns_zone.zone.id
+ name = var.root == null || var.root == "" ? "@" : var.root
+ value = var.ipv4_address
+ type = "A"
+}
+
+resource "hetznerdns_record" "server_root_ipv6" {
+ zone_id = hetznerdns_zone.zone.id
+ name = var.root == null || var.root == "" ? "@" : var.root
+ value = var.ipv6_address
+ type = "AAAA"
+}
+
+resource "hetznerdns_record" "server_wildcard" {
+ zone_id = hetznerdns_zone.zone.id
+ name = var.root == null || var.root == "" ? "*" : "*.${var.root}"
+ value = var.root
+ type = "CNAME"
+}
+
+locals {
+ root_suffix = var.root == null || var.root == "" ? "" : "."
+}
+
+output "server_domain" {
+ value = "${var.root}${local.root_suffix}${var.zone}"
+}
\ No newline at end of file
diff --git a/quadlets/modules/hetzner/main.tf b/quadlets/modules/hetzner/main.tf
new file mode 100644
index 0000000..8180944
--- /dev/null
+++ b/quadlets/modules/hetzner/main.tf
@@ -0,0 +1,191 @@
+terraform {
+ required_providers {
+ hcloud = {
+ source = "hetznercloud/hcloud"
+ version = "~> 1.0"
+ }
+ }
+}
+
+provider "hcloud" {
+ token = var.hcloud_token
+}
+
+variable "hcloud_token" {
+ description = "Hetzner Cloud API 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 "acme_email" {
+# description = "Email for Let's Encrypt certificates"
+# type = string
+# default = "engineering@fourlights.nl"
+# }
+
+variable "image" {
+ type = string
+ default = "ubuntu-24.04"
+}
+
+variable "location" {
+ type = string
+ default = "nbg1"
+}
+
+variable "server_type" {
+ type = string
+ default = "cx22"
+}
+
+variable "datacenter" {
+ type = string
+ default = "nbg1-dc3"
+}
+
+variable "name" {
+ type = string
+ default = "enterprise"
+}
+
+variable "zone" {
+ type = string
+ default = "fourlights.dev"
+}
+
+variable "hdns_token" {}
+variable "ghcr_username" {}
+variable "ghcr_token" {}
+
+locals {
+ acme_email = "engineering+${var.name}@fourlights.nl"
+}
+
+resource "hcloud_primary_ip" "server_ipv4" {
+ name = "${var.name}-ipv4"
+ type = "ipv4"
+ assignee_type = "server"
+ datacenter = var.datacenter
+ auto_delete = false
+}
+
+resource "hcloud_primary_ip" "server_ipv6" {
+ name = "${var.name}-ipv6"
+ type = "ipv6"
+ assignee_type = "server"
+ datacenter = var.datacenter
+ auto_delete = false
+}
+
+module "dns" {
+ source = "./dns"
+
+ hdns_token = var.hdns_token
+ zone = var.zone
+ ipv4_address = hcloud_primary_ip.server_ipv4.ip_address
+ ipv6_address = hcloud_primary_ip.server_ipv6.ip_address
+ root = "visualworkplace"
+}
+
+# SSH Key
+resource "hcloud_ssh_key" "default" {
+ name = "terraform-key"
+ public_key = file(var.ssh_public_key_path)
+}
+
+# Persistent volume for MinIO
+resource "hcloud_volume" "minio_data" {
+ name = "minio-data"
+ size = 50
+ location = var.location
+}
+
+# Server with comprehensive cloud-init setup
+resource "hcloud_server" "server" {
+ name = var.name
+ image = var.image
+ server_type = var.server_type
+ location = var.location
+ ssh_keys = [hcloud_ssh_key.default.id]
+
+ user_data = templatefile("${path.module}/cloud-init.yml", {
+ acme_email = local.acme_email
+ ssh_public_key = hcloud_ssh_key.default.public_key,
+ ghcr_username = var.ghcr_username
+ ghcr_token = var.ghcr_token
+ })
+
+ public_net {
+ ipv4_enabled = true
+ ipv6_enabled = true
+
+ ipv4 = hcloud_primary_ip.server_ipv4.id
+ ipv6 = hcloud_primary_ip.server_ipv6.id
+ }
+
+ lifecycle {
+ replace_triggered_by = [
+ # This ensures server gets rebuilt when user_data changes
+ ]
+ }
+}
+
+# Attach volume
+resource "hcloud_volume_attachment" "minio_data" {
+ volume_id = hcloud_volume.minio_data.id
+ server_id = hcloud_server.server.id
+ automount = false # We'll handle mounting in cloud-init
+}
+
+# Wait for cloud-init to complete
+resource "null_resource" "wait_for_cloud_init" {
+ depends_on = [hcloud_server.server]
+
+ connection {
+ type = "ssh"
+ host = hcloud_server.server.ipv4_address
+ user = "fourlights"
+ timeout = "10m"
+ agent = true
+ agent_identity = var.ssh_private_key_path
+ }
+
+ provisioner "remote-exec" {
+ inline = [
+ "echo 'Waiting for cloud-init to complete...'",
+ "cloud-init status --wait",
+ "echo 'Cloud-init completed successfully'"
+ ]
+ }
+}
+
+output "server_ip" {
+ value = hcloud_server.server.ipv4_address
+}
+
+output "haproxy_stats" {
+ value = "http://${hcloud_server.server.ipv4_address}:8404/stats"
+}
+
+output "haproxy_api" {
+ value = "http://${hcloud_server.server.ipv4_address}:5555"
+}
+
+output "server_domain" {
+ value = module.dns.server_domain
+}
+
+output "installed" {
+ value = true
+ depends_on = [null_resource.wait_for_cloud_init]
+}
diff --git a/quadlets/modules/minio/main.tf b/quadlets/modules/minio/main.tf
new file mode 100644
index 0000000..69002e3
--- /dev/null
+++ b/quadlets/modules/minio/main.tf
@@ -0,0 +1,92 @@
+variable "wait_on" {
+ type = any
+ description = "Resources to wait on"
+ default = true
+}
+
+variable "server_ip" {
+ type = string
+}
+
+variable "ssh_private_key_path" {
+ type = string
+}
+
+variable "server_domain" {
+ type = string
+}
+
+resource "random_password" "minio_access_key" {
+ length = 20
+ special = false
+}
+
+resource "random_password" "minio_secret_key" {
+ length = 40
+ special = false
+}
+
+module "minio" {
+ wait_on = var.wait_on
+ source = "../quadlet-app"
+
+ server_ip = var.server_ip
+ ssh_private_key_path = var.ssh_private_key_path
+
+ app_name = "minio"
+ image = "docker.io/minio/minio:latest"
+ ports = [
+ "9000:9000", # API port
+ "9001:9001" # Console port
+ ]
+ volumes = ["/opt/storage/data/minio:/data:Z"]
+
+ environment = {
+ MINIO_ROOT_USER = random_password.minio_access_key.result
+ MINIO_ROOT_PASSWORD = random_password.minio_secret_key.result
+ MINIO_CONSOLE_ADDRESS = ":9001"
+ MINIO_BROWSER_REDIRECT_URL = "http://storage.${var.server_domain}"
+ }
+
+ command = ["server", "/data", "--console-address", ":9001"]
+ healthcmd = "curl -f http://localhost:9001/minio/health/live || exit 1"
+
+ # Configure multiple HAProxy services for MinIO
+ haproxy_services = [
+ {
+ name = "minio_api"
+ domain = "storage-api.${var.server_domain}"
+ port = "9000"
+ host = "127.0.0.1"
+ tls = false
+ },
+ {
+ name = "minio_console"
+ domain = "storage.${var.server_domain}"
+ port = "9001"
+ host = "127.0.0.1"
+ tls = false
+ }
+ ]
+}
+
+output "app_urls" {
+ value = module.minio.app_urls
+}
+
+output "server" {
+ value = "storage-api.${var.server_domain}"
+}
+
+output "access_key" {
+ value = random_password.minio_access_key.result
+}
+
+output "secret_key" {
+ value = random_password.minio_secret_key.result
+}
+
+output "installed" {
+ value = true
+ depends_on = [module.minio.installed]
+}
\ No newline at end of file
diff --git a/quadlets/modules/minio/tenant/main.tf b/quadlets/modules/minio/tenant/main.tf
new file mode 100644
index 0000000..26fa592
--- /dev/null
+++ b/quadlets/modules/minio/tenant/main.tf
@@ -0,0 +1,221 @@
+resource "null_resource" "health_check" {
+ depends_on = [var.wait_on]
+
+ provisioner "local-exec" {
+ command = <<-EOT
+ until curl -s -f "${var.tls ? "https" : "http" }://${var.server}/minio/health/live" || [[ $attempts -ge 60 ]]; do
+ sleep 10
+ attempts=$((attempts+1))
+ done
+ if [[ $attempts -ge 60 ]]; then
+ echo "Minio health check failed after maximum attempts"
+ exit 1
+ fi
+ EOT
+ }
+}
+
+resource "minio_s3_bucket" "overlay" {
+ depends_on = [var.wait_on]
+ bucket = var.name
+ acl = "private"
+}
+
+resource "minio_s3_bucket_policy" "overlay" {
+ depends_on = [minio_s3_bucket.overlay]
+ bucket = minio_s3_bucket.overlay.bucket
+ policy = jsonencode({
+ "Version" : "2012-10-17",
+ "Statement" : [
+ {
+ "Effect" : "Allow",
+ "Principal" : {
+ "AWS" : [
+ "*"
+ ]
+ },
+ "Action" : [
+ "s3:GetBucketLocation"
+ ],
+ "Resource" : [
+ minio_s3_bucket.overlay.arn,
+ ]
+ },
+ {
+ "Effect" : "Allow",
+ "Principal" : {
+ "AWS" : [
+ "*"
+ ]
+ },
+ "Action" : [
+ "s3:ListBucket"
+ ],
+ "Resource" : [
+ minio_s3_bucket.overlay.arn,
+ ],
+ "Condition" : {
+ "StringEquals" : {
+ "s3:prefix" : [
+ "*"
+ ]
+ }
+ }
+ },
+ {
+ "Effect" : "Allow",
+ "Principal" : {
+ "AWS" : [
+ "*"
+ ]
+ },
+ "Action" : [
+ "s3:GetObject"
+ ],
+ "Resource" : [
+ "${minio_s3_bucket.overlay.arn}/**",
+ ]
+ }
+ ]
+ })
+}
+
+resource "minio_s3_bucket" "uploads" {
+ depends_on = [null_resource.health_check]
+ bucket = "uploads"
+ acl = "private"
+}
+
+resource "minio_s3_bucket_policy" "uploads" {
+ depends_on = [minio_s3_bucket.uploads]
+ bucket = minio_s3_bucket.uploads.bucket
+ policy = jsonencode({
+ "Version" : "2012-10-17",
+ "Statement" : [
+ {
+ "Effect" : "Allow",
+ "Principal" : {
+ "AWS" : [
+ "*"
+ ]
+ },
+ "Action" : [
+ "s3:GetBucketLocation"
+ ],
+ "Resource" : [
+ minio_s3_bucket.uploads.arn,
+ ]
+ },
+ {
+ "Effect" : "Allow",
+ "Principal" : {
+ "AWS" : [
+ "*"
+ ]
+ },
+ "Action" : [
+ "s3:ListBucket"
+ ],
+ "Resource" : [
+ minio_s3_bucket.uploads.arn,
+ ],
+ "Condition" : {
+ "StringEquals" : {
+ "s3:prefix" : [
+ "*"
+ ]
+ }
+ }
+ },
+ {
+ "Effect" : "Allow",
+ "Principal" : {
+ "AWS" : [
+ "*"
+ ]
+ },
+ "Action" : [
+ "s3:GetObject"
+ ],
+ "Resource" : [
+ "${minio_s3_bucket.uploads.arn}/**",
+ ]
+ }
+ ]
+ })
+}
+
+resource "minio_iam_user" "overlay" {
+ depends_on = [null_resource.health_check]
+ name = var.name
+}
+
+resource "minio_iam_policy" "overlay" {
+ depends_on = [minio_s3_bucket.overlay, minio_s3_bucket.uploads]
+ name = minio_s3_bucket.overlay.bucket
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [
+ {
+ Effect = "Allow"
+ Action = ["s3:ListBucket"]
+ Resource = [minio_s3_bucket.overlay.arn, minio_s3_bucket.uploads.arn, ]
+ },
+ {
+ Effect = "Allow"
+ Action = [
+ "s3:GetObject",
+ "s3:PutObject",
+ "s3:DeleteObject"
+ ]
+ Resource = ["${minio_s3_bucket.overlay.arn}/*", "${minio_s3_bucket.uploads.arn}/*"]
+ }
+ ]
+ })
+}
+
+
+resource "minio_iam_user_policy_attachment" "overlay" {
+ depends_on = [minio_iam_user.overlay, minio_iam_policy.overlay]
+
+ user_name = minio_iam_user.overlay.id
+ policy_name = minio_iam_policy.overlay.id
+}
+
+resource "minio_iam_service_account" "overlay" {
+ depends_on = [minio_iam_user.overlay, minio_s3_bucket.overlay, minio_s3_bucket.uploads]
+ target_user = minio_iam_user.overlay.name
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [
+ {
+ Effect = "Allow"
+ Action = ["s3:ListBucket"]
+ Resource = [minio_s3_bucket.overlay.arn, minio_s3_bucket.uploads.arn]
+ },
+ {
+ Effect = "Allow"
+ Action = [
+ "s3:GetObject",
+ "s3:PutObject",
+ "s3:DeleteObject"
+ ]
+ Resource = ["${minio_s3_bucket.overlay.arn}/*", "${minio_s3_bucket.uploads.arn}/*"]
+ }
+ ]
+ })
+}
+
+output "bucket" {
+ value = var.name
+}
+
+output "access_key" {
+ value = minio_iam_service_account.overlay.access_key
+ sensitive = true
+}
+
+output "secret_key" {
+ value = minio_iam_service_account.overlay.secret_key
+ sensitive = true
+}
diff --git a/quadlets/modules/minio/tenant/providers.tf b/quadlets/modules/minio/tenant/providers.tf
new file mode 100644
index 0000000..e1ac5bc
--- /dev/null
+++ b/quadlets/modules/minio/tenant/providers.tf
@@ -0,0 +1,16 @@
+terraform {
+ required_providers {
+ minio = {
+ source = "aminueza/minio"
+ version = "~> 3.3.0"
+ }
+ }
+}
+
+provider "minio" {
+ minio_server = var.server
+ minio_region = var.region
+ minio_user = var.access_key
+ minio_password = var.secret_key
+ minio_ssl = var.tls
+}
diff --git a/quadlets/modules/minio/tenant/variables.tf b/quadlets/modules/minio/tenant/variables.tf
new file mode 100644
index 0000000..4ce1cbd
--- /dev/null
+++ b/quadlets/modules/minio/tenant/variables.tf
@@ -0,0 +1,33 @@
+variable "name" {
+ type = string
+}
+
+variable "server" {
+ type = string
+}
+
+variable "access_key" {
+ type = string
+ sensitive = true
+}
+
+variable "secret_key" {
+ type = string
+ sensitive = true
+}
+
+variable "region" {
+ type = string
+ default = "eu-central-1"
+}
+
+variable "wait_on" {
+ type = any
+ description = "Resources to wait on"
+ default = true
+}
+
+variable "tls" {
+ type = bool
+ default = false
+}
\ No newline at end of file
diff --git a/quadlets/modules/oci-proxy/main.tf b/quadlets/modules/oci-proxy/main.tf
new file mode 100644
index 0000000..0cf13a1
--- /dev/null
+++ b/quadlets/modules/oci-proxy/main.tf
@@ -0,0 +1,36 @@
+variable "wait_on" {
+ type = any
+ description = "Resources to wait on"
+ default = true
+}
+
+variable "server_ip" {
+ type = string
+}
+
+variable "ssh_private_key_path" {
+ type = string
+}
+
+module "redis" {
+ source = "../quadlet-app"
+ wait_on = var.wait_on
+
+ server_ip = var.server_ip
+ ssh_private_key_path = var.ssh_private_key_path
+
+ app_name = "redis"
+ image = "docker.io/redis:7-alpine"
+ ports = ["6379:6379"]
+ volumes = ["/opt/storage/data/redis:/data:Z"]
+ command = ["redis-server", "--appendonly", "yes"]
+}
+
+output "app_urls" {
+ value = module.redis.app_urls
+}
+
+output "installed" {
+ value = true
+ depends_on = [module.redis.installed]
+}
\ No newline at end of file
diff --git a/quadlets/modules/quadlet-app/main.tf b/quadlets/modules/quadlet-app/main.tf
new file mode 100644
index 0000000..b4b2c51
--- /dev/null
+++ b/quadlets/modules/quadlet-app/main.tf
@@ -0,0 +1,220 @@
+variable "wait_on" {
+ type = any
+ description = "Resources to wait on"
+ default = true
+}
+
+variable "server_ip" {
+ description = "Target server IP"
+ type = string
+}
+
+variable "ssh_private_key_path" {
+ description = "Path to SSH private key"
+ type = string
+ default = "~/.ssh/id_rsa"
+}
+
+variable "app_name" {
+ description = "Name of the application"
+ type = string
+}
+
+variable "image" {
+ description = "Container image"
+ type = string
+}
+
+variable "ports" {
+ description = "List of port mappings (e.g., ['8080:80', '8443:443'])"
+ type = list(string)
+ default = []
+}
+
+variable "volumes" {
+ description = "List of volume mounts (e.g., ['/host/path:/container/path:Z'])"
+ type = list(string)
+ default = []
+}
+
+variable "environment" {
+ description = "Environment variables as key-value pairs"
+ type = map(string)
+ default = {}
+}
+
+variable "command" {
+ description = "Command to run in container (list of strings)"
+ type = list(string)
+ default = []
+}
+
+variable "haproxy_services" {
+ description = "Multiple HAProxy service configurations"
+ type = list(object({
+ name = string
+ domain = string
+ port = string
+ host = optional(string, "127.0.0.1")
+ tls = optional(bool, false)
+ }))
+ default = []
+}
+
+variable "depends_on_services" {
+ description = "List of systemd services this app depends on"
+ type = list(string)
+ default = []
+}
+
+variable "restart_policy" {
+ description = "Systemd restart policy"
+ type = string
+ default = "always"
+}
+
+variable "healthcmd" {
+ default = ""
+}
+
+locals {
+ # Build all HAProxy labels for multiple services
+ haproxy_labels = flatten([
+ for svc in var.haproxy_services : [
+ "Label=haproxy.${svc.name}.enable=true",
+ "Label=haproxy.${svc.name}.domain=${svc.domain}",
+ "Label=haproxy.${svc.name}.port=${svc.port}",
+ "Label=haproxy.${svc.name}.host=${svc.host}",
+ "Label=haproxy.${svc.name}.tls=${svc.tls}"
+ ]
+ ])
+}
+
+resource "null_resource" "deploy_quadlet_app" {
+ depends_on = [var.wait_on]
+ triggers = {
+ app_name = var.app_name
+ image = var.image
+ server_ip = var.server_ip
+ ports = jsonencode(var.ports)
+ volumes = jsonencode(var.volumes)
+ environment = jsonencode(var.environment)
+ command = jsonencode(var.command)
+ haproxy_services = jsonencode(var.haproxy_services)
+ depends_on_services = jsonencode(var.depends_on_services)
+ ssh_private_key_path = var.ssh_private_key_path
+ restart_policy = var.restart_policy
+ }
+
+ provisioner "remote-exec" {
+ inline = compact(flatten([
+ [
+ # Wait for cloud-init to complete before proceeding
+ "cloud-init status --wait || true",
+
+ # Verify the user systemd session is ready and linger is enabled
+ "timeout 60 bash -c 'until loginctl show-user fourlights | grep -q \"Linger=yes\"; do sleep 2; done'",
+
+ # Create base quadlet file
+ "cat > /tmp/${var.app_name}.container << 'EOF'",
+ "[Unit]",
+ "Description=${var.app_name} Service",
+ "After=network-online.target",
+ "",
+ "[Container]",
+ "Image=${var.image}",
+ ],
+
+ # Add ports (only if not empty)
+ length(var.ports) > 0 ? formatlist("PublishPort=127.0.0.1:%s", var.ports) : [],
+
+ # Add volumes (only if not empty)
+ length(var.volumes) > 0 ? formatlist("Volume=%s", var.volumes) : [],
+
+ # Add environment variables (only if not empty)
+ length(var.environment) > 0 ? formatlist("Environment=%s=%s", keys(var.environment), values(var.environment)) : [],
+
+ # Add command (only if not empty)
+ length(var.command) > 0 ? ["Exec=${join(" ", var.command)}"] : [],
+
+ # Add pre-computed HAProxy labels (only if not empty)
+ length(local.haproxy_labels) > 0 ? local.haproxy_labels : [],
+ # Add health checks if not empty
+ var.healthcmd != "" ? ["HealthCmd=${var.healthcmd}"] : [],
+
+ [
+ "",
+ "[Service]",
+ "Restart=${var.restart_policy}",
+ "",
+ "[Install]",
+ "WantedBy=default.target",
+ "EOF",
+
+ # Create volume directory
+ "mkdir -p /opt/storage/data/${var.app_name}",
+
+ # Move and activate
+ # Create directory more robustly
+ "test -d ~/.config/containers/systemd || mkdir -p ~/.config/containers/systemd",
+ "cp /tmp/${var.app_name}.container ~/.config/containers/systemd/${var.app_name}.container",
+ "systemctl --user daemon-reload",
+ "timeout 60 bash -c 'until systemctl --user list-unit-files | grep -q \"^${var.app_name}.service\"; do sleep 2; systemctl --user daemon-reload; done'",
+
+ "systemctl --user start ${var.app_name}",
+ "systemctl --user status ${var.app_name} --no-pager",
+ ]
+ ]))
+
+
+ connection {
+ type = "ssh"
+ host = var.server_ip
+ user = "fourlights"
+ agent = true
+ agent_identity = var.ssh_private_key_path
+ }
+ }
+
+ provisioner "remote-exec" {
+ when = destroy
+ inline = [
+ # Stop and remove the service
+ "systemctl --user stop ${self.triggers.app_name} || true",
+
+ # Remove the .container file
+ "rm -f ~/.config/containers/systemd/${self.triggers.app_name}.container",
+
+ # Reload systemd to remove the generated service
+ "systemctl --user daemon-reload",
+
+ # Force remove any lingering containers
+ "podman rm -f ${self.triggers.app_name} || true"
+ ]
+ connection {
+ type = "ssh"
+ host = self.triggers.server_ip
+ user = "fourlights"
+ agent = true
+ agent_identity = self.triggers.ssh_private_key_path
+ }
+
+ }
+}
+
+output "app_name" {
+ value = var.app_name
+}
+
+output "service_status" {
+ value = "${var.app_name} deployed"
+}
+
+output "app_urls" {
+ value = [for svc in var.haproxy_services : format("%s://%s", (svc.tls == true ? "https" : "http"), svc.domain)]
+}
+
+output "installed" {
+ value = true
+ depends_on = [null_resource.deploy_quadlet_app]
+}
\ No newline at end of file
diff --git a/quadlets/modules/quadrant/main.tf b/quadlets/modules/quadrant/main.tf
new file mode 100644
index 0000000..0cf13a1
--- /dev/null
+++ b/quadlets/modules/quadrant/main.tf
@@ -0,0 +1,36 @@
+variable "wait_on" {
+ type = any
+ description = "Resources to wait on"
+ default = true
+}
+
+variable "server_ip" {
+ type = string
+}
+
+variable "ssh_private_key_path" {
+ type = string
+}
+
+module "redis" {
+ source = "../quadlet-app"
+ wait_on = var.wait_on
+
+ server_ip = var.server_ip
+ ssh_private_key_path = var.ssh_private_key_path
+
+ app_name = "redis"
+ image = "docker.io/redis:7-alpine"
+ ports = ["6379:6379"]
+ volumes = ["/opt/storage/data/redis:/data:Z"]
+ command = ["redis-server", "--appendonly", "yes"]
+}
+
+output "app_urls" {
+ value = module.redis.app_urls
+}
+
+output "installed" {
+ value = true
+ depends_on = [module.redis.installed]
+}
\ No newline at end of file
diff --git a/quadlets/modules/redis/main.tf b/quadlets/modules/redis/main.tf
new file mode 100644
index 0000000..0cf13a1
--- /dev/null
+++ b/quadlets/modules/redis/main.tf
@@ -0,0 +1,36 @@
+variable "wait_on" {
+ type = any
+ description = "Resources to wait on"
+ default = true
+}
+
+variable "server_ip" {
+ type = string
+}
+
+variable "ssh_private_key_path" {
+ type = string
+}
+
+module "redis" {
+ source = "../quadlet-app"
+ wait_on = var.wait_on
+
+ server_ip = var.server_ip
+ ssh_private_key_path = var.ssh_private_key_path
+
+ app_name = "redis"
+ image = "docker.io/redis:7-alpine"
+ ports = ["6379:6379"]
+ volumes = ["/opt/storage/data/redis:/data:Z"]
+ command = ["redis-server", "--appendonly", "yes"]
+}
+
+output "app_urls" {
+ value = module.redis.app_urls
+}
+
+output "installed" {
+ value = true
+ depends_on = [module.redis.installed]
+}
\ No newline at end of file
diff --git a/quadlets/modules/valkey/main.tf b/quadlets/modules/valkey/main.tf
new file mode 100644
index 0000000..25e3542
--- /dev/null
+++ b/quadlets/modules/valkey/main.tf
@@ -0,0 +1,36 @@
+variable "wait_on" {
+ type = any
+ description = "Resources to wait on"
+ default = true
+}
+
+variable "server_ip" {
+ type = string
+}
+
+variable "ssh_private_key_path" {
+ type = string
+}
+
+module "valkey" {
+ source = "../quadlet-app"
+ wait_on = var.wait_on
+
+ server_ip = var.server_ip
+ ssh_private_key_path = var.ssh_private_key_path
+
+ app_name = "valkey"
+ image = "docker.io/valkey/valkey:7-alpine"
+ ports = ["6379:6379"]
+ volumes = ["/opt/storage/data/valkey:/data:Z"]
+ command = ["valkey-server", "--appendonly", "yes"]
+}
+
+output "app_urls" {
+ value = module.valkey.app_urls
+}
+
+output "installed" {
+ value = true
+ depends_on = [module.valkey.installed]
+}
diff --git a/quadlets/modules/vw-hub/main.tf b/quadlets/modules/vw-hub/main.tf
new file mode 100644
index 0000000..9bea8c4
--- /dev/null
+++ b/quadlets/modules/vw-hub/main.tf
@@ -0,0 +1,120 @@
+variable "wait_on" {
+ type = any
+ description = "Resources to wait on"
+ default = true
+}
+
+variable "server_ip" {
+ type = string
+}
+
+variable "ssh_private_key_path" {
+ type = string
+}
+
+variable "domain" {
+ type = string
+ default = "hub.visualworkplace.fourlights.dev"
+}
+variable "name" {
+ type = string
+ default = "visualworkplace-hub"
+}
+
+variable "s3_access_key" {
+ type = string
+}
+
+variable "s3_secret_key" {
+ type = string
+}
+
+variable "s3_server" {
+ type = string
+}
+
+variable "valkey_host" {
+ type = string
+ default = "systemd-valkey"
+}
+
+variable "valkey_db" {
+ type = number
+ default = 0
+}
+
+module "s3-tenant" {
+ source = "../minio/tenant"
+ wait_on = var.wait_on
+
+ access_key = var.s3_access_key
+ secret_key = var.s3_secret_key
+ server = var.s3_server
+ name = var.name
+}
+
+module "vw-hub" {
+ source = "../quadlet-app"
+ wait_on = module.s3-tenant.secret_key
+
+ server_ip = var.server_ip
+ ssh_private_key_path = var.ssh_private_key_path
+
+ app_name = var.name
+ image = "ghcr.io/four-lights-nl/vw-hub:8edae556b9c64fb602b8a54e67c3d06656c4bb9e"
+ volumes = ["/opt/storage/data/vw-hub:/run/secrets:Z"]
+ ports = [
+ "3000:3000",
+ ]
+
+ environment = {
+ NODE_ENV = "production"
+ LOG_LEVEL = "info"
+ OTEL_LOG_LEVEL = "info"
+ HOST = "0.0.0.0"
+ PORT = "3000"
+ OAUTH_CLIENT_ID = var.name
+ OAUTH_CLIENT_SECRET = "OGZ0IDpkWOJXaFQOr6mbIF7.l0rZLvxQDZPEGv6qHLLH/stP5vAIqHLZ2x05uQn9TFQHtsPkRysGM.RpKlWra0"
+ OAUTH_DOMAIN = "https://${var.domain}"
+ BASE_URL = "https://${var.domain}"
+ REDIS_HOST = var.valkey_host
+ REDIS_DB = var.valkey_db
+ KEYS_MASTER_KEY = "54dd59c1f1c94795a2b63b074a3943674e964b0225e58b7595762d237d9fdcda"
+ TOKEN_ENCRYPTION_KEY = "4d15791e50874fbe8af1a8d0fe2605d65bcf44737b7c36d9b2f99ec3367276c5"
+ ZOHO_CLIENT_ID = "1000.LFYZSCTUJLMUNUUBZX5PMYUXM6HOMP"
+ ZOHO_CLIENT_SECRET = "07093529734781706356ec4bb8ce7274f1df25cb2e"
+ ZOHO_REFRESH_TOKEN = "1000.0808eabe967955a24d403eabec6c0aa5.44fbbd0c6e98c476c6bb7bee70317f82"
+ ZOHO_ACCESS_TOKEN = ""
+ ZOHO_TOKEN_URI = "https://accounts.zoho.eu/oauth/v2/token"
+ ZOHO_API_URI = "https://www.zohoapis.eu/crm/v6"
+ EXACTONLINE_CLIENT_ID = "5c6b0dc4-2e78-4116-89c2-79e6e73356d8"
+ EXACTONLINE_CLIENT_SECRET = "XMSrmWMZkABv"
+ EXACTONLINE_WEBHOOK_SECRET = "8vXq0eEHEhEc6iwn"
+ EXACTONLINE_REDIRECT_URI = "https://${var.domain}/exactonline/callback"
+ EXACTONLINE_BASE_URL = "https://start.exactonline.nl"
+ EXACTONLINE_API_BASE = "https://start.exactonline.nl/api/v1/2655637"
+ EXACTONLINE_AUTHORIZE_PATH = "api/oauth2/auth"
+ EXACTONLINE_TOKEN_PATH = "api/oauth2/token"
+ EXACTONLINE_BASE_URI = "https://start.exactonline.nl"
+ EXACTONLINE_DIVISION = "2655637"
+ EXACTONLINE_LEAD_SOURCE_ID = "945be231-9588-413e-a6cd-53c190669ea7"
+ S3_ENDPOINT = var.s3_server
+ S3_ACCESS_KEY = module.s3-tenant.access_key
+ S3_SECRET_KEY = module.s3-tenant.secret_key
+ S3_BUCKET = module.s3-tenant.bucket
+ }
+
+ haproxy_services = [
+ {
+ name = var.name
+ domain = var.domain
+ port = "3000"
+ host = "127.0.0.1"
+ tls = true
+ }
+ ]
+}
+
+output "app_urls" {
+ value = module.vw-hub.app_urls
+}