Initial commit — migrate legion Terraform from infrastructure repo
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
||||
# Terraform
|
||||
**/.terraform/
|
||||
**/*.tfstate
|
||||
**/*.tfstate.backup
|
||||
**/tfplan
|
||||
**/.terraform.lock.hcl
|
||||
|
||||
# Secrets
|
||||
**/*.json
|
||||
**/*.env
|
||||
**/.envrc
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,7 @@
|
||||
.terraform/
|
||||
*.tfstate
|
||||
*.tfstate.backup
|
||||
.terraform.lock.hcl
|
||||
terraform.tfvars
|
||||
.envrc
|
||||
vault-gcp-sa.json
|
||||
@@ -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",
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
}
|
||||
+361
@@ -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
|
||||
<link rel="stylesheet" href="/assets/css/custom.css">
|
||||
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]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+109
@@ -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,
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user