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 +}