From 7751eddd730392b12d7b617ea878470adfb04584 Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Mon, 23 Mar 2026 07:35:03 -0500 Subject: [PATCH] =?UTF-8?q?Initial=20commit=20=E2=80=94=20migrate=20legion?= =?UTF-8?q?=20Terraform=20from=20infrastructure=20repo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 14 ++ README.md | 16 ++ legion/.gitignore | 7 + legion/adguard.tf | 271 ++++++++++++++++++++++ legion/backup.tf | 137 ++++++++++++ legion/cert-manager.tf | 63 ++++++ legion/gitea.tf | 361 ++++++++++++++++++++++++++++++ legion/github-runner.tf | 94 ++++++++ legion/inactive/kong.tf.disabled | 81 +++++++ legion/inactive/webui.tf.disabled | 124 ++++++++++ legion/ingress.tf | 180 +++++++++++++++ legion/main.tf | 41 ++++ legion/monitoring.tf | 288 ++++++++++++++++++++++++ legion/namespaces.tf | 19 ++ legion/neuron.tf | 343 ++++++++++++++++++++++++++++ legion/ollama.tf | 118 ++++++++++ legion/packages.tf | 230 +++++++++++++++++++ legion/postgres.tf | 34 +++ legion/redis.tf | 34 +++ legion/registry.tf | 183 +++++++++++++++ legion/traefik.tf | 25 +++ legion/variables.tf | 131 +++++++++++ legion/vault.tf | 109 +++++++++ 23 files changed, 2903 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 legion/.gitignore create mode 100644 legion/adguard.tf create mode 100644 legion/backup.tf create mode 100644 legion/cert-manager.tf create mode 100644 legion/gitea.tf create mode 100644 legion/github-runner.tf create mode 100644 legion/inactive/kong.tf.disabled create mode 100644 legion/inactive/webui.tf.disabled create mode 100644 legion/ingress.tf create mode 100644 legion/main.tf create mode 100644 legion/monitoring.tf create mode 100644 legion/namespaces.tf create mode 100644 legion/neuron.tf create mode 100644 legion/ollama.tf create mode 100644 legion/packages.tf create mode 100644 legion/postgres.tf create mode 100644 legion/redis.tf create mode 100644 legion/registry.tf create mode 100644 legion/traefik.tf create mode 100644 legion/variables.tf create mode 100644 legion/vault.tf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4df9c8e --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Terraform +**/.terraform/ +**/*.tfstate +**/*.tfstate.backup +**/tfplan +**/.terraform.lock.hcl + +# Secrets +**/*.json +**/*.env +**/.envrc + +# OS +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..2b0f4b2 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# servers + +Infrastructure-as-code for all servers. Currently: Legion (Ubuntu 24.04, k3s). + +## Structure + +- `legion/` — Legion k3s cluster (Terraform) + +## Usage + +```bash +cd legion +direnv allow # loads credentials from ~/Secrets/credentials/infrastructure.env +terraform plan +terraform apply +``` diff --git a/legion/.gitignore b/legion/.gitignore new file mode 100644 index 0000000..7d69f78 --- /dev/null +++ b/legion/.gitignore @@ -0,0 +1,7 @@ +.terraform/ +*.tfstate +*.tfstate.backup +.terraform.lock.hcl +terraform.tfvars +.envrc +vault-gcp-sa.json diff --git a/legion/adguard.tf b/legion/adguard.tf new file mode 100644 index 0000000..efaa813 --- /dev/null +++ b/legion/adguard.tf @@ -0,0 +1,271 @@ +# AdGuard Home — household DNS with ad blocking and local nook.family resolution + +resource "kubernetes_namespace" "dns" { + metadata { + name = "dns" + labels = { + managed-by = "terraform" + tier = "infrastructure" + } + } +} + +resource "kubernetes_persistent_volume_claim" "adguard_config" { + wait_until_bound = false + metadata { + name = "adguard-config" + namespace = kubernetes_namespace.dns.metadata[0].name + } + spec { + access_modes = ["ReadWriteOnce"] + resources { + requests = { + storage = "1Gi" + } + } + } +} + +resource "kubernetes_persistent_volume_claim" "adguard_data" { + wait_until_bound = false + metadata { + name = "adguard-data" + namespace = kubernetes_namespace.dns.metadata[0].name + } + spec { + access_modes = ["ReadWriteOnce"] + resources { + requests = { + storage = "5Gi" + } + } + } +} + +resource "kubernetes_deployment" "adguard" { + metadata { + name = "adguard" + namespace = kubernetes_namespace.dns.metadata[0].name + labels = { + app = "adguard" + } + } + + spec { + replicas = 1 + + strategy { + type = "Recreate" + } + + selector { + match_labels = { + app = "adguard" + } + } + + template { + metadata { + labels = { + app = "adguard" + } + } + + spec { + # hostNetwork gives AdGuard direct access to port 53 on the host IP + host_network = true + dns_policy = "ClusterFirstWithHostNet" + + # Ensure bind_hosts is always 0.0.0.0 regardless of what was written to the PVC + init_container { + name = "fix-bind-hosts" + image = "python:3.12-alpine" + command = ["python3", "-c"] + args = [<<-EOT + import re, os + config = "/opt/adguardhome/conf/AdGuardHome.yaml" + if os.path.exists(config): + with open(config) as f: + content = f.read() + # Replace any bind_hosts list with 0.0.0.0 + content = re.sub( + r'(bind_hosts:\n)((?:[ \t]*- .*\n)*)', + 'bind_hosts:\n - 0.0.0.0\n', + content + ) + with open(config, "w") as f: + f.write(content) + print("bind_hosts set to 0.0.0.0") + else: + print("No config yet — AdGuard will create it on first run") + EOT + ] + volume_mount { + name = "config" + mount_path = "/opt/adguardhome/conf" + } + } + + container { + name = "adguard" + image = "adguard/adguardhome:latest" + + port { + name = "dns-tcp" + container_port = 53 + protocol = "TCP" + } + port { + name = "dns-udp" + container_port = 53 + protocol = "UDP" + } + port { + name = "http" + container_port = 3000 + protocol = "TCP" + } + + volume_mount { + name = "config" + mount_path = "/opt/adguardhome/conf" + } + + volume_mount { + name = "data" + mount_path = "/opt/adguardhome/work" + } + + resources { + requests = { + memory = "128Mi" + cpu = "100m" + } + limits = { + memory = "512Mi" + cpu = "500m" + } + } + } + + volume { + name = "config" + persistent_volume_claim { + claim_name = kubernetes_persistent_volume_claim.adguard_config.metadata[0].name + } + } + + volume { + name = "data" + persistent_volume_claim { + claim_name = kubernetes_persistent_volume_claim.adguard_data.metadata[0].name + } + } + } + } + } +} + +# UI accessible via Traefik ingress (port 3000 → ingress) +resource "kubernetes_service" "adguard_ui" { + metadata { + name = "adguard-ui" + namespace = kubernetes_namespace.dns.metadata[0].name + } + + spec { + selector = { + app = "adguard" + } + + port { + name = "http" + port = 3000 + target_port = 3000 + } + + type = "ClusterIP" + } +} + +resource "kubernetes_ingress_v1" "adguard" { + metadata { + name = "adguard" + namespace = kubernetes_namespace.dns.metadata[0].name + annotations = { + "traefik.ingress.kubernetes.io/router.entrypoints" = "websecure" + "cert-manager.io/cluster-issuer" = "letsencrypt-prod" + } + } + + spec { + tls { + hosts = ["dns.${var.domain_suffix}"] + secret_name = "adguard-tls" + } + + rule { + host = "dns.${var.domain_suffix}" + http { + path { + path = "/" + path_type = "Prefix" + backend { + service { + name = kubernetes_service.adguard_ui.metadata[0].name + port { + number = 3000 + } + } + } + } + } + } + } + + depends_on = [helm_release.cert_manager] +} + +# Add new LAN IP to k3s TLS SANs so the API cert is valid after network change +resource "null_resource" "k3s_tls_san" { + triggers = { + legion_ip = var.legion_ip + } + + connection { + type = "ssh" + host = var.legion_ip + user = "will" + private_key = file("~/.ssh/id_ed25519") + } + + provisioner "remote-exec" { + inline = [ + "sudo mkdir -p /etc/rancher/k3s", + "echo 'tls-san:' | sudo tee /etc/rancher/k3s/config.yaml", + "echo ' - ${var.legion_ip}' | sudo tee -a /etc/rancher/k3s/config.yaml", + "echo ' - 127.0.0.1' | sudo tee -a /etc/rancher/k3s/config.yaml", + "sudo k3s certificate rotate", + "sudo systemctl restart k3s", + "sleep 20", + ] + } +} + +# systemd-resolved occupies port 53 on the host — must be disabled for AdGuard to bind +resource "null_resource" "disable_systemd_resolved" { + connection { + type = "ssh" + host = var.legion_ip + user = "will" + private_key = file("~/.ssh/id_ed25519") + } + + provisioner "remote-exec" { + inline = [ + "sudo systemctl disable --now systemd-resolved || true", + "sudo rm -f /etc/resolv.conf", + "echo 'nameserver 1.1.1.1' | sudo tee /etc/resolv.conf", + ] + } +} diff --git a/legion/backup.tf b/legion/backup.tf new file mode 100644 index 0000000..143567c --- /dev/null +++ b/legion/backup.tf @@ -0,0 +1,137 @@ +# Legion backup — restic to Cloudflare R2 +# Dumps all Postgres databases + Gitea data nightly at 2am +# Retains 30 daily / 12 weekly / 6 monthly snapshots + +resource "kubernetes_secret" "backup_credentials" { + metadata { + name = "backup-credentials" + namespace = "git" + } + + data = { + RESTIC_PASSWORD = "gitea-restic-2026" + AWS_ACCESS_KEY_ID = var.r2_access_key_id + AWS_SECRET_ACCESS_KEY = var.r2_secret_access_key + RESTIC_REPOSITORY = "s3:${var.r2_endpoint}/legion-gitea-backup" + POSTGRES_PASSWORD = var.postgres_password + } +} + +resource "kubernetes_cron_job_v1" "gitea_backup" { + metadata { + name = "gitea-backup" + namespace = "git" + } + + spec { + schedule = "0 2 * * *" + concurrency_policy = "Forbid" + failed_jobs_history_limit = 3 + successful_jobs_history_limit = 3 + + job_template { + metadata {} + spec { + backoff_limit = 2 + template { + metadata {} + spec { + restart_policy = "OnFailure" + + # Dump all Postgres databases before restic runs + init_container { + name = "pg-dump" + image = "postgres:18-alpine" + + command = ["/bin/sh", "-c", + "pg_dumpall -h postgres-postgresql.platform.svc.cluster.local -U postgres --no-role-passwords > /dump/all-databases.sql && echo 'DB dump complete, size:' $(du -sh /dump/all-databases.sql)" + ] + + env_from { + secret_ref { + name = kubernetes_secret.backup_credentials.metadata[0].name + } + } + + volume_mount { + name = "dump" + mount_path = "/dump" + } + + resources { + requests = { memory = "128Mi", cpu = "100m" } + limits = { memory = "256Mi" } + } + } + + container { + name = "backup" + image = "restic/restic:latest" + + command = ["/bin/sh", "-c", <<-EOT + set -e + + # Init repo if first run + restic snapshots 2>/dev/null || restic init + + # Back up Gitea data + full DB dump + echo "Running restic backup..." + restic backup \ + /gitea-data \ + /dump/all-databases.sql \ + --tag legion \ + --host legion + + # Prune old snapshots + restic forget \ + --keep-daily 30 \ + --keep-weekly 12 \ + --keep-monthly 6 \ + --prune + + echo "Backup complete." + restic snapshots --latest 3 + EOT + ] + + env_from { + secret_ref { + name = kubernetes_secret.backup_credentials.metadata[0].name + } + } + + volume_mount { + name = "gitea-data" + mount_path = "/gitea-data" + read_only = true + } + + volume_mount { + name = "dump" + mount_path = "/dump" + } + + resources { + requests = { memory = "256Mi", cpu = "100m" } + limits = { memory = "512Mi" } + } + } + + volume { + name = "gitea-data" + persistent_volume_claim { + claim_name = "gitea-data" + read_only = true + } + } + + volume { + name = "dump" + empty_dir {} + } + } + } + } + } + } +} diff --git a/legion/cert-manager.tf b/legion/cert-manager.tf new file mode 100644 index 0000000..10ad7ed --- /dev/null +++ b/legion/cert-manager.tf @@ -0,0 +1,63 @@ +# cert-manager — TLS certificate management via Let's Encrypt + Cloudflare DNS-01 + +resource "helm_release" "cert_manager" { + name = "cert-manager" + repository = "https://charts.jetstack.io" + chart = "cert-manager" + version = "v1.17.1" + namespace = "cert-manager" + create_namespace = true + + set { + name = "crds.enabled" + value = "true" + } +} + +resource "kubernetes_secret" "cloudflare_api_key" { + metadata { + name = "cloudflare-api-key" + namespace = "cert-manager" + } + + data = { + api-key = var.cloudflare_api_key + } + + depends_on = [helm_release.cert_manager] +} + +resource "kubernetes_manifest" "cluster_issuer_prod" { + manifest = { + apiVersion = "cert-manager.io/v1" + kind = "ClusterIssuer" + metadata = { + name = "letsencrypt-prod" + } + spec = { + acme = { + server = "https://acme-v02.api.letsencrypt.org/directory" + email = var.cloudflare_email + privateKeySecretRef = { + name = "letsencrypt-prod" + } + solvers = [{ + dns01 = { + cloudflare = { + email = var.cloudflare_email + apiKeySecretRef = { + name = "cloudflare-api-key" + key = "api-key" + } + } + } + }] + } + } + } + + depends_on = [ + helm_release.cert_manager, + kubernetes_secret.cloudflare_api_key, + ] +} diff --git a/legion/gitea.tf b/legion/gitea.tf new file mode 100644 index 0000000..56dd1a6 --- /dev/null +++ b/legion/gitea.tf @@ -0,0 +1,361 @@ +# Gitea — local git server for private/sensitive infrastructure repos +# Accessible at git.neuralplatform.dev (internal only, real TLS) + +resource "kubernetes_config_map" "gitea_custom_css" { + metadata { + name = "gitea-custom-css" + namespace = kubernetes_namespace.git.metadata[0].name + } + + data = { + "header.tmpl" = <<-TMPL + + TMPL + + + "custom.css" = <<-CSS + /* ── Typography & base ── */ + :root { + --color-primary: #6366f1; + --color-primary-dark: #4f46e5; + --color-secondary: #8b5cf6; + --color-bg: #0f0f17; + --color-surface: #16161f; + --color-surface-2: #1e1e2e; + --color-border: #2a2a3d; + --color-text: #e2e2f0; + --color-text-muted: #8888aa; + --color-green: #22d3a5; + --radius: 10px; + } + + body { + background: var(--color-bg) !important; + color: var(--color-text) !important; + font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important; + } + + /* ── Top navbar ── */ + .navbar, #navbar { + background: var(--color-surface) !important; + border-bottom: 1px solid var(--color-border) !important; + box-shadow: 0 1px 12px rgba(0,0,0,0.4) !important; + } + + .navbar .brand svg, .navbar .brand img { filter: brightness(1.2); } + + /* ── Sidebar & panels ── */ + .repository, .ui.container, .ui.segment, + .ui.card, .ui.cards > .card { + background: var(--color-surface) !important; + border: 1px solid var(--color-border) !important; + border-radius: var(--radius) !important; + } + + /* ── Buttons ── */ + .ui.primary.button, .ui.green.button { + background: var(--color-primary) !important; + border: none !important; + border-radius: 8px !important; + } + .ui.primary.button:hover { background: var(--color-primary-dark) !important; } + + /* ── Inputs ── */ + input, textarea, select, + .ui.input > input, .ui.dropdown { + background: var(--color-surface-2) !important; + border: 1px solid var(--color-border) !important; + color: var(--color-text) !important; + border-radius: 8px !important; + } + + /* ── Sign-in page ── */ + .user.signin .ui.segment, + .user.signup .ui.segment { + background: var(--color-surface) !important; + border: 1px solid var(--color-border) !important; + border-radius: 16px !important; + box-shadow: 0 8px 32px rgba(0,0,0,0.5) !important; + padding: 2.5rem !important; + } + + /* ── Repo file tree ── */ + .repository.file.list .file-list { + background: var(--color-surface) !important; + border-radius: var(--radius) !important; + border: 1px solid var(--color-border) !important; + } + + .repository.file.list .file-list tr:hover td { + background: var(--color-surface-2) !important; + } + + /* ── Labels & badges ── */ + .ui.label { border-radius: 6px !important; } + + /* ── Dashboard activity feed ── */ + .feeds .news { border-bottom: 1px solid var(--color-border) !important; } + + /* ── Code blocks ── */ + pre, code { + background: var(--color-surface-2) !important; + border: 1px solid var(--color-border) !important; + border-radius: 6px !important; + } + + /* ── Muted footer ── */ + #footer { + background: var(--color-surface) !important; + border-top: 1px solid var(--color-border) !important; + color: var(--color-text-muted) !important; + } + CSS + } +} + +resource "kubernetes_namespace" "git" { + metadata { + name = "git" + labels = { + managed-by = "terraform" + tier = "infrastructure" + } + } +} + +resource "kubernetes_persistent_volume_claim" "gitea_data" { + wait_until_bound = false + metadata { + name = "gitea-data" + namespace = kubernetes_namespace.git.metadata[0].name + } + spec { + access_modes = ["ReadWriteOnce"] + resources { + requests = { + storage = "20Gi" + } + } + } +} + +resource "kubernetes_secret" "gitea_db_secret" { + metadata { + name = "gitea-db" + namespace = kubernetes_namespace.git.metadata[0].name + } + data = { + password = var.gitea_db_password + } +} + +resource "kubernetes_deployment" "gitea" { + metadata { + name = "gitea" + namespace = kubernetes_namespace.git.metadata[0].name + labels = { app = "gitea" } + } + + spec { + replicas = 1 + strategy { type = "Recreate" } + + selector { + match_labels = { app = "gitea" } + } + + template { + metadata { + labels = { app = "gitea" } + } + + spec { + container { + name = "gitea" + image = "gitea/gitea:1.22" + + port { + name = "http" + container_port = 3000 + } + port { + name = "ssh" + container_port = 22 + } + + env { + name = "GITEA__database__DB_TYPE" + value = "postgres" + } + env { + name = "GITEA__database__HOST" + value = "postgres-postgresql.platform.svc.cluster.local:5432" + } + env { + name = "GITEA__database__NAME" + value = "gitea" + } + env { + name = "GITEA__database__USER" + value = "gitea" + } + env { + name = "GITEA__database__PASSWD" + value_from { + secret_key_ref { + name = kubernetes_secret.gitea_db_secret.metadata[0].name + key = "password" + } + } + } + env { + name = "GITEA__server__DOMAIN" + value = var.gitea_domain + } + env { + name = "GITEA__server__ROOT_URL" + value = "https://${var.gitea_domain}" + } + env { + name = "GITEA__server__SSH_DOMAIN" + value = var.gitea_domain + } + env { + name = "GITEA__server__SSH_PORT" + value = "30022" + } + env { + name = "GITEA__server__START_SSH_SERVER" + value = "false" + } + env { + name = "GITEA__service__DISABLE_REGISTRATION" + value = "true" + } + env { + name = "GITEA__service__REQUIRE_SIGNIN_VIEW" + value = "true" + } + env { + name = "GITEA__security__INSTALL_LOCK" + value = "true" + } + env { + name = "GITEA__packages__ENABLED" + value = "true" + } + + env { + name = "GITEA__webhook__ALLOWED_HOST_LIST" + value = "192.168.0.0/24,10.0.0.0/8" + } + + volume_mount { + name = "data" + mount_path = "/data" + } + + volume_mount { + name = "custom-css" + mount_path = "/data/gitea/custom/public/assets/css/custom.css" + sub_path = "custom.css" + } + + volume_mount { + name = "custom-css" + mount_path = "/data/gitea/custom/templates/custom/header.tmpl" + sub_path = "header.tmpl" + } + + resources { + requests = { + memory = "256Mi" + cpu = "100m" + } + limits = { + memory = "512Mi" + cpu = "500m" + } + } + } + + volume { + name = "data" + persistent_volume_claim { + claim_name = kubernetes_persistent_volume_claim.gitea_data.metadata[0].name + } + } + + volume { + name = "custom-css" + config_map { + name = kubernetes_config_map.gitea_custom_css.metadata[0].name + } + } + } + } + } + + depends_on = [helm_release.postgres] +} + +resource "kubernetes_service" "gitea" { + metadata { + name = "gitea" + namespace = kubernetes_namespace.git.metadata[0].name + } + + spec { + selector = { app = "gitea" } + + port { + name = "http" + port = 3000 + target_port = 3000 + } + + port { + name = "ssh" + port = 22 + target_port = 22 + node_port = 30022 + } + + type = "NodePort" + } +} + +resource "kubernetes_ingress_v1" "gitea" { + metadata { + name = "gitea" + namespace = kubernetes_namespace.git.metadata[0].name + annotations = { + "traefik.ingress.kubernetes.io/router.entrypoints" = "websecure" + "cert-manager.io/cluster-issuer" = "letsencrypt-prod" + } + } + + spec { + tls { + hosts = [var.gitea_domain] + secret_name = "gitea-tls" + } + rule { + host = var.gitea_domain + http { + path { + path = "/" + path_type = "Prefix" + backend { + service { + name = kubernetes_service.gitea.metadata[0].name + port { number = 3000 } + } + } + } + } + } + } + + depends_on = [helm_release.cert_manager] +} diff --git a/legion/github-runner.tf b/legion/github-runner.tf new file mode 100644 index 0000000..d59faa9 --- /dev/null +++ b/legion/github-runner.tf @@ -0,0 +1,94 @@ +# GitHub Actions Self-Hosted Runner — org-level, serves all repos +resource "kubernetes_deployment" "github_runner" { + metadata { + name = "github-runner" + namespace = kubernetes_namespace.ci.metadata[0].name + labels = { + app = "github-runner" + } + } + + spec { + replicas = 1 + + selector { + match_labels = { + app = "github-runner" + } + } + + template { + metadata { + labels = { + app = "github-runner" + } + } + + spec { + container { + name = "runner" + image = "myoung34/github-runner:latest" + + env { + name = "ACCESS_TOKEN" + value = var.github_classic_pat + } + + env { + name = "RUNNER_SCOPE" + value = "org" + } + + env { + name = "ORG_NAME" + value = "harmonic-framework" + } + + env { + name = "RUNNER_NAME" + value = "legion" + } + + env { + name = "RUNNER_WORKDIR" + value = "/tmp/runner" + } + + env { + name = "LABELS" + value = "self-hosted,linux,x64,legion" + } + + env { + name = "DISABLE_AUTO_UPDATE" + value = "true" + } + + volume_mount { + name = "docker-sock" + mount_path = "/var/run/docker.sock" + } + + resources { + requests = { + memory = "512Mi" + cpu = "250m" + } + limits = { + memory = "4Gi" + cpu = "4" + } + } + } + + volume { + name = "docker-sock" + host_path { + path = "/var/run/docker.sock" + type = "Socket" + } + } + } + } + } +} diff --git a/legion/inactive/kong.tf.disabled b/legion/inactive/kong.tf.disabled new file mode 100644 index 0000000..413e5f5 --- /dev/null +++ b/legion/inactive/kong.tf.disabled @@ -0,0 +1,81 @@ +# Kong API Gateway — backed by Postgres +resource "helm_release" "kong" { + name = "kong" + namespace = kubernetes_namespace.platform.metadata[0].name + repository = "https://charts.konghq.com" + chart = "kong" + + timeout = 600 + + depends_on = [helm_release.postgres] + + set { + name = "env.database" + value = "postgres" + } + + set { + name = "env.pg_host" + value = "postgres-postgresql.platform.svc.cluster.local" + } + + set { + name = "env.pg_user" + value = "kong" + } + + set { + name = "env.pg_password" + value = var.kong_db_password + } + + set { + name = "env.pg_database" + value = "kong" + } + + set { + name = "proxy.type" + value = "ClusterIP" + } + + set { + name = "admin.enabled" + value = "true" + } + + set { + name = "admin.type" + value = "ClusterIP" + } + + set { + name = "admin.http.enabled" + value = "true" + } + + set { + name = "manager.enabled" + value = "true" + } + + set { + name = "manager.type" + value = "ClusterIP" + } + + set { + name = "ingressController.enabled" + value = "false" + } + + set { + name = "migrations.preUpgrade" + value = "true" + } + + set { + name = "migrations.postUpgrade" + value = "true" + } +} diff --git a/legion/inactive/webui.tf.disabled b/legion/inactive/webui.tf.disabled new file mode 100644 index 0000000..1de5e3a --- /dev/null +++ b/legion/inactive/webui.tf.disabled @@ -0,0 +1,124 @@ +# Open WebUI — chat interface for Ollama with image generation +resource "kubernetes_namespace" "webui" { + metadata { + name = "webui" + labels = { + managed-by = "terraform" + tier = "application" + } + } +} + +resource "kubernetes_persistent_volume_claim" "webui_data" { + metadata { + name = "webui-data" + namespace = kubernetes_namespace.webui.metadata[0].name + } + + spec { + access_modes = ["ReadWriteOnce"] + resources { + requests = { + storage = "5Gi" + } + } + } +} + +resource "kubernetes_deployment" "open_webui" { + metadata { + name = "open-webui" + namespace = kubernetes_namespace.webui.metadata[0].name + labels = { + app = "open-webui" + } + } + + spec { + replicas = 1 + + selector { + match_labels = { + app = "open-webui" + } + } + + template { + metadata { + labels = { + app = "open-webui" + } + } + + spec { + container { + name = "open-webui" + image = "ghcr.io/open-webui/open-webui:main" + + port { + container_port = 8080 + } + + env { + name = "OLLAMA_BASE_URL" + value = "http://ollama.ollama.svc.cluster.local:11434" + } + + env { + name = "AUTOMATIC1111_BASE_URL" + value = "http://stable-diffusion.stable-diffusion.svc.cluster.local:7860" + } + + env { + name = "ENABLE_IMAGE_GENERATION" + value = "true" + } + + volume_mount { + name = "data" + mount_path = "/app/backend/data" + } + + resources { + requests = { + memory = "256Mi" + cpu = "100m" + } + limits = { + memory = "1Gi" + cpu = "1" + } + } + } + + volume { + name = "data" + persistent_volume_claim { + claim_name = kubernetes_persistent_volume_claim.webui_data.metadata[0].name + } + } + } + } + } +} + +resource "kubernetes_service" "open_webui" { + metadata { + name = "open-webui" + namespace = kubernetes_namespace.webui.metadata[0].name + } + + spec { + selector = { + app = "open-webui" + } + + port { + port = 8080 + target_port = 8080 + node_port = 30080 + } + + type = "NodePort" + } +} diff --git a/legion/ingress.tf b/legion/ingress.tf new file mode 100644 index 0000000..7de99ea --- /dev/null +++ b/legion/ingress.tf @@ -0,0 +1,180 @@ +# Traefik ingress routes for all services — TLS via cert-manager + Cloudflare DNS-01 +# Kong and open-webui ingresses moved to inactive/ — restore when needed + +resource "kubernetes_ingress_v1" "ollama" { + metadata { + name = "ollama" + namespace = kubernetes_namespace.ollama.metadata[0].name + annotations = { + "traefik.ingress.kubernetes.io/router.entrypoints" = "websecure" + "cert-manager.io/cluster-issuer" = "letsencrypt-prod" + } + } + + spec { + tls { + hosts = ["ollama.${var.infra_domain}"] + secret_name = "ollama-tls" + } + rule { + host = "ollama.${var.infra_domain}" + http { + path { + path = "/" + path_type = "Prefix" + backend { + service { + name = kubernetes_service.ollama.metadata[0].name + port { number = 11434 } + } + } + } + } + } + } + + depends_on = [helm_release.cert_manager] +} + + +resource "kubernetes_ingress_v1" "registry" { + metadata { + name = "registry" + namespace = kubernetes_namespace.registry.metadata[0].name + annotations = { + "traefik.ingress.kubernetes.io/router.entrypoints" = "websecure" + "cert-manager.io/cluster-issuer" = "letsencrypt-prod" + } + } + + spec { + tls { + hosts = ["registry.${var.domain_suffix}"] + secret_name = "registry-tls" + } + rule { + host = "registry.${var.domain_suffix}" + http { + path { + path = "/" + path_type = "Prefix" + backend { + service { + name = kubernetes_service.registry.metadata[0].name + port { number = 5000 } + } + } + } + } + } + } + + depends_on = [helm_release.cert_manager] +} + +resource "kubernetes_ingress_v1" "registry_ui" { + metadata { + name = "registry-ui" + namespace = kubernetes_namespace.registry.metadata[0].name + annotations = { + "traefik.ingress.kubernetes.io/router.entrypoints" = "websecure" + "cert-manager.io/cluster-issuer" = "letsencrypt-prod" + } + } + + spec { + tls { + hosts = ["docker.neuralplatform.ai"] + secret_name = "docker-ui-tls" + } + rule { + host = "docker.neuralplatform.ai" + http { + path { + path = "/" + path_type = "Prefix" + backend { + service { + name = kubernetes_service.registry_ui.metadata[0].name + port { number = 80 } + } + } + } + } + } + } + + depends_on = [helm_release.cert_manager] +} + +# npm registry — Verdaccio (proxies npmjs.org, caches locally) +resource "kubernetes_ingress_v1" "npm_registry" { + metadata { + name = "npm-registry" + namespace = kubernetes_namespace.packages.metadata[0].name + annotations = { + "traefik.ingress.kubernetes.io/router.entrypoints" = "websecure" + "cert-manager.io/cluster-issuer" = "letsencrypt-prod" + } + } + + spec { + tls { + hosts = ["npm.neuralplatform.ai"] + secret_name = "npm-registry-tls" + } + rule { + host = "npm.neuralplatform.ai" + http { + path { + path = "/" + path_type = "Prefix" + backend { + service { + name = kubernetes_service.verdaccio.metadata[0].name + port { number = 4873 } + } + } + } + } + } + } + + depends_on = [helm_release.cert_manager, kubernetes_deployment.verdaccio] +} + +# PyPI registry — devpi (proxies pypi.org, caches locally) +resource "kubernetes_ingress_v1" "pypi_registry" { + metadata { + name = "pypi-registry" + namespace = kubernetes_namespace.packages.metadata[0].name + annotations = { + "traefik.ingress.kubernetes.io/router.entrypoints" = "websecure" + "cert-manager.io/cluster-issuer" = "letsencrypt-prod" + } + } + + spec { + tls { + hosts = ["pypi.neuralplatform.ai"] + secret_name = "pypi-registry-tls" + } + rule { + host = "pypi.neuralplatform.ai" + http { + path { + path = "/" + path_type = "Prefix" + backend { + service { + name = kubernetes_service.devpi.metadata[0].name + port { number = 3141 } + } + } + } + } + } + } + + depends_on = [helm_release.cert_manager, kubernetes_deployment.devpi] +} diff --git a/legion/main.tf b/legion/main.tf new file mode 100644 index 0000000..70c6c01 --- /dev/null +++ b/legion/main.tf @@ -0,0 +1,41 @@ +terraform { + required_version = ">= 1.5" + + required_providers { + kubernetes = { + source = "hashicorp/kubernetes" + version = "~> 2.30" + } + helm = { + source = "hashicorp/helm" + version = "~> 2.14" + } + null = { + source = "hashicorp/null" + version = "~> 3.0" + } + } + + backend "s3" { + bucket = "legion-terraform-state" + key = "legion/terraform.tfstate" + region = "auto" + endpoint = "https://651161e0a3d321561b4c90b5bcd5f15b.r2.cloudflarestorage.com" + + # R2 is S3-compatible but not AWS — skip AWS-specific checks + skip_credentials_validation = true + skip_metadata_api_check = true + skip_region_validation = true + force_path_style = true + } +} + +provider "kubernetes" { + config_path = var.kubeconfig_path +} + +provider "helm" { + kubernetes { + config_path = var.kubeconfig_path + } +} diff --git a/legion/monitoring.tf b/legion/monitoring.tf new file mode 100644 index 0000000..53b171e --- /dev/null +++ b/legion/monitoring.tf @@ -0,0 +1,288 @@ +# Monitoring stack — Prometheus, Grafana, Loki, Tempo, Alloy, Alertmanager +# Grafana UI: https://grafana.nook.family (admin / var.grafana_admin_password) +# +# TODO: Migrate Tempo to tempo-distributed once R2 is wired as trace storage backend +# TODO: Replace loki.useTestSchema=true with proper schema_config for production + +resource "kubernetes_namespace" "monitoring" { + metadata { + name = "monitoring" + labels = { + managed-by = "terraform" + tier = "observability" + } + } +} + +# kube-prometheus-stack — Prometheus, Grafana, Alertmanager, node exporters +resource "helm_release" "kube_prometheus_stack" { + name = "kube-prometheus-stack" + namespace = kubernetes_namespace.monitoring.metadata[0].name + repository = "https://prometheus-community.github.io/helm-charts" + chart = "kube-prometheus-stack" + timeout = 600 + + # Grafana + set { + name = "grafana.adminPassword" + value = var.grafana_admin_password + } + set { + name = "grafana.persistence.enabled" + value = "true" + } + set { + name = "grafana.persistence.size" + value = "5Gi" + } + set { + name = "grafana.sidecar.datasources.defaultDatasourceEnabled" + value = "false" + } + set { + name = "grafana.initChownData.enabled" + value = "false" + } + set { + name = "grafana.ingress.enabled" + value = "true" + } + set { + name = "grafana.ingress.ingressClassName" + value = "traefik" + } + set { + name = "grafana.ingress.hosts[0]" + value = "grafana.${var.infra_domain}" + } + set { + name = "grafana.ingress.tls[0].secretName" + value = "grafana-tls" + } + set { + name = "grafana.ingress.tls[0].hosts[0]" + value = "grafana.${var.infra_domain}" + } + set { + name = "grafana.ingress.annotations.traefik\\.ingress\\.kubernetes\\.io/router\\.entrypoints" + value = "websecure" + } + set { + name = "grafana.ingress.annotations.cert-manager\\.io/cluster-issuer" + value = "letsencrypt-prod" + } + + # Prometheus persistent storage + set { + name = "prometheus.prometheusSpec.storageSpec.volumeClaimTemplate.spec.resources.requests.storage" + value = "20Gi" + } + + # Disable k3s-incompatible scrapers + set { + name = "kubeEtcd.enabled" + value = "false" + } + set { + name = "kubeControllerManager.enabled" + value = "false" + } + set { + name = "kubeScheduler.enabled" + value = "false" + } + set { + name = "kubeProxy.enabled" + value = "false" + } + + depends_on = [helm_release.cert_manager] +} + +# Grafana extra datasources — Loki and Tempo wired up with cross-linking +resource "kubernetes_config_map" "grafana_extra_datasources" { + metadata { + name = "grafana-extra-datasources" + namespace = kubernetes_namespace.monitoring.metadata[0].name + labels = { grafana_datasource = "1" } + } + + data = { + "datasources.yaml" = <<-EOT + apiVersion: 1 + datasources: + - name: Loki + type: loki + uid: loki + url: http://loki-gateway.monitoring.svc.cluster.local + access: proxy + jsonData: + httpHeaderName1: X-Scope-OrgID + secureJsonData: + httpHeaderValue1: legion + - name: Tempo + type: tempo + uid: tempo + url: http://tempo.monitoring.svc.cluster.local:3100 + access: proxy + jsonData: + lokiSearch: + datasourceUid: loki + tracesToLogsV2: + datasourceUid: loki + filterByTraceID: true + filterBySpanID: false + tracesToMetrics: + datasourceUid: prometheus + EOT + } + + depends_on = [helm_release.kube_prometheus_stack] +} + +# Loki — log aggregation (single binary mode) +# TODO: Replace useTestSchema with proper schema_config before storing long-term logs +resource "helm_release" "loki" { + name = "loki" + namespace = kubernetes_namespace.monitoring.metadata[0].name + repository = "https://grafana.github.io/helm-charts" + chart = "loki" + timeout = 300 + + set { + name = "loki.commonConfig.replication_factor" + value = "1" + } + set { + name = "loki.storage.type" + value = "filesystem" + } + set { + name = "loki.useTestSchema" + value = "true" + } + set { + name = "singleBinary.replicas" + value = "1" + } + set { + name = "singleBinary.persistence.enabled" + value = "true" + } + set { + name = "singleBinary.persistence.size" + value = "10Gi" + } + set { + name = "deploymentMode" + value = "SingleBinary" + } + set { + name = "backend.replicas" + value = "0" + } + set { + name = "read.replicas" + value = "0" + } + set { + name = "write.replicas" + value = "0" + } + set { + name = "chunksCache.enabled" + value = "false" + } + set { + name = "resultsCache.enabled" + value = "false" + } +} + +# Tempo — distributed tracing backend (local storage) +# TODO: Migrate to tempo-distributed with R2 storage once R2 trace bucket is created +resource "helm_release" "tempo" { + name = "tempo" + namespace = kubernetes_namespace.monitoring.metadata[0].name + repository = "https://grafana.github.io/helm-charts" + chart = "tempo" + timeout = 300 + + set { + name = "tempo.storage.trace.backend" + value = "local" + } + set { + name = "tempo.storage.trace.local.path" + value = "/var/tempo" + } + set { + name = "persistence.enabled" + value = "true" + } + set { + name = "persistence.size" + value = "10Gi" + } +} + +# Alloy — log shipping + OTLP trace forwarding (replaces deprecated Promtail) +resource "helm_release" "alloy" { + name = "alloy" + namespace = kubernetes_namespace.monitoring.metadata[0].name + repository = "https://grafana.github.io/helm-charts" + chart = "alloy" + timeout = 300 + + set { + name = "controller.type" + value = "daemonset" + } + + values = [<<-EOT + alloy: + configMap: + content: | + discovery.kubernetes "pods" { + role = "pod" + } + + discovery.relabel "pods" { + targets = discovery.kubernetes.pods.targets + rule { source_labels = ["__meta_kubernetes_namespace"]; target_label = "namespace" } + rule { source_labels = ["__meta_kubernetes_pod_name"]; target_label = "pod" } + rule { source_labels = ["__meta_kubernetes_pod_container_name"]; target_label = "container" } + rule { source_labels = ["__meta_kubernetes_pod_label_app"]; target_label = "app" } + } + + loki.source.kubernetes "pods" { + targets = discovery.relabel.pods.output + forward_to = [loki.write.default.receiver] + } + + loki.write "default" { + endpoint { + url = "http://loki-gateway.monitoring.svc.cluster.local/loki/api/v1/push" + tenant_id = "legion" + } + } + + otelcol.receiver.otlp "default" { + grpc { endpoint = "0.0.0.0:4317" } + http { endpoint = "0.0.0.0:4318" } + output { + traces = [otelcol.exporter.otlp.tempo.input] + } + } + + otelcol.exporter.otlp "tempo" { + client { + endpoint = "tempo.monitoring.svc.cluster.local:4317" + tls { insecure = true } + } + } + EOT + ] + + depends_on = [helm_release.loki, helm_release.tempo] +} diff --git a/legion/namespaces.tf b/legion/namespaces.tf new file mode 100644 index 0000000..3e885c5 --- /dev/null +++ b/legion/namespaces.tf @@ -0,0 +1,19 @@ +resource "kubernetes_namespace" "platform" { + metadata { + name = "platform" + labels = { + managed-by = "terraform" + tier = "infrastructure" + } + } +} + +resource "kubernetes_namespace" "ci" { + metadata { + name = "ci" + labels = { + managed-by = "terraform" + tier = "infrastructure" + } + } +} diff --git a/legion/neuron.tf b/legion/neuron.tf new file mode 100644 index 0000000..d48a717 --- /dev/null +++ b/legion/neuron.tf @@ -0,0 +1,343 @@ +# Neuron — MCP server + Axon webhook hub +# MCP: https://neuron.nook.family (internal, Traefik) +# Webhooks: https://axon.neuralplatform.ai (external, Cloudflare tunnel) +# +# Image: registry.nook.family/neural-platform/neuron:latest +# Built by GitHub Actions runner on Legion — see neural-platform/neuron ci.yml +# +# TODO: Migrate SQLite → Postgres once neuron db migration is ready +# TODO: Wire Gmail OAuth credentials via k8s Secret (currently unauthenticated) + +resource "kubernetes_namespace" "neuron" { + metadata { + name = "neuron" + labels = { + managed-by = "terraform" + tier = "platform" + } + } +} + +resource "kubernetes_persistent_volume_claim" "neuron_data" { + metadata { + name = "neuron-data" + namespace = kubernetes_namespace.neuron.metadata[0].name + } + + spec { + access_modes = ["ReadWriteOnce"] + resources { + requests = { + storage = "5Gi" + } + } + } +} + +resource "kubernetes_config_map" "neuron_config" { + metadata { + name = "neuron-config" + namespace = kubernetes_namespace.neuron.metadata[0].name + } + + data = { + MCP_PORT = "8001" + MCP_HOST = "0.0.0.0" + WEBHOOK_PORT = "3847" + NEURON_DB_PATH = "/data/neuron.db" + AXON_DB_PATH = "/data/axon.db" + TUNNEL_NAME = "neural-platform" + + "default.yaml" = <<-EOT + runtime: + name: neuron + version: 0.1.0 + storage: + db_path: /data/neuron.db + cache: + max_l1_size: 1000 + gc: + gen0_threshold: 3 + gen1_threshold: 5 + knowledge: + base_path: /data/knowledge + synapse: + path: /data/synapse + EOT + } +} + +resource "kubernetes_secret" "neuron_secrets" { + metadata { + name = "neuron-secrets" + namespace = kubernetes_namespace.neuron.metadata[0].name + } + + data = { + GITHUB_WEBHOOK_SECRET = var.github_webhook_secret + GITEA_WEBHOOK_SECRET = var.gitea_webhook_secret + SLACK_BOT_TOKEN = var.slack_bot_token + SLACK_SIGNING_SECRET = var.slack_signing_secret + } +} + +resource "kubernetes_deployment" "neuron" { + metadata { + name = "neuron" + namespace = kubernetes_namespace.neuron.metadata[0].name + labels = { + app = "neuron" + } + } + + spec { + replicas = 1 + + strategy { + type = "Recreate" # SQLite cannot handle concurrent writers + } + + selector { + match_labels = { + app = "neuron" + } + } + + template { + metadata { + labels = { + app = "neuron" + } + } + + spec { + container { + name = "neuron" + image = "registry.nook.family/neural-platform/neuron:latest" + image_pull_policy = "Always" + + command = [ + "bash", "-c", + "mkdir -p /root/.neuron && ln -sf /data/neuron.db /root/.neuron/neuron.db && python -m synapse platform serve --mcp-host 0.0.0.0 --mcp-port 8001 --webhook-port 3847" + ] + + port { + name = "mcp" + container_port = 8001 + } + + port { + name = "webhook" + container_port = 3847 + } + + env_from { + config_map_ref { + name = kubernetes_config_map.neuron_config.metadata[0].name + } + } + + env_from { + secret_ref { + name = kubernetes_secret.neuron_secrets.metadata[0].name + } + } + + volume_mount { + name = "data" + mount_path = "/data" + } + + volume_mount { + name = "app-config" + mount_path = "/app/config/default.yaml" + sub_path = "default.yaml" + } + + resources { + requests = { + memory = "256Mi" + cpu = "100m" + } + limits = { + memory = "1Gi" + cpu = "500m" + } + } + + liveness_probe { + http_get { + path = "/health" + port = "mcp" + } + initial_delay_seconds = 10 + period_seconds = 30 + } + + readiness_probe { + http_get { + path = "/health" + port = "mcp" + } + initial_delay_seconds = 5 + period_seconds = 10 + } + } + + volume { + name = "data" + persistent_volume_claim { + claim_name = kubernetes_persistent_volume_claim.neuron_data.metadata[0].name + } + } + + volume { + name = "app-config" + config_map { + name = kubernetes_config_map.neuron_config.metadata[0].name + items { + key = "default.yaml" + path = "default.yaml" + } + } + } + } + } + } + + depends_on = [kubernetes_persistent_volume_claim.neuron_data] +} + +resource "kubernetes_service" "neuron" { + metadata { + name = "neuron" + namespace = kubernetes_namespace.neuron.metadata[0].name + } + + spec { + selector = { + app = "neuron" + } + + port { + name = "mcp" + port = 8001 + target_port = 8001 + } + + port { + name = "webhook" + port = 3847 + target_port = 3847 + } + + type = "ClusterIP" + } +} + +# Internal MCP ingress — accessible within nook.family network +resource "kubernetes_ingress_v1" "neuron" { + metadata { + name = "neuron" + namespace = kubernetes_namespace.neuron.metadata[0].name + annotations = { + "traefik.ingress.kubernetes.io/router.entrypoints" = "websecure" + "cert-manager.io/cluster-issuer" = "letsencrypt-prod" + } + } + + spec { + ingress_class_name = "traefik" + + tls { + secret_name = "neuron-tls" + hosts = ["neuron.${var.infra_domain}"] + } + + rule { + host = "neuron.${var.infra_domain}" + http { + path { + path = "/" + path_type = "Prefix" + backend { + service { + name = kubernetes_service.neuron.metadata[0].name + port { + number = 8001 + } + } + } + } + } + } + } + + depends_on = [helm_release.cert_manager] +} + +# cloudflared — Cloudflare tunnel for external access to Axon webhooks +# Routes: axon.neuralplatform.ai → neuron:3847 +# webhooks.neuralplatform.ai → neuron:3847 +resource "kubernetes_deployment" "cloudflared" { + metadata { + name = "cloudflared" + namespace = kubernetes_namespace.neuron.metadata[0].name + labels = { + app = "cloudflared" + } + } + + spec { + replicas = 1 + + selector { + match_labels = { + app = "cloudflared" + } + } + + template { + metadata { + labels = { + app = "cloudflared" + } + } + + spec { + container { + name = "cloudflared" + image = "cloudflare/cloudflared:latest" + args = ["tunnel", "--no-autoupdate", "run"] + + env { + name = "TUNNEL_TOKEN" + value = var.cloudflared_tunnel_token + } + + resources { + requests = { + memory = "64Mi" + cpu = "50m" + } + limits = { + memory = "128Mi" + cpu = "200m" + } + } + + liveness_probe { + http_get { + path = "/ready" + port = 20241 + } + initial_delay_seconds = 10 + period_seconds = 30 + } + } + } + } + } + + depends_on = [kubernetes_deployment.neuron] +} diff --git a/legion/ollama.tf b/legion/ollama.tf new file mode 100644 index 0000000..31f2e3f --- /dev/null +++ b/legion/ollama.tf @@ -0,0 +1,118 @@ +# Ollama — local LLM inference with GPU +resource "kubernetes_namespace" "ollama" { + metadata { + name = "ollama" + labels = { + managed-by = "terraform" + tier = "application" + } + } +} + +resource "kubernetes_persistent_volume_claim" "ollama_models" { + metadata { + name = "ollama-models" + namespace = kubernetes_namespace.ollama.metadata[0].name + } + + spec { + access_modes = ["ReadWriteOnce"] + resources { + requests = { + storage = "50Gi" + } + } + } +} + +resource "kubernetes_deployment" "ollama" { + metadata { + name = "ollama" + namespace = kubernetes_namespace.ollama.metadata[0].name + labels = { + app = "ollama" + } + } + + spec { + replicas = 1 + + strategy { + type = "Recreate" + } + + selector { + match_labels = { + app = "ollama" + } + } + + template { + metadata { + labels = { + app = "ollama" + } + } + + spec { + container { + name = "ollama" + image = "ollama/ollama:latest" + + port { + container_port = 11434 + } + + env { + name = "NVIDIA_VISIBLE_DEVICES" + value = "all" + } + + volume_mount { + name = "models" + mount_path = "/root/.ollama" + } + + resources { + requests = { + memory = "2Gi" + cpu = "500m" + } + limits = { + memory = "8Gi" + cpu = "4" + } + } + } + + volume { + name = "models" + persistent_volume_claim { + claim_name = kubernetes_persistent_volume_claim.ollama_models.metadata[0].name + } + } + } + } + } +} + +resource "kubernetes_service" "ollama" { + metadata { + name = "ollama" + namespace = kubernetes_namespace.ollama.metadata[0].name + } + + spec { + selector = { + app = "ollama" + } + + port { + port = 11434 + target_port = 11434 + node_port = 31434 + } + + type = "NodePort" + } +} diff --git a/legion/packages.tf b/legion/packages.tf new file mode 100644 index 0000000..d20d5df --- /dev/null +++ b/legion/packages.tf @@ -0,0 +1,230 @@ +# Package registries — Verdaccio (npm) and devpi (PyPI) +# Both proxy to public registries and cache locally on Legion + +resource "kubernetes_namespace" "packages" { + metadata { + name = "packages" + labels = { + managed-by = "terraform" + tier = "infrastructure" + } + } +} + +resource "kubernetes_persistent_volume_claim" "verdaccio_data" { + metadata { + name = "verdaccio-data" + namespace = kubernetes_namespace.packages.metadata[0].name + } + spec { + access_modes = ["ReadWriteOnce"] + resources { + requests = { storage = "10Gi" } + } + } +} + +resource "kubernetes_persistent_volume_claim" "devpi_data" { + metadata { + name = "devpi-data" + namespace = kubernetes_namespace.packages.metadata[0].name + } + spec { + access_modes = ["ReadWriteOnce"] + resources { + requests = { storage = "10Gi" } + } + } +} + +# Verdaccio — npm proxy/cache +resource "kubernetes_config_map" "verdaccio_config" { + metadata { + name = "verdaccio-config" + namespace = kubernetes_namespace.packages.metadata[0].name + } + + data = { + "config.yaml" = <<-EOT + storage: /verdaccio/storage + auth: + htpasswd: + file: /verdaccio/storage/htpasswd + max_users: 1 + uplinks: + npmjs: + url: https://registry.npmjs.org/ + cache: true + packages: + '@harmonic-framework/*': + access: $authenticated + publish: $authenticated + unpublish: $authenticated + '**': + access: $all + publish: $authenticated + proxy: npmjs + listen: 0.0.0.0:4873 + server: + keepAliveTimeout: 60 + log: + type: stdout + format: pretty + level: http + EOT + } +} + +resource "kubernetes_deployment" "verdaccio" { + metadata { + name = "verdaccio" + namespace = kubernetes_namespace.packages.metadata[0].name + } + + spec { + replicas = 1 + selector { + match_labels = { app = "verdaccio" } + } + template { + metadata { + labels = { app = "verdaccio" } + } + spec { + security_context { + fs_group = 101 + } + container { + name = "verdaccio" + image = "verdaccio/verdaccio:5" + + port { + container_port = 4873 + } + + # Override k8s service env injection which conflicts with Verdaccio's port detection + env { + name = "VERDACCIO_PORT" + value = "4873" + } + + volume_mount { + name = "config" + mount_path = "/verdaccio/conf/config.yaml" + sub_path = "config.yaml" + } + + volume_mount { + name = "data" + mount_path = "/verdaccio/storage" + } + + resources { + requests = { memory = "128Mi", cpu = "50m" } + limits = { memory = "512Mi" } + } + } + + volume { + name = "config" + config_map { + name = kubernetes_config_map.verdaccio_config.metadata[0].name + } + } + + volume { + name = "data" + persistent_volume_claim { + claim_name = kubernetes_persistent_volume_claim.verdaccio_data.metadata[0].name + } + } + } + } + } +} + +resource "kubernetes_service" "verdaccio" { + metadata { + name = "verdaccio" + namespace = kubernetes_namespace.packages.metadata[0].name + } + spec { + selector = { app = "verdaccio" } + port { + port = 4873 + target_port = 4873 + } + } +} + +# devpi — PyPI proxy/cache +resource "kubernetes_deployment" "devpi" { + metadata { + name = "devpi" + namespace = kubernetes_namespace.packages.metadata[0].name + } + + spec { + replicas = 1 + selector { + match_labels = { app = "devpi" } + } + template { + metadata { + labels = { app = "devpi" } + } + spec { + init_container { + name = "devpi-init" + image = "python:3.12-slim" + command = ["sh", "-c", "pip install -q devpi-server devpi-web && if [ ! -f /data/.serverversion ]; then devpi-init --serverdir /data; fi && chmod 600 /data/.secret 2>/dev/null || true"] + volume_mount { + name = "data" + mount_path = "/data" + } + } + + container { + name = "devpi" + image = "python:3.12-slim" + command = ["sh", "-c", "pip install -q devpi-server devpi-web && exec devpi-server --serverdir /data --host 0.0.0.0 --port 3141 --outside-url https://pypi.neuralplatform.ai"] + + port { + container_port = 3141 + } + + volume_mount { + name = "data" + mount_path = "/data" + } + + resources { + requests = { memory = "512Mi", cpu = "100m" } + limits = { memory = "2Gi" } + } + } + + volume { + name = "data" + persistent_volume_claim { + claim_name = kubernetes_persistent_volume_claim.devpi_data.metadata[0].name + } + } + } + } + } +} + +resource "kubernetes_service" "devpi" { + metadata { + name = "devpi" + namespace = kubernetes_namespace.packages.metadata[0].name + } + spec { + selector = { app = "devpi" } + port { + port = 3141 + target_port = 3141 + } + } +} diff --git a/legion/postgres.tf b/legion/postgres.tf new file mode 100644 index 0000000..09b5507 --- /dev/null +++ b/legion/postgres.tf @@ -0,0 +1,34 @@ +# Shared Postgres — Kong, app databases +resource "helm_release" "postgres" { + name = "postgres" + namespace = kubernetes_namespace.platform.metadata[0].name + repository = "oci://registry-1.docker.io/bitnamicharts" + chart = "postgresql" + + timeout = 600 + + set { + name = "auth.postgresPassword" + value = var.postgres_password + } + + set { + name = "primary.persistence.size" + value = "20Gi" + } + + set { + name = "primary.resources.requests.memory" + value = "256Mi" + } + + set { + name = "primary.resources.requests.cpu" + value = "100m" + } + + set { + name = "primary.initdb.scripts.create-kong-db\\.sql" + value = "CREATE DATABASE kong; CREATE USER kong WITH PASSWORD '${var.kong_db_password}'; GRANT ALL PRIVILEGES ON DATABASE kong TO kong; ALTER DATABASE kong OWNER TO kong;" + } +} diff --git a/legion/redis.tf b/legion/redis.tf new file mode 100644 index 0000000..f8308b6 --- /dev/null +++ b/legion/redis.tf @@ -0,0 +1,34 @@ +# Shared Redis — caching, session store, pub/sub +resource "helm_release" "redis" { + name = "redis" + namespace = kubernetes_namespace.platform.metadata[0].name + repository = "oci://registry-1.docker.io/bitnamicharts" + chart = "redis" + + timeout = 600 + + set { + name = "auth.enabled" + value = "false" + } + + set { + name = "architecture" + value = "standalone" + } + + set { + name = "master.persistence.size" + value = "5Gi" + } + + set { + name = "master.resources.requests.memory" + value = "128Mi" + } + + set { + name = "master.resources.requests.cpu" + value = "50m" + } +} diff --git a/legion/registry.tf b/legion/registry.tf new file mode 100644 index 0000000..522fbce --- /dev/null +++ b/legion/registry.tf @@ -0,0 +1,183 @@ +# Local Docker Registry — push images locally, no DockerHub +resource "kubernetes_namespace" "registry" { + metadata { + name = "registry" + labels = { + managed-by = "terraform" + tier = "infrastructure" + } + } +} + +resource "kubernetes_persistent_volume_claim" "registry_data" { + metadata { + name = "registry-data" + namespace = kubernetes_namespace.registry.metadata[0].name + } + + spec { + access_modes = ["ReadWriteOnce"] + resources { + requests = { + storage = "50Gi" + } + } + } +} + +resource "kubernetes_deployment" "registry" { + metadata { + name = "registry" + namespace = kubernetes_namespace.registry.metadata[0].name + labels = { + app = "registry" + } + } + + spec { + replicas = 1 + + selector { + match_labels = { + app = "registry" + } + } + + template { + metadata { + labels = { + app = "registry" + } + } + + spec { + container { + name = "registry" + image = "registry:2" + + port { + container_port = 5000 + } + + volume_mount { + name = "data" + mount_path = "/var/lib/registry" + } + + resources { + requests = { + memory = "64Mi" + cpu = "50m" + } + limits = { + memory = "256Mi" + cpu = "200m" + } + } + } + + volume { + name = "data" + persistent_volume_claim { + claim_name = kubernetes_persistent_volume_claim.registry_data.metadata[0].name + } + } + } + } + } +} + +resource "kubernetes_service" "registry" { + metadata { + name = "registry" + namespace = kubernetes_namespace.registry.metadata[0].name + } + + spec { + selector = { app = "registry" } + port { + port = 5000 + target_port = 5000 + } + type = "ClusterIP" + } +} + +# Docker Registry UI — browse images and tags visually +resource "kubernetes_deployment" "registry_ui" { + metadata { + name = "registry-ui" + namespace = kubernetes_namespace.registry.metadata[0].name + labels = { app = "registry-ui" } + } + + spec { + replicas = 1 + selector { + match_labels = { app = "registry-ui" } + } + template { + metadata { + labels = { app = "registry-ui" } + } + spec { + container { + name = "registry-ui" + image = "joxit/docker-registry-ui:latest" + + port { + container_port = 80 + } + + env { + name = "REGISTRY_TITLE" + value = "Nook Registry" + } + env { + name = "NGINX_PROXY_PASS_URL" + value = "http://registry:5000" + } + env { + name = "SINGLE_REGISTRY" + value = "true" + } + env { + name = "SHOW_CONTENT_DIGEST" + value = "true" + } + env { + name = "DELETE_IMAGES" + value = "true" + } + + resources { + requests = { + memory = "32Mi" + cpu = "20m" + } + limits = { + memory = "64Mi" + cpu = "100m" + } + } + } + } + } + } +} + +resource "kubernetes_service" "registry_ui" { + metadata { + name = "registry-ui" + namespace = kubernetes_namespace.registry.metadata[0].name + } + + spec { + selector = { app = "registry-ui" } + port { + port = 80 + target_port = 80 + } + type = "ClusterIP" + } +} diff --git a/legion/traefik.tf b/legion/traefik.tf new file mode 100644 index 0000000..00ff03a --- /dev/null +++ b/legion/traefik.tf @@ -0,0 +1,25 @@ +# Traefik configuration — global HTTP→HTTPS redirect +# Applied via k3s HelmChartConfig (managed outside of Helm provider) + +resource "kubernetes_manifest" "traefik_config" { + field_manager { + force_conflicts = true + } + + manifest = { + apiVersion = "helm.cattle.io/v1" + kind = "HelmChartConfig" + metadata = { + name = "traefik" + namespace = "kube-system" + } + spec = { + valuesContent = <<-YAML + ports: + web: + redirectTo: + port: websecure + YAML + } + } +} diff --git a/legion/variables.tf b/legion/variables.tf new file mode 100644 index 0000000..8897ab8 --- /dev/null +++ b/legion/variables.tf @@ -0,0 +1,131 @@ +variable "kubeconfig_path" { + description = "Path to Legion kubeconfig" + type = string + default = "~/.kube/legion-config" +} + +variable "legion_ip" { + description = "Legion server LAN IP (DHCP reserved)" + type = string + default = "192.168.68.77" +} + +variable "domain_suffix" { + description = "Domain suffix for nook.family family apps" + type = string + default = "nook.family" +} + +variable "infra_domain" { + description = "Domain for infrastructure / platform services" + type = string + default = "neuralplatform.ai" +} + +variable "cloudflare_api_key" { + description = "Cloudflare global API key for cert-manager DNS-01" + type = string + sensitive = true +} + +variable "cloudflare_email" { + description = "Cloudflare account email" + type = string + default = "andersonwilliam85@gmail.com" +} + +variable "gitea_domain" { + description = "Domain for Gitea git server" + type = string + default = "git.neuralplatform.dev" +} + +variable "gitea_admin_password" { + description = "Gitea admin user password" + type = string + sensitive = true +} + +variable "gitea_db_password" { + description = "Gitea database password" + type = string + sensitive = true +} + +variable "postgres_password" { + description = "Postgres superuser password" + type = string + sensitive = true +} + +variable "kong_db_password" { + description = "Kong database password" + type = string + sensitive = true +} + +variable "grafana_admin_password" { + description = "Grafana admin password" + type = string + sensitive = true +} + +variable "github_classic_pat" { + description = "GitHub classic PAT with admin:org scope for runner registration" + type = string + sensitive = true +} + +variable "github_org" { + description = "GitHub organization" + type = string + default = "harmonic-framework" +} + +variable "r2_endpoint" { + description = "Cloudflare R2 S3-compatible endpoint" + type = string + default = "https://651161e0a3d321561b4c90b5bcd5f15b.r2.cloudflarestorage.com" +} + +variable "r2_access_key_id" { + description = "Cloudflare R2 access key ID" + type = string + sensitive = true +} + +variable "r2_secret_access_key" { + description = "Cloudflare R2 secret access key" + type = string + sensitive = true +} + +variable "cloudflared_tunnel_token" { + description = "Cloudflare tunnel token for neural-platform tunnel" + type = string + sensitive = true +} + +variable "slack_bot_token" { + description = "Slack bot token for Neuron bot" + type = string + sensitive = true +} + +variable "slack_signing_secret" { + description = "Slack signing secret for webhook verification" + type = string + sensitive = true +} + +variable "github_webhook_secret" { + description = "GitHub webhook secret for Axon event ingestion" + type = string + sensitive = true +} + +variable "gitea_webhook_secret" { + description = "Gitea webhook secret for Axon event ingestion" + type = string + sensitive = true +} diff --git a/legion/vault.tf b/legion/vault.tf new file mode 100644 index 0000000..6049c41 --- /dev/null +++ b/legion/vault.tf @@ -0,0 +1,109 @@ +# Vault — secrets management with GCP KMS auto-unseal +# UI: https://vault.neuralplatform.ai +# Auto-unseal: GCP KMS projects/neuron-785695/locations/global/keyRings/vault/cryptoKeys/vault-unseal + +resource "kubernetes_namespace" "vault" { + metadata { + name = "vault" + labels = { + managed-by = "terraform" + tier = "platform" + } + } +} + +resource "kubernetes_secret" "vault_gcp_sa" { + metadata { + name = "vault-gcp-sa" + namespace = kubernetes_namespace.vault.metadata[0].name + } + + data = { + "sa.json" = file("${path.module}/vault-gcp-sa.json") + } +} + +resource "helm_release" "vault" { + name = "vault" + namespace = kubernetes_namespace.vault.metadata[0].name + repository = "https://helm.releases.hashicorp.com" + chart = "vault" + version = "0.29.1" + timeout = 300 + + values = [<<-EOT + server: + image: + repository: hashicorp/vault + tag: "1.18.3" + + extraEnvironmentVars: + GOOGLE_APPLICATION_CREDENTIALS: /vault/gcp/sa.json + + volumes: + - name: gcp-sa + secret: + secretName: vault-gcp-sa + + volumeMounts: + - name: gcp-sa + mountPath: /vault/gcp + readOnly: true + + ha: + enabled: false + + dataStorage: + enabled: true + size: 10Gi + + standalone: + enabled: true + config: | + ui = true + + listener "tcp" { + tls_disable = 1 + address = "[::]:8200" + cluster_address = "[::]:8201" + } + + storage "file" { + path = "/vault/data" + } + + seal "gcpckms" { + project = "neuron-785695" + region = "global" + key_ring = "vault" + crypto_key = "vault-unseal" + } + + ingress: + enabled: true + ingressClassName: traefik + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: websecure + cert-manager.io/cluster-issuer: letsencrypt-prod + hosts: + - host: vault.${var.infra_domain} + paths: + - / + tls: + - secretName: vault-tls + hosts: + - vault.${var.infra_domain} + + ui: + enabled: true + + injector: + enabled: false + EOT + ] + + depends_on = [ + kubernetes_secret.vault_gcp_sa, + helm_release.cert_manager, + ] +}