Compare commits

..

No commits in common. "b9acac33e276ab5d7ad0a54d3d12fe87d1a3c1ba" and "344baa16aa7bb3d189ac6e488ab0799c1876c813" have entirely different histories.

302 changed files with 312 additions and 21750 deletions

5
.gitignore vendored
View File

@ -5,9 +5,6 @@
*.tfstate
*.tfstate.*
# Terraform lockfiles
*.terraform.lock.hcl
# Crash log files
crash.log
crash.*.log
@ -39,6 +36,4 @@ override.tf.json
.terraformrc
terraform.rc
# Local scratch / retired assets, not meant for version control
junkyard/

View File

@ -1,143 +0,0 @@
customModes:
- slug: service-deployment
name: "🚀 Service Deployment"
description: Deploy new services to the mars ship infrastructure using quadlets/modules
roleDefinition: |
You are an expert DevOps engineer tasked with deploying new services to the mars ship infrastructure. Your goal is to create new quadlet modules for services and add them to the mars ship configuration. You specialize in adapting Docker Compose deployments to the quadlet-app module structure, integrating with existing services, and following established infrastructure patterns.
groups:
- read
- edit
- command
- browser
- mcp
whenToUse: |
When you want to deploy a new service to the mars ship infrastructure and need to:
- Create a new quadlet module for the service
- Adapt Docker Compose configurations to the quadlet-app structure
- Integrate with existing services (postgres, valkey, minio, rabbitmq)
- Add the service to the mars ship configuration
customInstructions: |
**Initial Question:**
Always start by asking: "Please provide the OCI container image/tag that you want to deploy to the mars ship."
**Deployment Process:**
1. **Research the Service Deployment:**
- Research how the service is typically deployed using Docker Compose
- Look for official documentation and Docker Compose examples
- Identify required environment variables, ports, volumes, and health checks
- Determine dependencies on other services
2. **Analyze Existing Mars Ship Services:**
- Review available services for dependencies:
- **postgres**: Database service (port 5432)
- **valkey**: Redis-compatible key-value store (port 6379)
- **minio**: S3-compatible object storage (ports 9000, 9001)
- **rabbitmq**: Message queue (port 5672)
- **oci-proxy**: Docker socket proxy (port 2375)
- Understand existing environment variable patterns
- Follow established port allocation and volume mounting patterns
3. **Create the Quadlet Module:**
- Create directory: `quadlets/modules/[service-name]/main.tf`
- Use the quadlet-app base module with this structure:
```hcl
variable "wait_on" {
type = any
description = "Resources to wait on"
default = true
}
variable "server_ip" {
type = string
}
variable "ssh_private_key_path" {
type = string
}
variable "server_domain" {
type = string
}
module "[service-name]" {
source = "../quadlet-app"
wait_on = var.wait_on
server_ip = var.server_ip
ssh_private_key_path = var.ssh_private_key_path
app_name = "[service-name]"
image = "[oci-image-from-user]"
ports = ["port:port"]
volumes = ["/host/path:/container/path:Z"]
environment = {
ENV_VAR = "value"
}
command = ["command", "args"]
healthcmd = "health-check-command"
haproxy_services = [
{
name = "service-name"
domain = "service-name.${var.server_domain}"
port = "port"
host = "127.0.0.1"
tls = false
}
]
}
output "app_urls" {
value = module.[service-name].app_urls
}
output "installed" {
value = true
depends_on = [module.[service-name].installed]
}
```
4. **Configure Service Integration:**
- **Database Integration**: Use postgres connection: `postgres:5432`
- **Redis/Valkey Integration**: Use valkey connection: `valkey:6379`
- **MinIO/S3 Integration**: Use minio connection: `minio:9000`
- **RabbitMQ Integration**: Use rabbitmq connection: `rabbitmq:5672`
5. **Add to Mars Ship Configuration:**
- Add the module to `ships/mars/main.tf`:
```hcl
module "[service-name]" {
wait_on = null_resource.is_up
source = "../../quadlets/modules/[service-name]"
server_ip = var.server
server_domain = var.server_domain
ssh_private_key_path = var.ssh_private_key_path
}
```
- Place appropriately based on dependencies (use `module.oci-proxy.installed` if needed)
6. **Handle Special Configurations:**
- **For passwords**: Use `random_password` resource like postgres/minio modules
- **For multiple ports**: Configure all ports and create separate HAProxy entries
- **For volumes**: Use pattern `/opt/storage/data/[service-name]:/container/path:Z`
7. **Ask Follow-up Questions When:**
- Service requires multiple containers (infrastructure only supports single containers)
- Service needs unusual configurations not seen in existing modules
- Dependencies on existing services are unclear
- Special security configurations are required
8. **Final Implementation:**
- Create the complete module file
- Add the module to mars ship configuration
- Provide exact files that need to be created/modified
- Include necessary deployment commands
**Key Points to Remember:**
- All services use the `quadlet-app` base module
- Services are deployed as single containers (no multi-container docker-compose)
- Use existing services for dependencies
- Follow established patterns for ports, volumes, and environment variables
- Always include HAProxy configuration for web services
- Use the mars ship domain for service URLs

View File

@ -49,29 +49,17 @@ module "valkey" {
ssh_private_key_path = var.ssh_private_key_path
}
module "airsonic-advanced" {
wait_on = module.hetzner.installed
source = "./modules/airsonic-advanced"
server_ip = module.hetzner.server_ip
ssh_private_key_path = var.ssh_private_key_path
server_domain = module.hetzner.server_domain
}
module "mopidy" {
wait_on = module.hetzner.installed
source = "./modules/mopidy"
server_ip = module.hetzner.server_ip
ssh_private_key_path = var.ssh_private_key_path
server_domain = module.hetzner.server_domain
}
module "beets" {
wait_on = module.hetzner.installed
source = "./modules/beets"
server_ip = module.hetzner.server_ip
server_domain = module.hetzner.server_domain
ssh_private_key_path = var.ssh_private_key_path
}
# module "vw-hub" {
# wait_on = module.minio.installed
#
# source = "./modules/vw-hub"
# server_ip = module.hetzner.server_ip
# ssh_private_key_path = var.ssh_private_key_path
# domain = "hub.${module.hetzner.server_domain}"
# s3_access_key = module.minio.access_key
# s3_secret_key = module.minio.secret_key
# s3_server = module.minio.server
# }
output "minio_app_urls" {
value = module.minio.app_urls

View File

@ -1,82 +0,0 @@
variable "wait_on" {
type = any
description = "Resources to wait on"
default = true
}
variable "server_ip" {
type = string
}
variable "ssh_private_key_path" {
type = string
}
variable "server_domain" {
type = string
}
variable "postgres_password" {
type = string
sensitive = true
}
module "psql_db" {
source = "../postgres/tenant"
tenant_name = "affine"
admin_username = "postgres"
admin_password = var.postgres_password
server_ip = var.server_ip
ssh_private_key_path = var.ssh_private_key_path
}
module "affine" {
source = "../quadlet-app"
wait_on = var.wait_on
server_ip = var.server_ip
ssh_private_key_path = var.ssh_private_key_path
restart_policy = "never"
app_name = "affine"
image = "ghcr.io/toeverything/affine:stable"
ports = ["3020:3010"]
volumes = [
"/opt/storage/data/affine/storage:/root/.affine/storage:Z",
"/opt/storage/data/affine/config:/root/.affine/config:Z"
]
environment = {
DATABASE_URL = "postgresql://affine:${module.psql_db.password}@systemd-postgres:5432/affine"
REDIS_SERVER_HOST = "systemd-valkey"
AFFINE_INDEXER_ENABLED = "false"
}
command = [
"sh",
"-c",
"\"node ./scripts/self-host-predeploy.js && node ./dist/main.js\""
]
healthcmd = "curl -f http://localhost:3010 || exit 1"
haproxy_services = [
{
name = "affine"
domain = "affine.${var.server_domain}"
port = "3020"
host = "127.0.0.1"
tls = false
}
]
depends_on_services = ["postgres.service", "valkey.service"]
}
output "app_urls" {
value = module.affine.app_urls
}
output "installed" {
value = true
depends_on = [module.affine.installed]
}

View File

@ -1,60 +0,0 @@
variable "wait_on" {
type = any
description = "Resources to wait on"
default = true
}
variable "server_ip" {
type = string
}
variable "ssh_private_key_path" {
type = string
}
variable "server_domain" {
type = string
}
module "airsonic-advanced" {
source = "../quadlet-app"
wait_on = var.wait_on
server_ip = var.server_ip
ssh_private_key_path = var.ssh_private_key_path
app_name = "airsonic-advanced"
image = "lscr.io/linuxserver/airsonic-advanced:latest"
ports = ["4040:4040"]
volumes = [
"/opt/storage/data/airsonic-advanced:/config:Z",
"/opt/storage/media/music:/music:Z",
]
environment = {
PUID="1001"
PGID="1001"
TZ="Etc/UTC"
#JAVA_OPTS="-Dserver.address=127.0.0.1"
}
haproxy_services = [
{
name = "airsonic-advanced"
domain = "airsonic.${var.server_domain}"
port = "4040"
host = "127.0.0.1"
tls = false
},
]
}
output "app_urls" {
value = module.airsonic-advanced.app_urls
}
output "installed" {
value = true
depends_on = [module.airsonic-advanced.installed]
}

View File

@ -12,57 +12,25 @@ variable "ssh_private_key_path" {
type = string
}
variable "server_domain" {
type = string
}
resource "random_password" "encryption_key" {
length = 32
special = false
}
resource "random_password" "jwt_secret" {
length = 32
special = false
}
module "arcane" {
module "redis" {
source = "../quadlet-app"
wait_on = var.wait_on
server_ip = var.server_ip
ssh_private_key_path = var.ssh_private_key_path
app_name = "arcane"
image = "ghcr.io/ofkm/arcane:latest"
ports = ["3552:3552"]
volumes = ["/opt/storage/data/arcane:/app/data:Z", "/run/user/1001/podman/podman.sock:/var/run/docker.sock:Z"]
#volumes = ["/opt/storage/data/arcane:/app/data:Z"]
environment = {
#DOCKER_HOST = "tcp://localhost:2375"
APP_URL = "https://${var.server_domain}"
ENCRYPTION_KEY = random_password.encryption_key.result
JWT_SECRET = random_password.jwt_secret.result
}
haproxy_services = [
{
name = "arcane"
domain = "${var.server_domain}"
port = "3552"
host = "127.0.0.1"
tls = false
},
]
app_name = "redis"
image = "docker.io/redis:7-alpine"
ports = ["6379:6379"]
volumes = ["/opt/storage/data/redis:/data:Z"]
command = ["redis-server", "--appendonly", "yes"]
}
output "app_urls" {
value = module.arcane.app_urls
value = module.redis.app_urls
}
output "installed" {
value = true
depends_on = [module.arcane.installed]
}
depends_on = [module.redis.installed]
}

View File

@ -1,59 +0,0 @@
variable "wait_on" {
type = any
description = "Resources to wait on"
default = true
}
variable "server_ip" {
type = string
}
variable "ssh_private_key_path" {
type = string
}
variable "server_domain" {
type = string
}
module "beets" {
source = "../quadlet-app"
wait_on = var.wait_on
server_ip = var.server_ip
ssh_private_key_path = var.ssh_private_key_path
app_name = "beets"
image = "lscr.io/linuxserver/beets:latest"
ports = ["8337:8337"]
volumes = [
"/opt/storage/data/beets/config:/config:Z",
"/opt/storage/media/music:/music:z",
"/opt/storage/media/downloads:/downloads:z"
]
environment = {
PUID = "1001"
PGID = "1001"
TZ = "Europe/Amsterdam"
}
haproxy_services = [
{
name = "beets"
domain = "beets.${var.server_domain}"
port = "8337"
host = "127.0.0.1"
tls = false
}
]
}
output "app_urls" {
value = module.beets.app_urls
}
output "installed" {
value = true
depends_on = [module.beets.installed]
}

View File

@ -1,98 +0,0 @@
variable "wait_on" {
type = any
description = "Resources to wait on"
default = true
}
variable "server_ip" {
type = string
}
variable "ssh_private_key_path" {
type = string
}
variable "server_domain" {
type = string
}
module "deeptutor" {
source = "../quadlet-app"
wait_on = var.wait_on
server_ip = var.server_ip
ssh_private_key_path = var.ssh_private_key_path
app_name = "deeptutor"
image = "ghcr.io/hkuds/deeptutor:latest"
ports = ["8001:8001", "3782:3782"]
volumes = [
"/opt/storage/data/deeptutor/config:/app/config:Z",
"/opt/storage/data/deeptutor/user:/app/data/user:Z",
"/opt/storage/data/deeptutor/knowledge_bases:/app/data/knowledge_bases:Z",
]
environment = {
# LLM Configuration (Required - set these in terraform.tfvars)
LLM_BINDING = "openai"
LLM_MODEL = "deepseek/deepseek-v3.2"
LLM_BINDING_API_KEY = "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"
LLM_BINDING_HOST = "https://openrouter.ai/api/v1"
DISABLE_SSL_VERIFY = "false"
# Embedding Configuration (Required for Knowledge Base)
EMBEDDING_BINDING = "openai"
EMBEDDING_MODEL = "text-embedding-3-large"
EMBEDDING_BINDING_API_KEY = "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"
EMBEDDING_BINDING_HOST = "https://openrouter.ai/api/v1"
EMBEDDING_DIM = "3072"
EMBEDDING_MAX_TOKENS = "8192"
# TTS Configuration (Optional)
TTS_MODEL = ""
TTS_API_KEY = ""
TTS_URL = ""
TTS_VOICE = "alloy"
# Web Search Configuration (Optional)
PERPLEXITY_API_KEY = ""
# Logging Configuration
RAG_TOOL_MODULE_LOG_LEVEL = "INFO"
# Service Ports
BACKEND_PORT = "8001"
FRONTEND_PORT = "3782"
# External API URL (for browser access if different from localhost)
NEXT_PUBLIC_API_BASE_EXTERNAL = "https://deeptutor-api.${var.server_domain}"
}
healthcmd = "curl -f http://localhost:8001/ || exit 1"
haproxy_services = [
{
name = "deeptutor-frontend"
domain = "deeptutor.${var.server_domain}"
port = "3782"
host = "127.0.0.1"
tls = false
},
{
name = "deeptutor-api"
domain = "deeptutor-api.${var.server_domain}"
port = "8001"
host = "127.0.0.1"
tls = false
}
]
}
output "app_urls" {
value = module.deeptutor.app_urls
}
output "installed" {
value = true
depends_on = [module.deeptutor.installed]
}

View File

@ -1,59 +0,0 @@
variable "wait_on" {
type = any
description = "Resources to wait on"
default = true
}
variable "server_ip" {
type = string
}
variable "ssh_private_key_path" {
type = string
}
resource "random_password" "username" {
length = 32
special = false
}
resource "random_password" "password" {
length = 32
special = false
}
module "redis" {
source = "../quadlet-app"
wait_on = var.wait_on
server_ip = var.server_ip
ssh_private_key_path = var.ssh_private_key_path
app_name = "documentdb"
image = "ghcr.io/documentdb/documentdb/documentdb-local:latest"
ports = ["27017:27017"]
volumes = ["/opt/storage/data/documentdb:/data:Z"]
environment = {
USERNAME = random_password.username.result
PASSWORD = random_password.password.result
}
}
output "app_urls" {
value = module.redis.app_urls
}
output "installed" {
value = true
depends_on = [module.redis.installed]
}
output "username" {
value = random_password.username.result
sensitive = true
}
output "password" {
value = random_password.password.result
sensitive = true
}

View File

@ -1,117 +0,0 @@
variable "wait_on" {
type = any
description = "Resources to wait on"
default = true
}
variable "server_ip" {
type = string
}
variable "ssh_private_key_path" {
type = string
}
variable "server_domain" {
type = string
}
variable "postgres_password" {
type = string
sensitive = true
}
resource "random_password" "forgejo_secret_key" {
length = 64
special = false
}
resource "random_password" "forgejo_internal_token" {
length = 64
special = false
}
resource "random_password" "forgejo_jwt_secret" {
length = 32
special = false
}
module "psql_db" {
source = "../postgres/tenant"
tenant_name = "forgejo"
admin_username = "postgres"
admin_password = var.postgres_password
server_ip = var.server_ip
ssh_private_key_path = var.ssh_private_key_path
}
module "forgejo" {
source = "../quadlet-app"
wait_on = var.wait_on
server_ip = var.server_ip
ssh_private_key_path = var.ssh_private_key_path
app_name = "forgejo"
image = "codeberg.org/forgejo/forgejo:13.0"
ports = ["3000:3000", "2222:22"]
volumes = [
"/opt/storage/data/forgejo:/data:Z",
"/etc/localtime:/etc/localtime:ro"
]
environment = {
USER_UID = "1000"
USER_GID = "1000"
FORGEJO__database__DB_TYPE = "postgres"
FORGEJO__database__HOST = "systemd-postgres:5432"
FORGEJO__database__NAME = "forgejo"
FORGEJO__database__USER = module.psql_db.username
FORGEJO__database__PASSWD = module.psql_db.password
FORGEJO__server__DOMAIN = "forgejo.${var.server_domain}"
FORGEJO__server__ROOT_URL = "https://forgejo.${var.server_domain}/"
FORGEJO__server__SSH_DOMAIN = "forgejo.${var.server_domain}"
FORGEJO__server__SSH_PORT = "2222"
FORGEJO__security__INSTALL_LOCK = "true"
FORGEJO__security__SECRET_KEY = random_password.forgejo_secret_key.result
FORGEJO__security__INTERNAL_TOKEN = random_password.forgejo_internal_token.result
FORGEJO__oauth2__JWT_SECRET = random_password.forgejo_jwt_secret.result
}
healthcmd = "curl -f http://localhost:3000/api/v1/version || exit 1"
haproxy_services = [
{
name = "forgejo"
domain = "forgejo.${var.server_domain}"
port = "3000"
host = "127.0.0.1"
tls = false
}
]
}
output "app_urls" {
value = module.forgejo.app_urls
}
output "installed" {
value = true
depends_on = [module.forgejo.installed]
}
output "secret_key" {
value = random_password.forgejo_secret_key.result
sensitive = true
}
output "internal_token" {
value = random_password.forgejo_internal_token.result
sensitive = true
}
output "jwt_secret" {
value = random_password.forgejo_jwt_secret.result
sensitive = true
}

View File

@ -1,60 +0,0 @@
variable "wait_on" {
type = any
description = "Resources to wait on"
default = true
}
variable "server_ip" {
type = string
}
variable "ssh_private_key_path" {
type = string
}
variable "server_domain" {
type = string
}
module "gonic" {
source = "../quadlet-app"
wait_on = var.wait_on
server_ip = var.server_ip
ssh_private_key_path = var.ssh_private_key_path
app_name = "gonic"
image = "docker.io/sentriz/gonic:latest"
ports = ["4747:80"]
volumes = [
"/opt/storage/data/gonic:/config:Z",
"/opt/storage/media/music:/music:Z",
"/opt/storage/media/podcasts:/podcasts:Z",
"/opt/storage/media/playlists:/playlists:Z",
"/opt/storage/cache/gonic:/cache:Z",
]
environment = {
TZ="Etc/UTC"
}
haproxy_services = [
{
name = "gonic"
domain = "gonic.${var.server_domain}"
port = "4747"
host = "127.0.0.1"
tls = false
},
]
}
output "app_urls" {
value = module.gonic.app_urls
}
output "installed" {
value = true
depends_on = [module.gonic.installed]
}

View File

@ -16,11 +16,6 @@ variable "server_domain" {
type = string
}
variable "https" {
type = bool
default = false
}
resource "random_password" "minio_access_key" {
length = 20
special = false
@ -50,7 +45,7 @@ module "minio" {
MINIO_ROOT_USER = random_password.minio_access_key.result
MINIO_ROOT_PASSWORD = random_password.minio_secret_key.result
MINIO_CONSOLE_ADDRESS = ":9001"
MINIO_BROWSER_REDIRECT_URL = "${var.https ? "https" : "http" }://storage.${var.server_domain}"
MINIO_BROWSER_REDIRECT_URL = "http://storage.${var.server_domain}"
}
command = ["server", "/data", "--console-address", ":9001"]

View File

@ -185,7 +185,6 @@ resource "minio_iam_user_policy_attachment" "overlay" {
resource "minio_iam_service_account" "overlay" {
depends_on = [minio_iam_user.overlay, minio_s3_bucket.overlay, minio_s3_bucket.uploads]
target_user = minio_iam_user.overlay.name
name = var.name
policy = jsonencode({
Version = "2012-10-17"
Statement = [

View File

@ -1,59 +0,0 @@
variable "wait_on" {
type = any
description = "Resources to wait on"
default = true
}
variable "server_ip" {
type = string
}
variable "ssh_private_key_path" {
type = string
}
variable "server_domain" {
type = string
}
module "mopidy" {
source = "../quadlet-app"
wait_on = var.wait_on
server_ip = var.server_ip
ssh_private_key_path = var.ssh_private_key_path
app_name = "mopidy"
image = "docker.io/ivdata/mopidy"
ports = ["6600:6600", "6680:6680"]
volumes = [
"/opt/storage/data/mopidy:/var/lib/mopidy:Z",
"/opt/storage/data/mopidy/mopidy.conf:/home/mopidy/.config/mopidy/mopidy.conf:Z",
"/opt/storage/media/music:/media/music:ro,Z",
]
# environment = {
# SPOTIFY_CLIENT_ID="ff4a141c-cb52-4569-9848-c0468da66bd1"
# SPOTIFY_CLIENT_SECRET="VVXVtoiY8DanFLl9BnpHvLygXCchboHoEewoALTHIaA="
# }
haproxy_services = [
{
name = "mopidy"
domain = "mopidy.${var.server_domain}"
port = "6680"
host = "127.0.0.1"
tls = false
},
]
}
output "app_urls" {
value = module.mopidy.app_urls
}
output "installed" {
value = true
depends_on = [module.mopidy.installed]
}

View File

@ -1,58 +0,0 @@
variable "wait_on" {
type = any
description = "Resources to wait on"
default = true
}
variable "server_ip" {
type = string
}
variable "ssh_private_key_path" {
type = string
}
variable "server_domain" {
type = string
}
module "navidrome" {
source = "../quadlet-app"
wait_on = var.wait_on
server_ip = var.server_ip
ssh_private_key_path = var.ssh_private_key_path
app_name = "navidrome"
image = "docker.io/deluan/navidrome:latest"
ports = ["4533:4533"]
volumes = [
"/opt/storage/data/navidrome:/data:Z",
"/opt/storage/media/music:/music:ro,Z",
]
environment = {
ND_LOGLEVEL="info"
ND_ENABLEINSIGHTSCOLLECTOR="false"
}
haproxy_services = [
{
name = "navidrome"
domain = "navidrome.${var.server_domain}"
port = "4533"
host = "127.0.0.1"
tls = false
},
]
}
output "app_urls" {
value = module.navidrome.app_urls
}
output "installed" {
value = true
depends_on = [module.navidrome.installed]
}

View File

@ -12,51 +12,25 @@ variable "ssh_private_key_path" {
type = string
}
module "oci-proxy" {
module "redis" {
source = "../quadlet-app"
wait_on = var.wait_on
server_ip = var.server_ip
ssh_private_key_path = var.ssh_private_key_path
app_name = "oci-proxy"
image = "docker.io/tecnativa/docker-socket-proxy"
ports = ["2375:2375"]
volumes = ["/run/user/1001/podman/podman.sock:/var/run/docker.sock:Z"]
environment = {
LOG_LEVEL = "info"
EVENTS = 1
PING = 1
VERSION = 1
AUTH = 0
SECRETS = 0
POST = 1
BUILD = 0
COMMIT = 0
CONFIGS = 0
CONTAINERS = 1
DISTRIBUTION = 0
EXEC = 0
IMAGES = 1
INFO = 1
NETWORKS = 1
NODES = 0
PLUGINS = 0
SERVICES = 1
SESSION = 0
SWARM = 0
SYSTEM = 0
TASKS = 1
VOLUMES = 1
}
app_name = "redis"
image = "docker.io/redis:7-alpine"
ports = ["6379:6379"]
volumes = ["/opt/storage/data/redis:/data:Z"]
command = ["redis-server", "--appendonly", "yes"]
}
output "app_urls" {
value = module.oci-proxy.app_urls
value = module.redis.app_urls
}
output "installed" {
value = true
depends_on = [module.oci-proxy.installed]
depends_on = [module.redis.installed]
}

View File

@ -1,111 +0,0 @@
variable "wait_on" {
type = any
description = "Resources to wait on"
default = true
}
variable "server_ip" {
type = string
}
variable "ssh_private_key_path" {
type = string
}
variable "server_domain" {
type = string
}
# Generate random keys for esign
resource "random_password" "app_id" {
length = 12
special = false
}
resource "random_password" "master_key" {
length = 32
special = true
}
module "esign" {
source = "../quadlet-app"
wait_on = var.wait_on
server_ip = var.server_ip
ssh_private_key_path = var.ssh_private_key_path
app_name = "esign"
image = "docker.io/esign/esignserver:main"
ports = ["8180:8080"]
volumes = ["/opt/storage/data/esign:/usr/src/app/files:Z"]
environment = {
# Node environment
NODE_ENV = "production"
# App identifiers
APP_ID = random_password.app_id.result
REACT_APP_APPID = random_password.app_id.result
appName = "Four Lights - eSign"
MASTER_KEY = random_password.master_key.result
# Frontend config
PUBLIC_URL = "https://esign.${var.server_domain}"
REACT_APP_SERVERURL = "https://esign.${var.server_domain}/api/app"
GENERATE_SOURCEMAP = "false"
# Backend API config
SERVER_URL = "https://esign.${var.server_domain}/api/app"
PARSE_MOUNT = "/app"
# MongoDB connection settings
MONGODB_URI = "mongodb://systemd-documentdb:27017/esign"
# MinIO/S3 storage configuration
DO_SPACE = "esign"
DO_ENDPOINT = "systemd-minio:9000"
DO_BASEURL = "https://esign.${var.server_domain}/minio"
DO_ACCESS_KEY_ID = "esign"
DO_SECRET_ACCESS_KEY = "esign"
DO_REGION = "us-east-1"
# Email config (commented out - configure as needed)
# SMTP_ENABLE = ""
# SMTP_HOST = ""
# SMTP_PORT = ""
# SMTP_USER_EMAIL = ""
# SMTP_PASS = ""
# Document signing (optional)
PFX_BASE64 = ""
PASS_PHRASE = ""
}
haproxy_services = [
{
name = "esign"
domain = "esign.${var.server_domain}"
port = "8180"
host = "127.0.0.1"
tls = false
}
]
}
output "app_urls" {
value = module.esign.app_urls
}
output "app_id" {
value = random_password.app_id.result
sensitive = true
}
output "master_key" {
value = random_password.master_key.result
sensitive = true
}
output "installed" {
value = true
depends_on = [module.esign.installed]
}

View File

@ -1,58 +0,0 @@
variable "wait_on" {
type = any
description = "Resources to wait on"
default = true
}
variable "server_ip" {
type = string
}
variable "ssh_private_key_path" {
type = string
}
variable "server_domain" {
type = string
}
module "admin" {
source = "../../quadlet-app"
wait_on = var.wait_on
server_ip = var.server_ip
ssh_private_key_path = var.ssh_private_key_path
app_name = "plane-admin"
image = "docker.io/makeplane/plane-admin:v1.0.0"
ports = ["3010:3000"]
environment = {
# API settings
NEXT_PUBLIC_API_BASE_URL = "https://plane.${var.server_domain}/api"
# Web settings
NEXT_PUBLIC_WEB_URL = "https://plane.${var.server_domain}/god-mode"
# Environment
NODE_ENV = "production"
}
haproxy_services = [
{
name = "plane-admin"
domain = "plane.${var.server_domain}/god-mode/"
port = "3010"
host = "127.0.0.1"
tls = false
}
]
}
output "app_urls" {
value = module.admin.app_urls
}
output "installed" {
value = true
depends_on = [module.admin.installed]
}

View File

@ -1,182 +0,0 @@
variable "wait_on" {
type = any
description = "Resources to wait on"
default = true
}
variable "server_ip" {
type = string
}
variable "ssh_private_key_path" {
type = string
}
variable "server_domain" {
type = string
}
# Accept credentials from parent Plane module
variable "db_password" {
type = string
sensitive = true
}
variable "redis_password" {
type = string
sensitive = true
}
variable "rabbitmq_password" {
type = string
sensitive = true
}
variable "minio_access_key" {
type = string
sensitive = true
}
variable "minio_secret_key" {
type = string
sensitive = true
}
variable "secret_key" {
type = string
sensitive = true
}
module "api" {
source = "../../quadlet-app"
wait_on = var.wait_on
server_ip = var.server_ip
ssh_private_key_path = var.ssh_private_key_path
restart_policy = "never"
app_name = "plane-api"
image = "docker.io/makeplane/plane-backend:v1.0.0"
ports = ["3011:8000"]
volumes = [
"/opt/storage/data/plane-api:/var/www/plane:Z"
]
environment = {
# Database settings
DATABASE_URL = "postgresql://plane:${var.db_password}@systemd-postgres:5432/plane"
POSTGRES_USER = "plane"
POSTGRES_PASSWORD = var.db_password
POSTGRES_DB = "plane"
# Redis settings
REDIS_URL = "redis://:${var.redis_password}@systemd-valkey:6379/0"
REDIS_HOST = "systemd-valkey"
REDIS_PORT = "6379"
# RabbitMQ settings
RABBITMQ_URL = "amqp://plane:${var.rabbitmq_password}@systemd-rabbitmq:5672/plane"
RABBITMQ_HOST = "systemd-rabbitmq"
RABBITMQ_PORT = "5672"
RABBITMQ_USER = "plane"
RABBITMQ_PASSWORD = var.rabbitmq_password
RABBITMQ_VHOST = "plane"
# MinIO/S3 settings
AWS_S3_ENDPOINT_URL = "http://systemd-minio:9000"
AWS_ACCESS_KEY_ID = var.minio_access_key
AWS_SECRET_ACCESS_KEY = var.minio_secret_key
AWS_S3_BUCKET_NAME = "plane"
AWS_REGION = "us-east-1"
USE_MINIO = "1"
# Application settings
SECRET_KEY = var.secret_key
DEBUG = "0"
ENVIRONMENT = "production"
# Web settings
WEB_URL = "https://plane.${var.server_domain}/api/"
ADMIN_BASE_URL = "https://plane.${var.server_domain}/god-mode/"
SPACE_BASE_URL = "https://plane.${var.server_domain}/spaces/"
APP_BASE_URL = "https://plane.${var.server_domain}/"
LIVE_BASE_URL = "https://plane.${var.server_domain}/live/"
# File upload settings
FILE_SIZE_LIMIT = "5242880"
# Rate limiting
API_KEY_RATE_LIMIT = "60/minute"
# Workers
GUNICORN_WORKERS=3
# Email settings (optional - can be configured later)
# EMAIL_HOST = ""
# EMAIL_PORT = ""
# EMAIL_HOST_USER = ""
# EMAIL_HOST_PASSWORD = ""
# EMAIL_USE_TLS = "1"
# OpenAI/GPT settings (optional)
# OPENAI_API_KEY = ""
# GPT_ENGINE = "gpt-3.5-turbo"
}
command = ["./bin/docker-entrypoint-api.sh"]
haproxy_services = [
{
name = "plane-api"
domain = "plane.${var.server_domain}/api/"
port = "3011"
host = "127.0.0.1"
tls = false
},
{
name = "plane-auth"
domain = "plane.${var.server_domain}/auth/"
port = "3011"
host = "127.0.0.1"
tls = false
}
]
}
output "app_urls" {
value = module.api.app_urls
}
output "installed" {
value = true
depends_on = [module.api.installed]
}
output "db_password" {
value = var.db_password
sensitive = true
}
output "redis_password" {
value = var.redis_password
sensitive = true
}
output "rabbitmq_password" {
value = var.rabbitmq_password
sensitive = true
}
output "minio_access_key" {
value = var.minio_access_key
sensitive = true
}
output "minio_secret_key" {
value = var.minio_secret_key
sensitive = true
}
output "secret_key" {
value = var.secret_key
sensitive = true
}

View File

@ -1,112 +0,0 @@
variable "wait_on" {
type = any
description = "Resources to wait on"
default = true
}
variable "server_ip" {
type = string
}
variable "ssh_private_key_path" {
type = string
}
variable "server_domain" {
type = string
}
variable "db_password" {
type = string
sensitive = true
}
variable "redis_password" {
type = string
sensitive = true
}
variable "rabbitmq_password" {
type = string
sensitive = true
}
variable "minio_access_key" {
type = string
sensitive = true
}
variable "minio_secret_key" {
type = string
sensitive = true
}
variable "secret_key" {
type = string
sensitive = true
}
module "beat-worker" {
source = "../../quadlet-app"
wait_on = var.wait_on
server_ip = var.server_ip
ssh_private_key_path = var.ssh_private_key_path
restart_policy = "never"
app_name = "plane-beat-worker"
image = "docker.io/makeplane/plane-backend:v1.0.0"
volumes = [
"/opt/storage/data/plane-beat-worker:/var/www/plane:Z"
]
environment = {
# Database settings
DATABASE_URL = "postgresql://plane:${var.db_password}@systemd-postgres:5432/plane"
POSTGRES_USER = "plane"
POSTGRES_PASSWORD = var.db_password
POSTGRES_DB = "plane"
# Redis settings
REDIS_URL = "redis://:${var.redis_password}@systemd-valkey:6379/0"
REDIS_HOST = "systemd-valkey"
REDIS_PORT = "6379"
# RabbitMQ settings
RABBITMQ_URL = "amqp://plane:${var.rabbitmq_password}@systemd-rabbitmq:5672/plane"
RABBITMQ_HOST = "systemd-rabbitmq"
RABBITMQ_PORT = "5672"
RABBITMQ_USER = "plane"
RABBITMQ_PASSWORD = var.rabbitmq_password
RABBITMQ_VHOST = "plane"
# MinIO/S3 settings
AWS_S3_ENDPOINT_URL = "http://systemd-minio:9000"
AWS_ACCESS_KEY_ID = var.minio_access_key
AWS_SECRET_ACCESS_KEY = var.minio_secret_key
AWS_S3_BUCKET_NAME = "plane-uploads"
AWS_REGION = "us-east-1"
USE_MINIO = "1"
# Application settings
SECRET_KEY = var.secret_key
DEBUG = "0"
ENVIRONMENT = "production"
# Web settings
WEB_URL = "https://plane.${var.server_domain}"
API_BASE_URL = "https://plane.${var.server_domain}/api"
# File upload settings
FILE_SIZE_LIMIT = "5242880"
# Rate limiting
API_KEY_RATE_LIMIT = "60/minute"
}
command = ["./bin/docker-entrypoint-beat.sh"]
}
output "installed" {
value = true
depends_on = [module.beat-worker.installed]
}

View File

@ -1,60 +0,0 @@
variable "wait_on" {
type = any
description = "Resources to wait on"
default = true
}
variable "server_ip" {
type = string
}
variable "ssh_private_key_path" {
type = string
}
variable "server_domain" {
type = string
}
module "live" {
source = "../../quadlet-app"
wait_on = var.wait_on
server_ip = var.server_ip
ssh_private_key_path = var.ssh_private_key_path
app_name = "plane-live"
image = "docker.io/makeplane/plane-live:v1.0.0"
ports = ["3012:3000"]
environment = {
# API settings
NEXT_PUBLIC_API_BASE_URL = "https://plane.${var.server_domain}/api/"
# Web settings
NEXT_PUBLIC_WEB_URL = "https://plane.${var.server_domain}/live/"
# Environment
NODE_ENV = "production"
}
restart_policy = "never"
haproxy_services = [
{
name = "plane-live"
domain = "plane.${var.server_domain}/live/"
port = "3012"
host = "127.0.0.1"
tls = false
}
]
}
output "app_urls" {
value = module.live.app_urls
}
output "installed" {
value = true
depends_on = [module.live.installed]
}

View File

@ -1,239 +0,0 @@
variable "wait_on" {
type = any
description = "Resources to wait on"
default = true
}
variable "server_ip" {
type = string
}
variable "ssh_private_key_path" {
type = string
}
variable "server_domain" {
type = string
}
# Credentials from existing services
variable "postgres_password" {
type = string
sensitive = true
}
variable "minio_server" {
type = string
sensitive = true
}
variable "minio_access_key" {
type = string
sensitive = true
}
variable "minio_secret_key" {
type = string
sensitive = true
}
variable "rabbitmq_username" {
type = string
sensitive = true
}
variable "rabbitmq_password" {
type = string
sensitive = true
}
# Generate Plane-specific credentials
resource "random_password" "redis_password" {
length = 32
special = false
}
resource "random_password" "secret_key" {
length = 64
special = true
}
module "psql_db" {
source = "../postgres/tenant"
tenant_name = "plane"
admin_username = "postgres"
admin_password = var.postgres_password
server_ip = var.server_ip
ssh_private_key_path = var.ssh_private_key_path
}
module "aqmp_db" {
source = "../rabbitmq/tenant"
tenant_name = "plane"
admin_username = var.rabbitmq_username
admin_password = var.rabbitmq_password
server_ip = var.server_ip
ssh_private_key_path = var.ssh_private_key_path
}
module "minio" {
source = "../minio/tenant"
name = "plane"
server = var.minio_server
access_key = var.minio_access_key
secret_key = var.minio_secret_key
tls = true
}
# Deploy the core API service first
module "api" {
source = "./api"
wait_on = var.wait_on
server_ip = var.server_ip
server_domain = var.server_domain
ssh_private_key_path = var.ssh_private_key_path
# Use credentials from existing services
redis_password = "" #random_password.redis_password.result
db_password = module.psql_db.password
rabbitmq_password = module.aqmp_db.password
minio_access_key = module.minio.access_key
minio_secret_key = module.minio.secret_key
secret_key = random_password.secret_key.result
}
# Run database migrations after API is ready
module "migrator" {
source = "./migrator"
wait_on = module.api.installed
server_ip = var.server_ip
server_domain = var.server_domain
ssh_private_key_path = var.ssh_private_key_path
# Use credentials from API module
db_password = module.api.db_password
redis_password = module.api.redis_password
rabbitmq_password = module.api.rabbitmq_password
minio_access_key = module.minio.access_key
minio_secret_key = module.minio.secret_key
secret_key = module.api.secret_key
}
# Start background workers after migrations
module "worker" {
source = "./worker"
wait_on = module.migrator.installed
server_ip = var.server_ip
server_domain = var.server_domain
ssh_private_key_path = var.ssh_private_key_path
# Use credentials from API module
db_password = module.api.db_password
redis_password = module.api.redis_password
rabbitmq_password = module.api.rabbitmq_password
minio_access_key = module.minio.access_key
minio_secret_key = module.minio.secret_key
secret_key = module.api.secret_key
}
module "beat-worker" {
source = "./beat-worker"
wait_on = module.migrator.installed
server_ip = var.server_ip
server_domain = var.server_domain
ssh_private_key_path = var.ssh_private_key_path
# Use credentials from API module
db_password = module.api.db_password
redis_password = module.api.redis_password
rabbitmq_password = module.api.rabbitmq_password
minio_access_key = module.minio.access_key
minio_secret_key = module.minio.secret_key
secret_key = module.api.secret_key
}
# Start web interfaces after API is ready
module "web" {
source = "./web"
wait_on = module.api.installed
server_ip = var.server_ip
server_domain = var.server_domain
ssh_private_key_path = var.ssh_private_key_path
}
module "admin" {
source = "./admin"
wait_on = module.api.installed
server_ip = var.server_ip
server_domain = var.server_domain
ssh_private_key_path = var.ssh_private_key_path
}
module "space" {
source = "./space"
wait_on = module.api.installed
server_ip = var.server_ip
server_domain = var.server_domain
ssh_private_key_path = var.ssh_private_key_path
}
module "live" {
source = "./live"
wait_on = module.api.installed
server_ip = var.server_ip
server_domain = var.server_domain
ssh_private_key_path = var.ssh_private_key_path
}
# Aggregate all app URLs
locals {
app_urls = {
web = module.web.app_urls
admin = module.admin.app_urls
space = module.space.app_urls
live = module.live.app_urls
api = module.api.app_urls
}
}
output "app_urls" {
value = local.app_urls
description = "All Plane application URLs"
}
output "installed" {
value = true
depends_on = [
module.api.installed,
module.migrator.installed,
module.worker.installed,
module.beat-worker.installed,
module.web.installed,
module.admin.installed,
module.space.installed,
module.live.installed
]
description = "Whether all Plane services are installed"
}
output "credentials" {
value = {
db_password = module.api.db_password
redis_password = module.api.redis_password
minio_access_key = module.api.minio_access_key
minio_secret_key = module.api.minio_secret_key
rabbitmq_password = module.api.rabbitmq_password
secret_key = module.api.secret_key
}
sensitive = true
description = "All Plane service credentials"
}
output "main_url" {
value = "https://plane.${var.server_domain}"
description = "Main Plane application URL"
}

View File

@ -1,112 +0,0 @@
variable "wait_on" {
type = any
description = "Resources to wait on"
default = true
}
variable "server_ip" {
type = string
}
variable "ssh_private_key_path" {
type = string
}
variable "server_domain" {
type = string
}
variable "db_password" {
type = string
sensitive = true
}
variable "redis_password" {
type = string
sensitive = true
}
variable "rabbitmq_password" {
type = string
sensitive = true
}
variable "minio_access_key" {
type = string
sensitive = true
}
variable "minio_secret_key" {
type = string
sensitive = true
}
variable "secret_key" {
type = string
sensitive = true
}
module "migrator" {
source = "../../quadlet-app"
wait_on = var.wait_on
server_ip = var.server_ip
ssh_private_key_path = var.ssh_private_key_path
restart_policy = "never"
app_name = "plane-migrator"
image = "docker.io/makeplane/plane-backend:v1.0.0"
volumes = [
"/opt/storage/data/plane-migrator:/var/www/plane:Z"
]
environment = {
# Database settings
DATABASE_URL = "postgresql://plane:${var.db_password}@systemd-postgres:5432/plane"
POSTGRES_USER = "plane"
POSTGRES_PASSWORD = var.db_password
POSTGRES_DB = "plane"
# Redis settings
REDIS_URL = "redis://:${var.redis_password}@systemd-valkey:6379/0"
REDIS_HOST = "systemd-valkey"
REDIS_PORT = "6379"
# RabbitMQ settings
RABBITMQ_URL = "amqp://plane:${var.rabbitmq_password}@systemd-rabbitmq:5672/plane"
RABBITMQ_HOST = "systemd-rabbitmq"
RABBITMQ_PORT = "5672"
RABBITMQ_USER = "plane"
RABBITMQ_PASSWORD = var.rabbitmq_password
RABBITMQ_VHOST = "plane"
# MinIO/S3 settings
AWS_S3_ENDPOINT_URL = "http://systemd-minio:9000"
AWS_ACCESS_KEY_ID = var.minio_access_key
AWS_SECRET_ACCESS_KEY = var.minio_secret_key
AWS_S3_BUCKET_NAME = "plane-uploads"
AWS_REGION = "us-east-1"
USE_MINIO = "1"
# Application settings
SECRET_KEY = var.secret_key
DEBUG = "0"
ENVIRONMENT = "production"
# Web settings
WEB_URL = "https://plane.${var.server_domain}"
API_BASE_URL = "https://plane.${var.server_domain}/api"
# File upload settings
FILE_SIZE_LIMIT = "5242880"
# Rate limiting
API_KEY_RATE_LIMIT = "60/minute"
}
command = ["./bin/docker-entrypoint-migrator.sh"]
}
output "installed" {
value = true
depends_on = [module.migrator.installed]
}

View File

@ -1,60 +0,0 @@
variable "wait_on" {
type = any
description = "Resources to wait on"
default = true
}
variable "server_ip" {
type = string
}
variable "ssh_private_key_path" {
type = string
}
variable "server_domain" {
type = string
}
module "space" {
source = "../../quadlet-app"
wait_on = var.wait_on
server_ip = var.server_ip
ssh_private_key_path = var.ssh_private_key_path
restart_policy = "never"
app_name = "plane-space"
image = "docker.io/makeplane/plane-space:v1.0.0"
ports = ["3013:3000"]
environment = {
# API settings
NEXT_PUBLIC_API_BASE_URL = "https://plane.${var.server_domain}/api"
# Web settings
NEXT_PUBLIC_WEB_URL = "https://plane.${var.server_domain}/spaces"
# Environment
NODE_ENV = "production"
}
haproxy_services = [
{
name = "plane-space"
domain = "plane.${var.server_domain}/spaces/"
port = "3013"
host = "127.0.0.1"
tls = false
}
]
}
output "app_urls" {
value = module.space.app_urls
}
output "installed" {
value = true
depends_on = [module.space.installed]
}

View File

@ -1,64 +0,0 @@
variable "wait_on" {
type = any
description = "Resources to wait on"
default = true
}
variable "server_ip" {
type = string
}
variable "ssh_private_key_path" {
type = string
}
variable "server_domain" {
type = string
}
module "web" {
source = "../../quadlet-app"
wait_on = var.wait_on
server_ip = var.server_ip
ssh_private_key_path = var.ssh_private_key_path
restart_policy = "never"
app_name = "plane-web"
image = "docker.io/makeplane/plane-frontend:v1.0.0"
ports = ["3014:3000"]
environment = {
# API settings
NEXT_PUBLIC_API_BASE_URL = "https://plane.${var.server_domain}/api"
# Web settings
NEXT_PUBLIC_WEB_BASE_URL = "https://plane.${var.server_domain}"
NEXT_PUBLIC_ADMIN_BASE_URL = "https://plane.${var.server_domain}/god-mode"
NEXT_PUBLIC_SPACE_BASE_URL = "https://plane.${var.server_domain}/spaces"
NEXT_PUBLIC_LIVE_BASE_URL = "https://plane.${var.server_domain}/live"
# Environment
NODE_ENV = "production"
}
haproxy_services = [
{
name = "plane-web"
domain = "plane.${var.server_domain}"
port = "3014"
host = "127.0.0.1"
tls = false
}
]
}
output "app_urls" {
value = module.web.app_urls
}
output "installed" {
value = true
depends_on = [module.web.installed]
}

View File

@ -1,113 +0,0 @@
variable "wait_on" {
type = any
description = "Resources to wait on"
default = true
}
variable "server_ip" {
type = string
}
variable "ssh_private_key_path" {
type = string
}
variable "server_domain" {
type = string
}
variable "db_password" {
type = string
sensitive = true
}
variable "redis_password" {
type = string
sensitive = true
}
variable "rabbitmq_password" {
type = string
sensitive = true
}
variable "minio_access_key" {
type = string
sensitive = true
}
variable "minio_secret_key" {
type = string
sensitive = true
}
variable "secret_key" {
type = string
sensitive = true
}
module "worker" {
source = "../../quadlet-app"
wait_on = var.wait_on
server_ip = var.server_ip
ssh_private_key_path = var.ssh_private_key_path
restart_policy = "never"
app_name = "plane-worker"
image = "docker.io/makeplane/plane-backend:v1.0.0"
volumes = [
"/opt/storage/data/plane-worker:/var/www/plane:Z"
]
environment = {
# Database settings
DATABASE_URL = "postgresql://plane:${var.db_password}@systemd-postgres:5432/plane"
POSTGRES_USER = "plane"
POSTGRES_PASSWORD = var.db_password
POSTGRES_DB = "plane"
PGDATA = "/var/lib/postgresql/data"
# Redis settings
REDIS_URL = "redis://:${var.redis_password}@systemd-valkey:6379/0"
REDIS_HOST = "systemd-valkey"
REDIS_PORT = "6379"
# RabbitMQ settings
RABBITMQ_URL = "amqp://plane:${var.rabbitmq_password}@systemd-rabbitmq:5672/plane"
RABBITMQ_HOST = "systemd-rabbitmq"
RABBITMQ_PORT = "5672"
RABBITMQ_USER = "plane"
RABBITMQ_PASSWORD = var.rabbitmq_password
RABBITMQ_VHOST = "plane"
# MinIO/S3 settings
AWS_S3_ENDPOINT_URL = "http://systemd-minio:9000"
AWS_ACCESS_KEY_ID = var.minio_access_key
AWS_SECRET_ACCESS_KEY = var.minio_secret_key
AWS_S3_BUCKET_NAME = "plane-uploads"
AWS_REGION = "us-east-1"
USE_MINIO = "1"
# Application settings
SECRET_KEY = var.secret_key
DEBUG = "0"
ENVIRONMENT = "production"
# Web settings
WEB_URL = "https://plane.${var.server_domain}"
API_BASE_URL = "https://plane.${var.server_domain}/api"
# File upload settings
FILE_SIZE_LIMIT = "5242880"
# Rate limiting
API_KEY_RATE_LIMIT = "60/minute"
}
command = ["./bin/docker-entrypoint-worker.sh"]
}
output "installed" {
value = true
depends_on = [module.worker.installed]
}

View File

@ -1,48 +0,0 @@
variable "wait_on" {
type = any
description = "Resources to wait on"
default = true
}
variable "server_ip" {
type = string
}
variable "ssh_private_key_path" {
type = string
}
resource "random_password" "password" {
length = 32
special = false
}
module "postgres" {
source = "../quadlet-app"
wait_on = var.wait_on
server_ip = var.server_ip
ssh_private_key_path = var.ssh_private_key_path
app_name = "postgres"
image = "docker.io/postgres:17"
ports = ["5432:5432"]
volumes = ["/opt/storage/data/postgres:/var/lib/postgresql/data:Z"]
environment = {
POSTGRES_PASSWORD = random_password.password.result
}
}
output "app_urls" {
value = module.postgres.app_urls
}
output "installed" {
value = true
depends_on = [module.postgres.installed]
}
output "password" {
value = random_password.password.result
sensitive = true
}

View File

@ -1,132 +0,0 @@
# PostgreSQL Tenant Module
This module creates a PostgreSQL database and user (tenant) on a VPS-hosted PostgreSQL instance using SSH tunneling for secure access.
## Features
- Creates SSH tunnel to VPS-hosted PostgreSQL
- Creates isolated database and user for tenant
- Generates secure random password
- Outputs connection details for application use
- Supports explicit dependencies via `wait_on`
## Requirements
- SSH access to VPS with key-based authentication
- PostgreSQL admin credentials
- `terraform-ssh-tunnel` module (automatically downloaded)
- `cyrilgdn/postgresql` provider (automatically configured)
## Usage
### Basic Example
```hcl
module "app_tenant" {
source = "./quadlets/modules/postgres/tenant"
tenant_name = "myapp"
admin_username = "postgres"
admin_password = var.postgres_admin_password
server_ip = "203.0.113.10"
ssh_user = "fourlights"
# Wait for PostgreSQL service to be ready
wait_on = module.postgres.installed
}
# Use the connection details
output "app_db_connection" {
value = {
host = module.app_tenant.host
port = module.app_tenant.port
database = module.app_tenant.database
username = module.app_tenant.username
password = module.app_tenant.password
}
sensitive = true
}
```
### With Custom Port
```hcl
module "custom_tenant" {
source = "./quadlets/modules/postgres/tenant"
tenant_name = "custom_app"
admin_username = "postgres"
admin_password = var.postgres_admin_password
server_ip = "203.0.113.10"
target_port = 5433 # Custom PostgreSQL port
wait_on = module.postgres.installed
}
```
### Multiple Tenants
```hcl
module "tenant_1" {
source = "./quadlets/modules/postgres/tenant"
tenant_name = "app1"
admin_username = "postgres"
admin_password = var.postgres_admin_password
server_ip = var.vps_ip
wait_on = module.postgres.installed
}
module "tenant_2" {
source = "./quadlets/modules/postgres/tenant"
tenant_name = "app2"
admin_username = "postgres"
admin_password = var.postgres_admin_password
server_ip = var.vps_ip
wait_on = module.postgres.installed
}
```
## Inputs
| Name | Description | Type | Default | Required |
|------|-------------|------|---------|----------|
| tenant_name | Name of the tenant (used for database name and username) | string | - | yes |
| admin_username | PostgreSQL admin username | string | "postgres" | no |
| admin_password | PostgreSQL admin password | string | - | yes |
| server_ip | VPS server IP address | string | - | yes |
| ssh_user | SSH user for connecting to VPS | string | "fourlights" | no |
| ssh_private_key_path | Path to SSH private key | string | "~/.ssh/id_rsa" | no |
| target_port | PostgreSQL port on VPS | number | 5432 | no |
| wait_on | Resources to wait on before creating tenant | any | null | no |
## Outputs
| Name | Description | Sensitive |
|------|-------------|-----------|
| host | PostgreSQL connection host (via SSH tunnel) | no |
| port | PostgreSQL connection port (via SSH tunnel) | no |
| database | Database name | no |
| username | Database username | no |
| password | Database password | yes |
| connection_string | Full PostgreSQL connection string | yes |
| installed | Installation complete flag | no |
## How It Works
1. **SSH Tunnel**: The module uses `terraform-ssh-tunnel` to create a secure SSH tunnel from your local machine to the VPS PostgreSQL instance
2. **Provider Configuration**: The PostgreSQL provider is configured to connect through the tunnel
3. **Resource Creation**: Creates a PostgreSQL role (user) and database with appropriate permissions
4. **Password Generation**: Generates a cryptographically secure random password
5. **Outputs**: Provides connection details for your application to use
## Notes
- The SSH tunnel is created dynamically when Terraform needs to connect
- Ensure your SSH agent is running and has the appropriate key loaded
- The database and user will have the same name as `tenant_name`
- The user is granted full privileges on their database
- Password is stored in Terraform state (sensitive)

View File

@ -1,81 +0,0 @@
terraform {
required_providers {
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
postgresql = {
source = "cyrilgdn/postgresql"
version = "~> 1.22"
}
}
}
# Generate random password for the tenant user
resource "random_password" "tenant_password" {
length = 32
special = false
min_upper = 2
min_lower = 2
min_numeric = 2
}
# Create SSH tunnel to PostgreSQL on VPS
module "db_tunnel" {
source = "flaupretre/tunnel/ssh"
version = "2.3.0"
target_host = "127.0.0.1"
target_port = var.target_port
gateway_host = var.server_ip
gateway_user = var.ssh_user
gateway_port = 22
}
provider postgresql {
alias = "tunnel"
host = module.db_tunnel.host
port = module.db_tunnel.port
username = var.admin_username
password = var.admin_password
database = "postgres"
sslmode = "disable"
superuser = false
}
#---- Database
resource postgresql_database db {
provider = postgresql.tunnel
name = var.tenant_name
owner = var.tenant_name
encoding = "UTF8"
lc_collate = "C"
lc_ctype = "C"
}
#---- DB user/role
resource postgresql_role role {
provider = postgresql.tunnel
name = var.tenant_name
login = true
password = random_password.tenant_password.result
}
#---- Grant
# SQL: grant all on <db>.* to <user>@'%';
resource postgresql_grant grant {
provider = postgresql.tunnel
object_type = "database"
database = var.tenant_name
role = var.tenant_name
privileges = ["ALL"]
}

View File

@ -1,27 +0,0 @@
output "database" {
description = "Database name"
value = var.tenant_name
}
output "username" {
description = "Database username"
value = var.tenant_name
}
output "password" {
description = "Database password"
value = random_password.tenant_password.result
sensitive = true
}
output "connection_string" {
description = "PostgreSQL connection string"
value = "postgresql://${var.tenant_name}:${random_password.tenant_password.result}@localhost:${var.target_port}/${var.tenant_name}"
sensitive = true
}
output "installed" {
description = "Installation complete flag"
value = true
depends_on = [postgresql_role.role, postgresql_grant.grant, postgresql_database.db, random_password.tenant_password]
}

View File

@ -1,44 +0,0 @@
variable "tenant_name" {
description = "Name of the tenant (used for database name and username)"
type = string
}
variable "admin_username" {
description = "PostgreSQL admin username"
type = string
default = "postgres"
}
variable "admin_password" {
description = "PostgreSQL admin password"
type = string
sensitive = true
}
variable "server_ip" {
description = "VPS server IP address"
type = string
}
variable "ssh_user" {
description = "SSH user for connecting to VPS"
type = string
default = "fourlights"
}
variable "ssh_private_key_path" {
description = "Path to SSH private key for authentication"
type = string
}
variable "target_port" {
description = "PostgreSQL port on VPS"
type = number
default = 5432
}
variable "wait_on" {
description = "Resources to wait on before creating tenant"
type = any
default = true
}

View File

@ -1,63 +0,0 @@
variable "wait_on" {
type = any
description = "Resources to wait on"
default = true
}
variable "server_ip" {
type = string
}
variable "ssh_private_key_path" {
type = string
}
variable "server_domain" {}
resource "random_password" "api_key" {
length = 64
special = false
}
module "qdrant" {
source = "../quadlet-app"
wait_on = var.wait_on
server_ip = var.server_ip
ssh_private_key_path = var.ssh_private_key_path
app_name = "qdrant"
image = "docker.io/qdrant/qdrant"
ports = ["6333:6333", "6334:6334"]
volumes = ["/opt/storage/data/qdrant:/qdrant/storage:z"]
environment = {
"QDRANT__SERVICE__API_KEY" = random_password.api_key.result
}
haproxy_services = [
{
name = "qdrant"
domain = "qdrant.${var.server_domain}"
port = "6333"
host = "127.0.0.1"
tls = false
#grpc_port = "6334"
},
]
}
output "app_urls" {
value = module.qdrant.app_urls
}
output "installed" {
value = true
depends_on = [module.qdrant.installed]
}
output "api_key" {
value = random_password.api_key.result
sensitive = true
}

View File

@ -57,7 +57,6 @@ variable "haproxy_services" {
port = string
host = optional(string, "127.0.0.1")
tls = optional(bool, false)
grpc_port = optional(string, "")
}))
default = []
}
@ -86,8 +85,7 @@ locals {
"Label=haproxy.${svc.name}.domain=${svc.domain}",
"Label=haproxy.${svc.name}.port=${svc.port}",
"Label=haproxy.${svc.name}.host=${svc.host}",
"Label=haproxy.${svc.name}.tls=${svc.tls}",
#"Label=haproxy.${svc.name}.grpc_port=${svc.grpc_port}"
"Label=haproxy.${svc.name}.tls=${svc.tls}"
]
])
}
@ -111,8 +109,11 @@ resource "null_resource" "deploy_quadlet_app" {
provisioner "remote-exec" {
inline = compact(flatten([
[
# Wait for cloud-init to complete before proceeding
"cloud-init status --wait || true",
# Verify the user systemd session is ready and linger is enabled
#"timeout 60 bash -c 'until loginctl show-user fourlights | grep -q \"Linger=yes\"; do sleep 2; done'",
"timeout 60 bash -c 'until loginctl show-user fourlights | grep -q \"Linger=yes\"; do sleep 2; done'",
# Create base quadlet file
"cat > /tmp/${var.app_name}.container << 'EOF'",
@ -122,8 +123,6 @@ resource "null_resource" "deploy_quadlet_app" {
"",
"[Container]",
"Image=${var.image}",
"AutoUpdate=registry",
"Network=containers.network"
],
# Add ports (only if not empty)
@ -140,7 +139,6 @@ resource "null_resource" "deploy_quadlet_app" {
# Add pre-computed HAProxy labels (only if not empty)
length(local.haproxy_labels) > 0 ? local.haproxy_labels : [],
# Add health checks if not empty
var.healthcmd != "" ? ["HealthCmd=${var.healthcmd}"] : [],

View File

@ -0,0 +1,36 @@
variable "wait_on" {
type = any
description = "Resources to wait on"
default = true
}
variable "server_ip" {
type = string
}
variable "ssh_private_key_path" {
type = string
}
module "redis" {
source = "../quadlet-app"
wait_on = var.wait_on
server_ip = var.server_ip
ssh_private_key_path = var.ssh_private_key_path
app_name = "redis"
image = "docker.io/redis:7-alpine"
ports = ["6379:6379"]
volumes = ["/opt/storage/data/redis:/data:Z"]
command = ["redis-server", "--appendonly", "yes"]
}
output "app_urls" {
value = module.redis.app_urls
}
output "installed" {
value = true
depends_on = [module.redis.installed]
}

View File

@ -1,60 +0,0 @@
variable "wait_on" {
type = any
description = "Resources to wait on"
default = true
}
variable "server_ip" {
type = string
}
variable "ssh_private_key_path" {
type = string
}
resource "random_password" "username" {
length = 32
special = false
}
resource "random_password" "password" {
length = 32
special = false
}
module "rabbitmq" {
source = "../quadlet-app"
wait_on = var.wait_on
server_ip = var.server_ip
ssh_private_key_path = var.ssh_private_key_path
app_name = "rabbitmq"
image = "docker.io/rabbitmq:management"
ports = ["5672:5672", "15672:15672"]
volumes = ["/opt/storage/data/rabbitmq:/var/lib/rabbitmq:Z"]
environment = {
RABBITMQ_DEFAULT_USER = random_password.username.result
RABBITMQ_DEFAULT_PASS = random_password.password.result
}
}
output "app_urls" {
value = module.rabbitmq.app_urls
}
output "installed" {
value = true
depends_on = [module.rabbitmq.installed]
}
output "username" {
value = random_password.username.result
sensitive = true
}
output "password" {
value = random_password.password.result
sensitive = true
}

View File

@ -1,180 +0,0 @@
# RabbitMQ Tenant Module
This module creates a RabbitMQ virtual host (vhost) and user (tenant) on a VPS-hosted RabbitMQ instance using SSH tunneling for secure access to the Management API.
## Features
- Creates SSH tunnel to VPS-hosted RabbitMQ Management API
- Creates isolated virtual host and user for tenant
- Configures full permissions for user on their vhost
- Generates secure random password
- Outputs connection details for application use
- Supports explicit dependencies via `wait_on`
## Requirements
- SSH access to VPS with key-based authentication
- RabbitMQ admin credentials
- RabbitMQ Management plugin enabled
- `terraform-ssh-tunnel` module (automatically downloaded)
- `cyrilgdn/rabbitmq` provider (automatically configured)
## Usage
### Basic Example
```hcl
module "app_tenant" {
source = "./quadlets/modules/rabbitmq/tenant"
tenant_name = "myapp"
admin_username = var.rabbitmq_admin_username
admin_password = var.rabbitmq_admin_password
server_ip = "203.0.113.10"
ssh_user = "fourlights"
# Wait for RabbitMQ service to be ready
wait_on = module.rabbitmq.installed
}
# Use the connection details
output "app_mq_connection" {
value = {
host = module.app_tenant.host
port = module.app_tenant.port
vhost = module.app_tenant.vhost
username = module.app_tenant.username
password = module.app_tenant.password
}
sensitive = true
}
```
### With Custom Management Port
```hcl
module "custom_tenant" {
source = "./quadlets/modules/rabbitmq/tenant"
tenant_name = "custom_app"
admin_username = var.rabbitmq_admin_username
admin_password = var.rabbitmq_admin_password
server_ip = "203.0.113.10"
target_port = 25672 # Custom Management API port
wait_on = module.rabbitmq.installed
}
```
### Multiple Tenants
```hcl
module "tenant_1" {
source = "./quadlets/modules/rabbitmq/tenant"
tenant_name = "app1"
admin_username = var.rabbitmq_admin_username
admin_password = var.rabbitmq_admin_password
server_ip = var.vps_ip
wait_on = module.rabbitmq.installed
}
module "tenant_2" {
source = "./quadlets/modules/rabbitmq/tenant"
tenant_name = "app2"
admin_username = var.rabbitmq_admin_username
admin_password = var.rabbitmq_admin_password
server_ip = var.vps_ip
wait_on = module.rabbitmq.installed
}
```
### Using Connection String
```hcl
module "app_tenant" {
source = "./quadlets/modules/rabbitmq/tenant"
tenant_name = "myapp"
admin_username = var.rabbitmq_admin_username
admin_password = var.rabbitmq_admin_password
server_ip = var.vps_ip
wait_on = module.rabbitmq.installed
}
# Use in application configuration
resource "kubernetes_secret" "rabbitmq_credentials" {
metadata {
name = "rabbitmq-connection"
}
data = {
connection_string = module.app_tenant.connection_string
}
}
```
## Inputs
| Name | Description | Type | Default | Required |
|------|-------------|------|---------|----------|
| tenant_name | Name of the tenant (used for vhost name and username) | string | - | yes |
| admin_username | RabbitMQ admin username | string | - | yes |
| admin_password | RabbitMQ admin password | string | - | yes |
| server_ip | VPS server IP address | string | - | yes |
| ssh_user | SSH user for connecting to VPS | string | "fourlights" | no |
| ssh_private_key_path | Path to SSH private key | string | "~/.ssh/id_rsa" | no |
| target_port | RabbitMQ Management API port on VPS | number | 15672 | no |
| wait_on | Resources to wait on before creating tenant | any | null | no |
## Outputs
| Name | Description | Sensitive |
|------|-------------|-----------|
| host | RabbitMQ connection host (VPS IP for AMQP) | no |
| port | RabbitMQ AMQP connection port (5672) | no |
| management_host | RabbitMQ Management API host (via SSH tunnel) | no |
| management_port | RabbitMQ Management API port (via SSH tunnel) | no |
| vhost | Virtual host name | no |
| username | RabbitMQ username | no |
| password | RabbitMQ password | yes |
| connection_string | Full AMQP connection string | yes |
| installed | Installation complete flag | no |
## How It Works
1. **SSH Tunnel**: The module uses `terraform-ssh-tunnel` to create a secure SSH tunnel from your local machine to the VPS RabbitMQ Management API (port 15672)
2. **Provider Configuration**: The RabbitMQ provider is configured to connect through the tunnel
3. **Resource Creation**: Creates a RabbitMQ virtual host, user, and permissions
4. **Password Generation**: Generates a cryptographically secure random password
5. **Outputs**: Provides connection details for your application to use
## Connection Details
- **Management API**: Accessed via SSH tunnel (for Terraform operations)
- **AMQP Protocol**: Direct connection to VPS on port 5672 (for application connections)
- **Virtual Host**: Isolated messaging environment for the tenant
- **Permissions**: User has full configure, write, and read permissions on their vhost
## Notes
- The SSH tunnel is only used for management operations (creating vhost/user/permissions)
- Applications connect directly to the VPS AMQP port (5672), not through the tunnel
- The virtual host and user will have the same name as `tenant_name`
- User is granted `configure=".*"`, `write=".*"`, `read=".*"` on their vhost
- Password is stored in Terraform state (sensitive)
- Ensure RabbitMQ Management plugin is enabled on the VPS instance
## Permissions Explained
The tenant user receives the following permissions on their vhost:
- **configure**: Can declare exchanges, queues, and bindings
- **write**: Can publish messages
- **read**: Can consume messages
All permissions use the `.*` regex pattern, granting full access within the vhost.

View File

@ -1,71 +0,0 @@
terraform {
required_providers {
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
rabbitmq = {
source = "cyrilgdn/rabbitmq"
version = "~> 1.8"
}
}
}
# Generate random password for the tenant user
resource "random_password" "tenant_password" {
length = 32
special = false
min_upper = 2
min_lower = 2
min_numeric = 2
}
# Create SSH tunnel to RabbitMQ Management API on VPS
module "ssh_tunnel" {
source = "flaupretre/tunnel/ssh"
version = "2.2.1"
target_host = "127.0.0.1"
target_port = var.target_port
gateway_host = var.server_ip
gateway_user = var.ssh_user
# Tunnel will be created when needed
timeout = "30s"
}
# Configure RabbitMQ provider to use the SSH tunnel
provider "rabbitmq" {
endpoint = "http://${module.ssh_tunnel.host}:${module.ssh_tunnel.port}"
username = var.admin_username
password = var.admin_password
}
# Create the virtual host
resource "rabbitmq_vhost" "tenant_vhost" {
depends_on = [var.wait_on]
name = var.tenant_name
}
# Create the user
resource "rabbitmq_user" "tenant_user" {
depends_on = [var.wait_on]
name = var.tenant_name
password = random_password.tenant_password.result
tags = []
}
# Grant permissions to the user on the vhost
resource "rabbitmq_permissions" "tenant_permissions" {
user = rabbitmq_user.tenant_user.name
vhost = rabbitmq_vhost.tenant_vhost.name
permissions {
configure = ".*"
write = ".*"
read = ".*"
}
}

View File

@ -1,47 +0,0 @@
output "host" {
description = "RabbitMQ connection host (via SSH tunnel)"
value = var.server_ip
}
output "port" {
description = "RabbitMQ AMQP connection port"
value = 5672
}
output "management_host" {
description = "RabbitMQ Management API host (via SSH tunnel)"
value = module.ssh_tunnel.host
}
output "management_port" {
description = "RabbitMQ Management API port (via SSH tunnel)"
value = module.ssh_tunnel.port
}
output "vhost" {
description = "Virtual host name"
value = rabbitmq_vhost.tenant_vhost.name
}
output "username" {
description = "RabbitMQ username"
value = rabbitmq_user.tenant_user.name
}
output "password" {
description = "RabbitMQ password"
value = random_password.tenant_password.result
sensitive = true
}
output "connection_string" {
description = "RabbitMQ AMQP connection string"
value = "amqp://${rabbitmq_user.tenant_user.name}:${random_password.tenant_password.result}@${var.server_ip}:5672/${rabbitmq_vhost.tenant_vhost.name}"
sensitive = true
}
output "installed" {
description = "Installation complete flag"
value = true
depends_on = [rabbitmq_vhost.tenant_vhost, rabbitmq_user.tenant_user, rabbitmq_permissions.tenant_permissions]
}

View File

@ -1,44 +0,0 @@
variable "tenant_name" {
description = "Name of the tenant (used for vhost name and username)"
type = string
}
variable "admin_username" {
description = "RabbitMQ admin username"
type = string
}
variable "admin_password" {
description = "RabbitMQ admin password"
type = string
sensitive = true
}
variable "server_ip" {
description = "VPS server IP address"
type = string
}
variable "ssh_user" {
description = "SSH user for connecting to VPS"
type = string
default = "fourlights"
}
variable "ssh_private_key_path" {
description = "Path to SSH private key for authentication"
type = string
default = "~/.ssh/id_rsa"
}
variable "target_port" {
description = "RabbitMQ Management API port on VPS"
type = number
default = 15672
}
variable "wait_on" {
description = "Resources to wait on before creating tenant"
type = any
default = null
}

View File

@ -1,97 +0,0 @@
variable "wait_on" {
type = any
description = "Resources to wait on"
default = true
}
variable "server_ip" {
type = string
}
variable "ssh_private_key_path" {
type = string
}
variable "server_domain" {
type = string
}
variable "server_url" {
type = string
default = "https://mail.binarysunset.dev"
}
# tmail-web reads its configuration from a file at
# /usr/share/nginx/html/assets/env.file inside the container. Setting
# SERVER_URL as a container environment variable does NOT work (see
# https://github.com/linagora/tmail-flutter/issues/4240); the file must
# be injected. We write it to the host and bind-mount it into the
# container.
locals {
env_file_host_path = "/opt/storage/data/tmail-web/env.file"
env_file_container_path = "/usr/share/nginx/html/assets/env.file"
env_file_content = "SERVER_URL=${var.server_url}\nDOMAIN_REDIRECT_URL=https://mail.${var.server_domain}\n"
}
resource "null_resource" "tmail_env_file" {
triggers = {
content = local.env_file_content
server_ip = var.server_ip
host_path = local.env_file_host_path
}
provisioner "remote-exec" {
inline = [
"mkdir -p $(dirname ${local.env_file_host_path})",
"cat > ${local.env_file_host_path} << 'EOF'",
local.env_file_content,
"EOF",
]
connection {
type = "ssh"
host = var.server_ip
user = "fourlights"
agent = true
agent_identity = var.ssh_private_key_path
}
}
}
module "tmail-web" {
source = "../quadlet-app"
wait_on = var.wait_on
server_ip = var.server_ip
ssh_private_key_path = var.ssh_private_key_path
app_name = "tmail-web"
image = "docker.io/linagora/tmail-web:latest"
ports = ["8080:80"]
volumes = ["${local.env_file_host_path}:${local.env_file_container_path}:ro"]
environment = {}
haproxy_services = [
{
name = "tmail-web"
domain = "mail.${var.server_domain}"
port = "8080"
host = "127.0.0.1"
tls = false
}
]
healthcmd = "curl -f http://localhost:80 || exit 1"
depends_on = [null_resource.tmail_env_file]
}
output "app_urls" {
value = module.tmail-web.app_urls
}
output "installed" {
value = true
depends_on = [module.tmail-web.installed]
}

View File

@ -1,58 +0,0 @@
variable "wait_on" {
type = any
description = "Resources to wait on"
default = true
}
variable "server_ip" {
type = string
}
variable "ssh_private_key_path" {
type = string
}
variable "server_domain" {
type = string
}
module "zot" {
source = "../quadlet-app"
wait_on = var.wait_on
server_ip = var.server_ip
ssh_private_key_path = var.ssh_private_key_path
app_name = "zot"
image = "ghcr.io/project-zot/zot:latest"
ports = ["5000:5000"]
volumes = [
"/opt/storage/data/zot/config.json:/etc/zot/config.json:Z",
"/opt/storage/data/zot:/var/lib/registry:Z",
]
environment = {
ND_LOGLEVEL="info"
ND_ENABLEINSIGHTSCOLLECTOR="false"
}
haproxy_services = [
{
name = "registry"
domain = "registry.${var.server_domain}"
port = "5000"
host = "127.0.0.1"
tls = false
},
]
}
output "app_urls" {
value = module.zot.app_urls
}
output "installed" {
value = true
depends_on = [module.zot.installed]
}

View File

@ -1,94 +0,0 @@
variable "wait_on" {
type = any
description = "Resources to wait on"
default = true
}
variable "server_ip" {
description = "Target server IP"
type = string
}
variable "ssh_private_key_path" {
description = "Path to SSH private key"
type = string
default = "~/.ssh/id_rsa"
}
variable "name" {
description = "Name of the network"
type = string
}
resource "null_resource" "deploy_network" {
depends_on = [var.wait_on]
triggers = {
name = var.name
server_ip = var.server_ip
ssh_private_key_path = var.ssh_private_key_path
}
provisioner "remote-exec" {
inline = compact(flatten([
[
# Create base quadlet file
"cat > /tmp/${var.name}.network << 'EOF'",
"[Network]",
"Description=${var.name} network",
"",
"[Install]",
"WantedBy=default.target",
"EOF",
"test -d ~/.config/containers/systemd || mkdir -p ~/.config/containers/systemd",
"cp /tmp/${var.name}.network ~/.config/containers/systemd/${var.name}.network",
"systemctl --user daemon-reload",
]
]))
connection {
type = "ssh"
host = var.server_ip
user = "fourlights"
agent = true
agent_identity = var.ssh_private_key_path
}
}
provisioner "remote-exec" {
when = destroy
inline = [
# Remove the .container file
"rm -f ~/.config/containers/systemd/${self.triggers.name}.network",
# Reload systemd to remove the generated service
"systemctl --user daemon-reload",
# Force remove any lingering containers
"podman network rm -f ${self.triggers.name} || true"
]
connection {
type = "ssh"
host = self.triggers.server_ip
user = "fourlights"
agent = true
agent_identity = self.triggers.ssh_private_key_path
}
}
}
output "name" {
value = var.name
}
output "service_status" {
value = "${var.name} deployed"
}
output "installed" {
value = true
depends_on = [null_resource.deploy_network]
}

View File

@ -1,2 +0,0 @@
.idea
.venv/

View File

@ -1,625 +0,0 @@
#!/usr/bin/env python3
import json
import os
import subprocess
import sys
import time
import requests
HAPROXY_API_BASE = "http://:admin@127.0.0.1:5555/v3"
CERT_DIR = "/home/fourlights/.acme.sh"
ACME_SCRIPT = "/usr/local/bin/acme.sh"
class PodmanHAProxyACMESync:
def __init__(self):
self.ssl_services = set()
self.session = requests.Session()
self.session.headers.update({"Content-Type": "application/json"})
def get_next_index(self, path):
response = self.session.get(
f"{HAPROXY_API_BASE}/services/haproxy/configuration/{path}"
)
return len(response.json()) if response.status_code == 200 else None
def get_dataplaneapi_version(self):
response = self.session.get(
f"{HAPROXY_API_BASE}/services/haproxy/configuration/version"
)
return response.json() if response.status_code == 200 else None
def get_container_labels(self, container_id):
try:
result = subprocess.run(
["podman", "inspect", container_id], capture_output=True, text=True
)
if result.returncode == 0:
data = json.loads(result.stdout)
return data[0]["Config"]["Labels"] or {}
except Exception as e:
print(f"Error getting labels for {container_id}: {e}")
return {}
# ===== PHASE 1: Helper Methods (Check/Query Operations) =====
def check_acl_exists(self, frontend, acl_name, criterion, value):
"""
Check if an ACL exists in the specified frontend by matching name, criterion, and value.
Returns: (exists: bool, index: int | None, current_config: dict | None)
"""
try:
response = self.session.get(
f"{HAPROXY_API_BASE}/services/haproxy/configuration/frontends/{frontend}/acls"
)
if response.status_code == 200:
acls = response.json()
for idx, acl in enumerate(acls):
if (acl.get("acl_name") == acl_name and
acl.get("criterion") == criterion and
acl.get("value") == value):
return (True, idx, acl)
return (False, None, None)
except Exception as e:
print(f"[UPSERT-ERROR] Error checking ACL existence in frontend {frontend}: {e}")
return (False, None, None)
def check_backend_rule_exists(self, frontend, rule_name, cond_test):
"""
Check if a backend switching rule exists in the specified frontend by matching name and condition test.
Returns: (exists: bool, index: int | None, current_config: dict | None)
"""
try:
response = self.session.get(
f"{HAPROXY_API_BASE}/services/haproxy/configuration/frontends/{frontend}/backend_switching_rules"
)
if response.status_code == 200:
rules = response.json()
for idx, rule in enumerate(rules):
if (rule.get("name") == rule_name and
rule.get("cond_test") == cond_test):
return (True, idx, rule)
return (False, None, None)
except Exception as e:
print(f"[UPSERT-ERROR] Error checking backend rule existence in frontend {frontend}: {e}")
return (False, None, None)
def check_http_request_rule_exists(self, frontend, cond_test, redirect_type, redirect_value):
"""
Check if an http_request_rule (redirect rule) exists by matching condition test and redirect parameters.
Returns: (exists: bool, index: int | None, current_config: dict | None)
"""
try:
response = self.session.get(
f"{HAPROXY_API_BASE}/services/haproxy/configuration/frontends/{frontend}/http_request_rules"
)
if response.status_code == 200:
rules = response.json()
for idx, rule in enumerate(rules):
if (rule.get("cond_test") == cond_test and
rule.get("type") == "redirect"):
redirect_cfg = rule.get("redirect_rule", {})
if (redirect_cfg.get("type") == redirect_type and
redirect_cfg.get("value") == redirect_value):
return (True, idx, rule)
return (False, None, None)
except Exception as e:
print(f"[UPSERT-ERROR] Error checking http_request_rule existence in frontend {frontend}: {e}")
return (False, None, None)
# ===== PHASE 2: Upsert Operations =====
def upsert_acl(self, frontend, acl_name, criterion, value):
"""
Create or update an ACL in the specified frontend.
Returns: (success: bool, operation_type: str)
- operation_type: "CREATE", "UPDATE", "SKIP", or "ERROR"
"""
try:
exists, idx, current_config = self.check_acl_exists(frontend, acl_name, criterion, value)
if exists and current_config:
# ACL exists with same config - skip
print(f"[UPSERT-SKIP] ACL {acl_name} already exists in frontend {frontend}, skipping")
return (True, "SKIP")
elif exists and idx is not None:
# ACL exists but with different config - delete and recreate
try:
self.session.delete(
f"{HAPROXY_API_BASE}/services/haproxy/configuration/frontends/{frontend}/acls/{idx}?version={self.get_dataplaneapi_version()}"
)
except Exception as e:
print(f"[UPSERT-ERROR] Failed to delete old ACL {acl_name}: {e}")
return (False, "ERROR")
# Create new ACL
acl_data = {
"acl_name": acl_name,
"criterion": criterion,
"value": value,
}
response = self.session.post(
f"{HAPROXY_API_BASE}/services/haproxy/configuration/frontends/{frontend}/acls/{self.get_next_index(f'frontends/{frontend}/acls')}?version={self.get_dataplaneapi_version()}",
json=acl_data,
)
if response.status_code in [200, 201]:
op_type = "UPDATE" if (exists and idx is not None) else "CREATE"
print(f"[UPSERT-{op_type}] ACL {acl_name} {op_type.lower()}d in frontend {frontend}")
return (True, op_type)
else:
print(f"[UPSERT-ERROR] Failed to create ACL {acl_name}: {response.status_code} - {response.text}")
return (False, "ERROR")
except Exception as e:
print(f"[UPSERT-ERROR] Exception upserting ACL {acl_name}: {e}")
return (False, "ERROR")
def upsert_backend_rule(self, frontend, rule_name, cond_test):
"""
Create or update a backend switching rule in the specified frontend.
Returns: (success: bool, operation_type: str)
- operation_type: "CREATE", "UPDATE", "SKIP", or "ERROR"
"""
try:
exists, idx, current_config = self.check_backend_rule_exists(frontend, rule_name, cond_test)
if exists and current_config:
# Rule exists with same config - skip
print(f"[UPSERT-SKIP] Backend rule {rule_name} already exists in frontend {frontend}, skipping")
return (True, "SKIP")
elif exists and idx is not None:
# Rule exists but with different config - delete and recreate
try:
self.session.delete(
f"{HAPROXY_API_BASE}/services/haproxy/configuration/frontends/{frontend}/backend_switching_rules/{idx}?version={self.get_dataplaneapi_version()}"
)
except Exception as e:
print(f"[UPSERT-ERROR] Failed to delete old backend rule {rule_name}: {e}")
return (False, "ERROR")
# Create new backend rule
rule_data = {
"name": rule_name,
"cond": "if",
"cond_test": cond_test,
}
response = self.session.post(
f"{HAPROXY_API_BASE}/services/haproxy/configuration/frontends/{frontend}/backend_switching_rules/{self.get_next_index(f'frontends/{frontend}/backend_switching_rules')}?version={self.get_dataplaneapi_version()}",
json=rule_data,
)
if response.status_code in [200, 201]:
op_type = "UPDATE" if (exists and idx is not None) else "CREATE"
print(f"[UPSERT-{op_type}] Backend rule {rule_name} {op_type.lower()}d in frontend {frontend}")
return (True, op_type)
else:
print(f"[UPSERT-ERROR] Failed to create backend rule {rule_name}: {response.status_code} - {response.text}")
return (False, "ERROR")
except Exception as e:
print(f"[UPSERT-ERROR] Exception upserting backend rule {rule_name}: {e}")
return (False, "ERROR")
def upsert_http_request_rule(self, frontend, cond_test, redirect_rule_data):
"""
Create or update an http_request_rule (redirect rule) in the specified frontend.
redirect_rule_data should contain: {"type": "redirect", "redirect_rule": {...}, "cond": "if", "cond_test": ...}
Returns: (success: bool, operation_type: str)
- operation_type: "CREATE", "UPDATE", "SKIP", or "ERROR"
"""
try:
redirect_type = redirect_rule_data.get("redirect_rule", {}).get("type")
redirect_value = redirect_rule_data.get("redirect_rule", {}).get("value")
exists, idx, current_config = self.check_http_request_rule_exists(
frontend, cond_test, redirect_type, redirect_value
)
if exists and current_config:
# Rule exists with same config - skip
print(f"[UPSERT-SKIP] Redirect rule for {cond_test} already exists in frontend {frontend}, skipping")
return (True, "SKIP")
elif exists and idx is not None:
# Rule exists but with different config - delete and recreate
try:
self.session.delete(
f"{HAPROXY_API_BASE}/services/haproxy/configuration/frontends/{frontend}/http_request_rules/{idx}?version={self.get_dataplaneapi_version()}"
)
except Exception as e:
print(f"[UPSERT-ERROR] Failed to delete old redirect rule for {cond_test}: {e}")
return (False, "ERROR")
# Create new http_request_rule
response = self.session.post(
f"{HAPROXY_API_BASE}/services/haproxy/configuration/frontends/{frontend}/http_request_rules/{self.get_next_index(f'frontends/{frontend}/http_request_rules')}?version={self.get_dataplaneapi_version()}",
json=redirect_rule_data,
)
if response.status_code in [200, 201]:
op_type = "UPDATE" if (exists and idx is not None) else "CREATE"
print(f"[UPSERT-{op_type}] Redirect rule for {cond_test} {op_type.lower()}d in frontend {frontend}")
return (True, op_type)
else:
print(f"[UPSERT-ERROR] Failed to create redirect rule for {cond_test}: {response.status_code} - {response.text}")
return (False, "ERROR")
except Exception as e:
print(f"[UPSERT-ERROR] Exception upserting redirect rule for {cond_test}: {e}")
return (False, "ERROR")
def request_certificate(self, domain):
print(f"[CERT-REQUEST] About to request certificate for {domain}")
sys.stdout.flush()
try:
cmd = [
ACME_SCRIPT,
"--issue",
"-d",
domain,
"--standalone",
"--httpport",
"8888",
"--server",
"letsencrypt",
"--listen-v4",
"--debug",
"2",
]
# Log the command being executed
print(f"[CERT-REQUEST] Executing: {' '.join(cmd)}")
sys.stdout.flush()
result = subprocess.run(cmd, capture_output=True, text=True)
# Log both stdout and stderr for complete debugging
if result.stdout:
print(f"[CERT-STDOUT] {result.stdout}")
sys.stdout.flush()
if result.stderr:
print(f"[CERT-STDERR] {result.stderr}")
sys.stderr.flush()
if result.returncode == 0:
print(f"[CERT-SUCCESS] Certificate obtained for {domain}")
sys.stdout.flush()
self.install_certificate(domain)
return True
else:
print(f"[CERT-FAILED] Failed to obtain certificate for {domain}")
print(f"[CERT-FAILED] Return code: {result.returncode}")
sys.stdout.flush()
return False
except Exception as e:
print(f"[CERT-ERROR] Error requesting certificate: {e}")
sys.stdout.flush()
return False
def install_certificate(self, domain):
cert_file = f"{CERT_DIR}/{domain}.pem"
try:
acme_cert_dir = f"/home/fourlights/.acme.sh/{domain}_ecc"
with open(cert_file, "w") as outfile:
with open(f"{acme_cert_dir}/fullchain.cer") as cert:
outfile.write(cert.read())
with open(f"{acme_cert_dir}/{domain}.key") as key:
outfile.write(key.read())
try:
with open(f"{acme_cert_dir}/ca.cer") as ca:
outfile.write(ca.read())
except FileNotFoundError:
pass
os.chmod(cert_file, 0o600)
print(f"Certificate installed at {cert_file}")
self.update_haproxy_ssl_bind(domain)
except Exception as e:
print(f"Error installing certificate for {domain}: {e}")
def update_haproxy_ssl_bind(self, domain):
print(f"Updating ssl bind for {domain}")
try:
ssl_bind_data = {
"address": "*",
"port": 443,
"ssl": True,
"ssl_certificate": f"{CERT_DIR}/{domain}.pem",
}
response = self.session.post(
f"{HAPROXY_API_BASE}/services/haproxy/configuration/frontends/https_main/binds?version={self.get_dataplaneapi_version()}",
json=ssl_bind_data,
)
print(response.json())
if response.status_code in [200, 201]:
print(f"Updated HAProxy SSL bind for {domain}")
except Exception as e:
print(f"Error updating HAProxy SSL bind: {e}")
def setup_certificate_renewal(self, domain):
renewal_script = f"/etc/cron.d/acme-{domain.replace('.', '-')}"
cron_content = f"""0 0 * * * root {ACME_SCRIPT} --renew -d {domain} --post-hook "systemctl reload haproxy" >/dev/null 2>&1
"""
with open(renewal_script, "w") as f:
f.write(cron_content)
print(f"Setup automatic renewal for {domain}")
def update_haproxy_backend(self, service_name, host, port, action="add"):
backend_name = f"backend_{service_name}"
server_name = f"{service_name}_server"
if action == "add":
backend_data = {
"name": backend_name,
"mode": "http",
"balance": {"algorithm": "roundrobin"},
}
backends = self.session.post(
f"{HAPROXY_API_BASE}/services/haproxy/configuration/backends?version={self.get_dataplaneapi_version()}",
json=backend_data,
)
print(backends.json())
server_data = {
"name": server_name,
"address": host,
"port": int(port),
"check": "enabled",
}
tweak = self.session.post(
f"{HAPROXY_API_BASE}/services/haproxy/configuration/backends/{backend_name}/servers?version={self.get_dataplaneapi_version()}",
json=server_data,
)
print(tweak.json())
elif action == "remove":
self.session.delete(
f"{HAPROXY_API_BASE}/services/haproxy/configuration/backends/{backend_name}/servers/{server_name}?version={self.get_dataplaneapi_version()}"
)
def update_haproxy_frontend_rule(
self, service_name, domain, ssl_enabled=False, action="add"
):
"""
Update HAProxy frontend rules (ACLs, backend switching rules, and redirect rules).
Uses upsert behavior to prevent duplicate rules on multiple syncs.
"""
if action == "add":
if ssl_enabled and domain and domain not in self.ssl_services:
print(f"Setting up SSL for {domain}")
if self.request_certificate(domain):
self.setup_certificate_renewal(domain)
self.ssl_services.add(domain)
# Upsert ACL in HTTP frontend (main)
self.upsert_acl(
"main",
f"is_{service_name}",
"hdr(host)",
domain
)
# Upsert ACL in HTTPS frontend if SSL enabled
if ssl_enabled:
self.upsert_acl(
"https_main",
f"is_{service_name}",
"hdr(host)",
domain
)
# Upsert backend switching rule in HTTP frontend
self.upsert_backend_rule(
"main",
f"backend_{service_name}",
f"is_{service_name}"
)
# Upsert backend switching rule in HTTPS frontend if SSL enabled
if ssl_enabled:
self.upsert_backend_rule(
"https_main",
f"backend_{service_name}",
f"is_{service_name}"
)
# Upsert redirect rule (HTTP to HTTPS)
redirect_rule = {
"type": "redirect",
"redirect_rule": {"type": "scheme", "value": "https", "code": 301},
"cond": "if",
"cond_test": f"is_{service_name}",
}
self.upsert_http_request_rule(
"main",
f"is_{service_name}",
redirect_rule
)
def process_container_event(self, event):
# DIAGNOSTIC: Log raw event structure
print(
f"[EVENT-DEBUG] Received event - Type: {event.get('Type', 'MISSING')}, Action: {event.get('Action', 'MISSING')}"
)
sys.stdout.flush()
# DIAGNOSTIC: Check for Actor key
if "Actor" not in event:
print(
f"[EVENT-SKIP] Skipping event without 'Actor' key - Full event: {json.dumps(event)}"
)
sys.stdout.flush()
return
# DIAGNOSTIC: Check for ID in Actor
if "ID" not in event["Actor"]:
print(
f"[EVENT-SKIP] Skipping event without 'Actor.ID' - Actor content: {json.dumps(event['Actor'])}"
)
sys.stdout.flush()
return
container_id = event["Actor"]["ID"][:12]
action = event["Action"]
print(
f"[EVENT-PROCESS] Processing '{action}' event for container {container_id}"
)
sys.stdout.flush()
labels = self.get_container_labels(container_id)
# Dictionary to store discovered services
services = {}
# First, check for namespaced labels (haproxy.{service_name}.enable)
for label_key, label_value in labels.items():
if (
label_key.startswith("haproxy.")
and label_key.endswith(".enable")
and label_value.lower() == "true"
):
# Extract service name from label key
parts = label_key.split(".")
if len(parts) == 3: # haproxy.{service_name}.enable
service_name = parts[1]
# Extract properties for this service namespace
service_config = {
"service_name": service_name,
"host": labels.get(f"haproxy.{service_name}.host", "127.0.0.1"),
"port": labels.get(f"haproxy.{service_name}.port", "8080"),
"domain": labels.get(f"haproxy.{service_name}.domain", None),
"ssl_enabled": labels.get(
f"haproxy.{service_name}.tls", "false"
).lower()
== "true",
}
services[service_name] = service_config
# Backward compatibility: If no namespaced labels found, check for flat labels
if (
not services
and "haproxy.enable" in labels
and labels["haproxy.enable"].lower() == "true"
):
service_name = labels.get("haproxy.service", container_id)
services[service_name] = {
"service_name": service_name,
"host": labels.get("haproxy.host", "127.0.0.1"),
"port": labels.get("haproxy.port", "8080"),
"domain": labels.get("haproxy.domain", None),
"ssl_enabled": labels.get("haproxy.tls", "false").lower() == "true",
}
# Process each discovered service
for service_name, config in services.items():
if action in ["start", "restart"]:
print(
f"Adding service {config['service_name']} to HAProxy (SSL: {config['ssl_enabled']}, Domain: {config['domain']})"
)
sys.stdout.flush()
self.update_haproxy_backend(
config["service_name"], config["host"], config["port"], "add"
)
if config["domain"]:
self.update_haproxy_frontend_rule(
config["service_name"],
config["domain"],
config["ssl_enabled"],
"add",
)
elif action in ["stop", "remove", "died"]:
print(f"Removing service {config['service_name']} from HAProxy")
sys.stdout.flush()
self.update_haproxy_backend(
config["service_name"], config["host"], config["port"], "remove"
)
def watch_events(self):
print("Starting Podman-HAProxy-ACME sync...")
# Track last sync time
last_full_sync = 0
SYNC_INTERVAL = 60 # Re-scan all containers every 60 seconds
def do_full_sync():
"""Perform a full sync of all running containers"""
print("Performing full container sync...")
try:
result = subprocess.run(
["podman", "ps", "--format", "json"], capture_output=True, text=True
)
if result.returncode == 0:
containers = json.loads(result.stdout)
for container in containers:
event = {
"Type": "container",
"Action": "start",
"Actor": {"ID": container.get("Id", "")},
}
self.process_container_event(event)
print(f"Synced {len(containers)} containers")
except Exception as e:
print(f"Error during full sync: {e}")
# Initial sync
do_full_sync()
last_full_sync = time.time()
print("Watching for container events...")
cmd = ["podman", "events", "--format", "json"]
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, text=True)
# Use select/poll for non-blocking read so we can do periodic syncs
import select
while True:
# Check if it's time for periodic sync
if time.time() - last_full_sync >= SYNC_INTERVAL:
do_full_sync()
last_full_sync = time.time()
# Check for events with timeout
ready, _, _ = select.select([process.stdout], [], [], 5)
if ready:
line = process.stdout.readline()
if line:
try:
event = json.loads(line.strip())
if event["Type"] == "container":
self.process_container_event(event)
except json.JSONDecodeError as e:
print(
f"[EVENT-ERROR] JSON decode error: {e} - Line: {line[:100]}"
)
sys.stdout.flush()
except KeyError as e:
print(
f"[EVENT-ERROR] Missing key {e} in event: {json.dumps(event)}"
)
sys.stdout.flush()
except Exception as e:
print(f"[EVENT-ERROR] Error processing event: {e}")
print(f"[EVENT-ERROR] Event structure: {json.dumps(event)}")
sys.stdout.flush()
if __name__ == "__main__":
os.makedirs(CERT_DIR, exist_ok=True)
sync = PodmanHAProxyACMESync()
sync.watch_events()

View File

@ -1,9 +0,0 @@
[project]
name = "podman-haproxy-acme-sync"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"requests>=2.32.5",
]

View File

@ -1,97 +0,0 @@
version = 1
revision = 3
requires-python = ">=3.13"
[[package]]
name = "certifi"
version = "2025.10.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "podman-haproxy-acme-sync"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "requests" },
]
[package.metadata]
requires-dist = [{ name = "requests", specifier = ">=2.32.5" }]
[[package]]
name = "requests"
version = "2.32.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "urllib3"
version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
]

1
ships/k3s+rancher.ign Normal file
View File

@ -0,0 +1 @@
{"ignition":{"config":{"merge":[{"compression":"gzip","source":"data:;base64,H4sIAAAAAAAC/3yUSXOrPBaG9/0rXPSS3GAGYztdWWBjDB7ANgYP11kwKFgMAiQx3sp/73Jy892u6u5PK9WRzvtIi/P8YmCEIIU5Yl5+MTXA5HPLiM/i85D5eGIIzbEXgcfpO0wBYV5+/mLyGuAGQwqYF4or8MQUHr0zLwwHaMDdc0KRlwHmiQlyRAGi5NEd5FmBAfmdzzwxJK9wAJgXJvSo9/KEPRTcAf5BAK4BfqCzPATMiyQMP55+/UXIC8oRQKvix++GZ3L/W1LUw+K/aP/yPQJk6UmXiKF8rTkntY5z4WJzNmOnMnsZ24al3/tjOZwbzmiurPmOvzqLDVRCJT2Zu2VZWf45ycbuSdYlNqCmKEgzZRdc+3Y64Sfb0XjFXfnyrM3NIhkHjUATU+nGmyYjxkSulmGsKWpxMZYHI4uJjXOH22vlpfRFq8N0eT3w2zuPUt2Xg8Zl60glW6VS71RgzyInDtmdWN7FMku2QzPQujJnx9OjogZNv9knsyXQi0vdHBZ7edLms70SQn8hjueBr62kpNfrrXtORlGzmd75M9VqmztYw5O7GnE2Ki5DfYIFyBv5PHCUJZTnm50+V+1EUwgIE0u1bDUGvEuldkbml0OtB2ZAF7IBeMhufPtwXa5y8eB72VpTRYk7b018nY/aqJ8kE6ehgtSs2ykaxWdfkqzVDmzMgPNTb1dzvdyfyynweblM3e15V0btbhparXRtI26RGYvYVKIrPQrrPRtMGspfZ6fuYDj2ZqoUZLZbG/OjVLOSqvFCbisndg1oOIdKUxiCHg0TV7wsN++p3CfvBDeSPJ2dwg5v9TtXHZX+qrC2ELvD3shYB2K8H7u8qIjmfnU3VaObrVtz7Prt1pbZDUicrJrYCSWwEyxnLkYabyy4dJeQYaYhwaoiekWadZTfk7hTfZtVCnkWFxlSx0m4jFfwkEqdLpVr0evlepSnOGi2F6HqRKkeZnspuFh3aX0iuTAyY+3i9mWhw4nu4H10mOq86rjrMBaXESD1rtxhtGKz9h7G8tHK5mbhWYagrkzPbFngofG6PFZhvPAuRbr2j5mTmvGcivvx2nPczt4eEt5ojqHRuXRhtNkUQgSupVFMLtNOvO96lF4kZzIzLEffrXir61gMJ3vNPXPD7pxsxhNlq5prNpoFkcBlSLtqYnnetJC+L7rt4VqMJvzQ1rKTUPtVr1TueL7tjlVcXuVN3WwVZcdxdZCY/YJoiqK8/sf4T8WPt4eHOkJBFj7mu0KQfnnoz9QzPx0E6dsNqYAEGBYPo70evgwxsB++uCHlnQL8igBtcpz8yFEKEXimHo4AvaF/DpYAAQyDQZCj8NOIA5oPGg/SwXuOBwDSR1QikkGOBzgBwndgIpLnh7RgAD7rvw32XbuhAygriAH5f+gb+ml/3X27oWNXgNccAXLP6Q0tWhDY1MP09X+K75GdefDrIYsW0tcOkM9AAxHqpenbDZ08REE4616zKqXwR0UA/ovMPDEAeX4Kwm+Rf4r7hfkjY1oV3z9hPt4+Pv7x7wAAAP//hVXhITIGAAA="}]},"version":"3.3.0"}}

6
ships/k3s+rancher.yaml Normal file
View File

@ -0,0 +1,6 @@
variant: flatcar
version: 1.0.0
ignition:
config:
merge:
- local: rancher.ign

1
ships/k3s.ign Normal file
View File

@ -0,0 +1 @@
{"ignition":{"config":{"merge":[{"compression":"gzip","source":"data:;base64,H4sIAAAAAAAC/5yRwWvbMBjF7/szPsjNsRU5DKZdNlqy1h1ha9akyehBlr7aworkSZ8NJvh/H26yQ05jO4gn0OPpPX4nMJUzZLwDcYIeQ3y7Qp7mKYMxgUg+yAqn11djMYL4eYJWUg0CMiSV1T6Sk0eEBJR3hI7iZFb+2AaMlzhIIPouKAQBWpIUifMap/zjpGLJ2Zhc58YhKrKpzjib/+oM0lx5F73FVHn3+o+/NRgc2rQNxlEz42yW3844y68O+/y3PoRHfdEsYjDSziskGj4RDRuWRgy9UZjqzPcYgtH/U/VjKSO+Xya7xdoqd2j3fKtvmu3ysFv/0F9Wg75lD4/Ptd0v1mz/XLBvG9YrXrSl+VAf+Jbpu6b6ull0JV/Hw2413N8s6vu7oi/z71Xx1FTF6nH11LCH660vE+nzwKlj5wydSaOTpUUNgkKHCbyBFn+s8wujy3AYX8bx3e8AAAD//zEhu5dSAgAA"},{"compression":"gzip","source":"data:;base64,H4sIAAAAAAAC/3yTX4+qOBjG7/dTbHqrYx1xFN14geAI6qgM+Hf0osIrINByaEFh4nffoJs9m5zs6U2bvM3za9Lf840CjwYiYBT1v1EOKX8ckdSQGk10ryMuWEo8qKbnIAKO+l/fKCHCR32EQTjYZ1xQEgOqI4dRAVTw6rLD4iQF/k8cqiPOstQB1EcuEaRfDyX+QpkLFSOu9n671bzXf2azRGAOIkteQok3uP/bfK8Mkl8Yf50Ih067rre5oTyXil+LpV3LiTlUx13Mk2269qDn8cmJ6JFfTGGoRu/XXDBLPZWOyVt2z993rsY596xOR5c1b6kV8ocsd9qbJN9OxGSnC8eocdcznam3nxeyxkK51vLUbfvNtCcbPUqDteJIeGRos9t76yObzNc7p7YqdeHjJZba7qUADW+TfJm9tsr9LMmnukrbqbnIF7zV1OZrg/e2b3Iod5h7s8m66Aznbr7KdY73k31oNPVc3qldlVrSzf/RwfjiD93a9L03uwhrs13V5HyRkUXMa6fy05BNNXu9psR8N2fnHR4m+6X15qm37jTVFqPTYrqrObOe8mk7/t4uwV1Z+twfDyc0CF8zNp6N4wyS7oWMcUsv7bJ7Okcst5vbqb4aFZvQ8Fs9aX06q0P+oZUbM26H2tXHjGXedfnpj3BJw0tPMxUFY+z/6I5U5o0URRkM/qNCT7ofK/kKLiB2q//OaCCe8v20AH2taCCOB6oBd9IgqTQeTCX+p1V5c6DKWUA6oCCuLA1fGI0CCg1BUg/EgW4IFfz/hgf6ZUGaBw4cD9QuEhgwCtxn4kBHN3AsQVIx+EXRA/2EmARP7ugWiEEB/BFmUC5IFB2fWHCHxSDOIhG8ZBzSf6mojoCSUwQu6os0gzp6FKuPqrI8QA3+fBW6H+/3P/4OAAD//2a0KAm9AwAA"}]},"version":"3.3.0"}}

7
ships/k3s.yaml Normal file
View File

@ -0,0 +1,7 @@
variant: flatcar
version: 1.0.0
ignition:
config:
merge:
- local: base.ign
- local: k3s.ign

View File

@ -1,288 +0,0 @@
variable "hcloud_token" {
type = string
sensitive = true
}
variable "hdns_token" {
type = string
sensitive = true
}
variable "ssh_public_key_path" {
description = "Path to SSH public key"
type = string
}
variable "ssh_private_key_path" {
description = "Path to SSH private key"
type = string
}
variable "server" {
default = "mars"
}
variable "server_domain" {
default = "mars.fourlights.dev"
}
variable "ghcr_username" {}
variable "ghcr_token" {}
resource "null_resource" "is_up" {
connection {
type = "ssh"
host = var.server
user = "fourlights"
timeout = "10m"
agent = true
agent_identity = var.ssh_private_key_path
}
provisioner "remote-exec" {
inline = [
"whoami",
]
}
}
module "containers-network" {
wait_on = null_resource.is_up
source = "../../quadlets/network"
server_ip = var.server
ssh_private_key_path = var.ssh_private_key_path
name = "containers"
}
module "minio" {
wait_on = null_resource.is_up
source = "../../quadlets/modules/minio"
server_ip = var.server
server_domain = var.server_domain
ssh_private_key_path = var.ssh_private_key_path
https = true
}
module "valkey" {
wait_on = null_resource.is_up
source = "../../quadlets/modules/valkey"
server_ip = var.server
ssh_private_key_path = var.ssh_private_key_path
}
module "affine" {
wait_on = module.postgres.installed
source = "../../quadlets/modules/affine"
server_ip = var.server
server_domain = var.server_domain
ssh_private_key_path = var.ssh_private_key_path
# Pass postgres password for database connection
postgres_password = module.postgres.password
}
module "oci-proxy" {
wait_on = null_resource.is_up
source = "../../quadlets/modules/oci-proxy"
server_ip = var.server
ssh_private_key_path = var.ssh_private_key_path
}
module "arcane" {
wait_on = module.oci-proxy.installed
source = "../../quadlets/modules/arcane"
server_domain = var.server_domain
server_ip = var.server
ssh_private_key_path = var.ssh_private_key_path
}
module "qdrant" {
wait_on = null_resource.is_up
source = "../../quadlets/modules/qdrant"
server_ip = var.server
ssh_private_key_path = var.ssh_private_key_path
server_domain = var.server_domain
}
module "documentdb" {
wait_on = null_resource.is_up
source = "../../quadlets/modules/documentdb"
server_ip = var.server
ssh_private_key_path = var.ssh_private_key_path
}
#module "opensign" {
# wait_on = module.documentdb.installed
# source = "../../quadlets/modules/opensign"
# server_ip = var.server
# server_domain = var.server_domain
# ssh_private_key_path = var.ssh_private_key_path
#}
module "postgres" {
wait_on = null_resource.is_up
source = "../../quadlets/modules/postgres"
server_ip = var.server
ssh_private_key_path = var.ssh_private_key_path
}
module "rabbitmq" {
wait_on = null_resource.is_up
source = "../../quadlets/modules/rabbitmq"
server_ip = var.server
ssh_private_key_path = var.ssh_private_key_path
}
#module "plane" {
# count = 0
# wait_on = null_resource.is_up
# source = "../../quadlets/modules/plane"
# server_ip = var.server
# ssh_private_key_path = var.ssh_private_key_path
# server_domain = var.server_domain
#}
#module "airsonic-advanced" {
# wait_on = null_resource.is_up
# source = "../../quadlets/modules/airsonic-advanced"
# server_ip = var.server
# ssh_private_key_path = var.ssh_private_key_path
# server_domain = var.server_domain
#}
module "gonic" {
wait_on = null_resource.is_up
source = "../../quadlets/modules/gonic"
server_ip = var.server
server_domain = var.server_domain
ssh_private_key_path = var.ssh_private_key_path
}
module "tmail-web" {
wait_on = null_resource.is_up
source = "../../quadlets/modules/tmail-web"
server_ip = var.server
server_domain = var.server_domain
ssh_private_key_path = var.ssh_private_key_path
server_url = "https://mail.binarysunset.dev"
}
module "navidrome" {
wait_on = null_resource.is_up
source = "../../quadlets/modules/navidrome"
server_ip = var.server
ssh_private_key_path = var.ssh_private_key_path
server_domain = var.server_domain
}
module "mopidy" {
wait_on = null_resource.is_up
source = "../../quadlets/modules/mopidy"
server_ip = var.server
ssh_private_key_path = var.ssh_private_key_path
server_domain = var.server_domain
}
module "zot" {
wait_on = null_resource.is_up
source = "../../quadlets/modules/zot"
server_ip = var.server
ssh_private_key_path = var.ssh_private_key_path
server_domain = var.server_domain
}
# Deploy Plane after all dependencies are ready
#module "plane" {
# wait_on = null_resource.is_up
#
# source = "../../quadlets/modules/plane"
# server_ip = var.server
# server_domain = var.server_domain
# ssh_private_key_path = var.ssh_private_key_path
#
# # Pass credentials from existing services
# postgres_password = module.postgres.password
# minio_server = module.minio.server
# minio_access_key = module.minio.access_key
# minio_secret_key = module.minio.secret_key
# rabbitmq_username = module.rabbitmq.username
# rabbitmq_password = module.rabbitmq.password
#}
module "beets" {
wait_on = null_resource.is_up
source = "../../quadlets/modules/beets"
server_ip = var.server
server_domain = var.server_domain
ssh_private_key_path = var.ssh_private_key_path
}
module "deeptutor" {
count = 0
wait_on = null_resource.is_up
source = "../../quadlets/modules/deeptutor"
server_ip = var.server
server_domain = var.server_domain
ssh_private_key_path = var.ssh_private_key_path
}
output "psql_pw" {
value = module.postgres.password
sensitive = true
}
module "forgejo" {
wait_on = module.postgres.installed
source = "../../quadlets/modules/forgejo"
server_ip = var.server
server_domain = var.server_domain
ssh_private_key_path = var.ssh_private_key_path
# Pass postgres password for database connection
postgres_password = module.postgres.password
}
output "minio_app_urls" {
value = module.minio.app_urls
}
output "qdrant_api_key" {
value = module.qdrant.api_key
sensitive = true
}
#output "plane_app_urls" {
# value = module.plane.app_urls
#}
#
#output "plane_credentials" {
# value = module.plane.credentials
# sensitive = true
#}
#
#output "plane_main_url" {
# value = module.plane.main_url
#}
output "forgejo_app_urls" {
value = module.forgejo.app_urls
}
output "affine_app_urls" {
value = module.affine.app_urls
}
#output "deeptutor_app_urls" {
# value = module.deeptutor.app_urls
#}
output "tmail_web_app_urls" {
value = module.tmail-web.app_urls
}
#output "plane_credentials" {
#value = module.plane.credentials
#sensitive = true
#}

1
ships/overlays/base.ign Normal file
View File

@ -0,0 +1 @@
{"ignition":{"version":"3.3.0"},"storage":{"files":[{"path":"/etc/hostname","contents":{"compression":"","source":"data:,node"},"mode":420},{"path":"/etc/sysctl.d/20-quiet-console.conf","contents":{"compression":"","source":"data:,kernel.printk%20%3D%203%203%203%203%0A"},"mode":420},{"path":"/etc/systemd/system/serial-getty@ttyS0.service.d/override.conf","contents":{"compression":"","source":"data:;base64,W1NlcnZpY2VdCkV4ZWNTdGFydD0KRXhlY1N0YXJ0PS0vc2Jpbi9hZ2V0dHkgLS1ub2NsZWFyIC1hIHJvb3QgJUkgJFRFUk0K"},"mode":420}]},"systemd":{"units":[{"enabled":true,"name":"systemd-sysctl.service"}]}}

26
ships/overlays/base.yaml Normal file
View File

@ -0,0 +1,26 @@
# base.yaml
variant: flatcar
version: 1.0.0
storage:
files:
- path: /etc/hostname
mode: 0644
contents:
inline: node
replace: true
- path: /etc/sysctl.d/20-quiet-console.conf
mode: 0644
contents:
inline: |
kernel.printk = 3 3 3 3
- path: /etc/systemd/system/serial-getty@ttyS0.service.d/override.conf
mode: 0644
contents:
inline: |
[Service]
ExecStart=
ExecStart=-/sbin/agetty --noclear -a root %I $TERM
systemd:
units:
- name: systemd-sysctl.service
enabled: true

1
ships/overlays/k3s.ign Normal file
View File

@ -0,0 +1 @@
{"ignition":{"version":"3.3.0"},"storage":{"files":[{"path":"/etc/hostname","contents":{"compression":"","source":"data:,k3s-node"},"mode":420},{"path":"/opt/setup-k3s.sh","contents":{"compression":"gzip","source":"data:;base64,H4sIAAAAAAAC/1yPT+vaQBCG7/spXrVge9gsJbaHlhyKeBClFwvtoSCbzcQs2T9hZ6wIfvgS66H8DgPDy8M8864WpvXJtJYHtcI+sdgQcKgZNy8Dok8+2gCX45QTJWHlriVAc3/EIDLxF2MuJNVYc+UzHth/P/34djyeD/XpvPu12zZLpvKHCn4rQOvOs20DNVIs9X58k86odxTaVy6BNdvUvHs/ZJZkI0Hv8YC7CnS3xhq6//jhBd+KF9LjtSWXU+8vOuaOms+bzRI8QCu1wraQFQLfY/BpZPS5gCx7KrDOEbOKY+cL9ARTchZTzedUSHNhGBJnik1uoGLGmuep7jaG/2HzTz7bflovT0XKHUEyWkIh293VbfCBsMDzWQm4kDwh/oougwPRhE/znkj9DQAA///hq7ECogEAAA=="},"mode":493}]},"systemd":{"units":[{"contents":"[Unit]\nDescription=K3s Setup\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=oneshot\nExecStart=/opt/setup-k3s.sh\nRemainAfterExit=yes\n\n[Install]\nWantedBy=multi-user.target\n","enabled":true,"name":"k3s-setup.service"}]}}

45
ships/overlays/k3s.yaml Normal file
View File

@ -0,0 +1,45 @@
variant: flatcar
version: 1.0.0
storage:
files:
- path: /etc/hostname
mode: 0644
contents:
inline: k3s-node
replace: true
- path: /opt/setup-k3s.sh
mode: 0755
contents:
inline: |
#!/bin/bash
# Install K3s with minimal components
curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="server \
--disable=traefik \
--disable=servicelb \
--tls-san=$(hostname -I | cut -d' ' -f1) \
--write-kubeconfig-mode=644" sh -
# Create symlinks for easier access
mkdir -p /root/.kube
ln -sf /etc/rancher/k3s/k3s.yaml /root/.kube/config
# Wait for node to be ready
while ! kubectl get nodes; do sleep 5; done
systemd:
units:
- name: k3s-setup.service
enabled: true
contents: |
[Unit]
Description=K3s Setup
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/opt/setup-k3s.sh
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1 @@
{"ignition":{"version":"3.3.0"},"storage":{"files":[{"overwrite":true,"path":"/etc/hostname","contents":{"compression":"","source":"data:,rancher-server"},"mode":420},{"path":"/opt/setup-rancher.sh","contents":{"compression":"gzip","source":"data:;base64,H4sIAAAAAAAC/4xUUY/jNBB+96+Y7SIOHhzTq0CIU5CAK1y1ZUELiAdAlWNPGquObXkm7VW6H4+ctN324BAPcZzx9818M57J/Z1qXFCNpk7cw2tkNAy7LwmsI86uGdjFADpYIGRImjsSroU/QFqYqb3OyrtGZR1Mh1nlHb6cwV+vgDsMAuDht2+X3/30+P3qh3qmkM0NcFyqo+79TADcwzLQkBGeHpYvwREQ68xoBQAdibE37CcbFJ4kzHvMVXk5gwL9h1XtFvS/RO0WVJ5/SnpY0H8r2i1IoCcUAGi6CLPHCDSkFAsedkODOSDje1Vt4xBsCYRvHcNctE6Ie1i+LbSRZGJo3RbamKFD34/XMNrZC5xgz8k8Uwt24wKx9n5jXb44OJPeLNc/blaPv/z6zXq9eb16qlVMXPqgxP9dOx4Zxg/EmIEjNAgZtT2KQ+c8wt1ZBWyRIUSL9ApsBPKICT4v+4DF12oSAW+KetdCiAwpI2Hg0kV3YGLfl6zkfsrw469BWdyrMHh/uTAzZA+S2jV0zIm+UirrQ7V13A3NQJhNDIyBKxN7VbxMS6+LekUmu8Sktsiy2OUC3gF1IE/lPks0mFn2OugtZnFOT6fkjyDbS+Ap6BjpmnD7kdGjJiRl4yH4qK3az6v5olrcwMY2uy34v0mQ4cYOh4KWso25NjFYVzqpHi8HUrQgR9H1DUVKdj3GgesvPqPrnJ+mxhdj6TOmCNpaOI2DJNaNx+ean7KqTudjEaYplKbTmUlNjCt3Q7KaUVySMRk1IwTdIyVtEIxm9iineZqIp8Y9y3hPznlY4U8BIOUHPJ1Oyy+ri8QFVX/0yXkL78AMDNK+gBcg2/mnFZF3qXLxitfEyMRZp5810SFmW2vbuzAuV7CMyTujqZ6LvwMAAP//vckNzEsFAAA="},"mode":493}]},"systemd":{"units":[{"contents":"[Unit]\nDescription=Rancher Setup\nAfter=network-online.target\n# Generic condition to wait for either k3s or rke2\nAfter=k3s.service rke2-server.service\nRequires=network-online.target\n\n[Service]\nType=oneshot\nExecStart=/opt/setup-rancher.sh\nRemainAfterExit=yes\n\n[Install]\nWantedBy=multi-user.target\n","enabled":true,"name":"rancher-setup.service"}]}}

View File

@ -0,0 +1,78 @@
# rancher-overlay.yaml - Universal Rancher overlay
variant: flatcar
version: 1.0.0
storage:
files:
- path: /etc/hostname
mode: 0644
overwrite: true
contents:
inline: rancher-server
- path: /opt/setup-rancher.sh
mode: 0755
contents:
inline: |
#!/bin/bash
# Detect k8s distribution and set paths
if [ -d "/var/lib/rancher/rke2" ]; then
KUBECONFIG="/etc/rancher/rke2/rke2.yaml"
# Ensure RKE2 is started
systemctl start rke2-server.service
elif [ -d "/var/lib/rancher/k3s" ]; then
KUBECONFIG="/etc/rancher/k3s/k3s.yaml"
# Ensure K3s is started
systemctl start k3s
else
echo "No supported kubernetes distribution found"
exit 1
fi
# Export kubeconfig for helm and kubectl
export KUBECONFIG
# Export helm_install_dir for helm
export HELM_INSTALL_DIR=/opt/bin
# Wait for cluster to be ready
while ! kubectl get nodes; do sleep 5; done
# Install Helm if not present
if ! command -v helm &> /dev/null; then
curl -sfL https://raw.githubusercontent.com/helm/helm/master/scripts/get-helm-3 | sh -
fi
# Install cert-manager
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.13.3/cert-manager.yaml
# Wait for cert-manager
kubectl -n cert-manager wait --for=condition=ready pod -l app=cert-manager --timeout=60s
# Install Rancher
helm repo add rancher-stable https://releases.rancher.com/server-charts/stable
helm repo update
kubectl create namespace cattle-system
helm install rancher rancher-stable/rancher \
--namespace cattle-system \
--set hostname=$(hostname | cut -d' ' -f1).sslip.io \
--set bootstrapPassword=adminadmin \
--set replicas=1
systemd:
units:
- name: rancher-setup.service
enabled: true
contents: |
[Unit]
Description=Rancher Setup
After=network-online.target
# Generic condition to wait for either k3s or rke2
After=k3s.service rke2-server.service
Requires=network-online.target
[Service]
Type=oneshot
ExecStart=/opt/setup-rancher.sh
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target

1
ships/overlays/rke2.ign Normal file
View File

@ -0,0 +1 @@
{"ignition":{"version":"3.3.0"},"storage":{"files":[{"path":"/etc/hostname","contents":{"compression":"","source":"data:,rke2-node"},"mode":420},{"path":"/etc/rancher/rke2/config.yaml","contents":{"compression":"","source":"data:;base64,dGxzLXNhbjoKICAtICQoaG9zdG5hbWUgLUkgfCBjdXQgLWQnICcgLWYxKQp3cml0ZS1rdWJlY29uZmlnLW1vZGU6ICIwNjQ0Igo="},"mode":420},{"path":"/opt/setup-rke2.sh","contents":{"compression":"gzip","source":"data:;base64,H4sIAAAAAAAC/4SQsWrDQAyG93uKv3i2BRm7lg6lnfoG54scH5bvgiQHDH34ckkLaZdMAknf9wt1TzTmQmO0OXR4K+ZRBJ/vr4eQNhX0Nn1gdj/bM9GJfdCFD0Ou+ILN6IPt5rwmF3CJozDavDfWC+vQSk4cQocX5egM21fJZTFMVbFsIzcyliOS5uQSpKA30CUqSR5JY0kzKzXp9c5fhDZTkpqi3Lcf4reU//RPdliXY1b0Z5DW6jQ06005gdjTX9/1EXtc5X6dUi1TPoXvAAAA///HYfmJVwEAAA=="},"mode":493}]},"systemd":{"units":[{"contents":"[Unit]\nDescription=RKE2 Setup\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=oneshot\nExecStart=/opt/setup-rke2.sh\nRemainAfterExit=yes\n\n[Install]\nWantedBy=multi-user.target\n","enabled":true,"name":"rke2-setup.service"}]}}

49
ships/overlays/rke2.yaml Normal file
View File

@ -0,0 +1,49 @@
# rke2-base-config.yaml - Base Flatcar + RKE2 setup
variant: flatcar
version: 1.0.0
storage:
files:
- path: /etc/hostname
mode: 0644
contents:
inline: rke2-node
- path: /etc/rancher/rke2/config.yaml
mode: 0644
contents:
inline: |
tls-san:
- $(hostname -I | cut -d' ' -f1)
write-kubeconfig-mode: "0644"
- path: /opt/setup-rke2.sh
mode: 0755
contents:
inline: |
#!/bin/bash
# Install RKE2
curl -sfL https://get.rke2.io | sh -
systemctl enable rke2-server.service
# Create symlinks for kubectl and crictl
ln -s /var/lib/rancher/rke2/bin/kubectl /usr/local/bin/kubectl
ln -s /var/lib/rancher/rke2/bin/crictl /usr/local/bin/crictl
mkdir -p /root/.kube
ln -sf /etc/rancher/rke2/rke2.yaml /root/.kube/config
systemd:
units:
- name: rke2-setup.service
enabled: true
contents: |
[Unit]
Description=RKE2 Setup
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/opt/setup-rke2.sh
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target

1
ships/rke2.ign Normal file
View File

@ -0,0 +1 @@
{"ignition":{"config":{"merge":[{"compression":"gzip","source":"data:;base64,H4sIAAAAAAAC/5yRwWvbMBjF7/szPsjNsRU5DKZdNlqy1h1ha9akyehBlr7aworkSZ8NJvh/H26yQ05jO4gn0OPpPX4nMJUzZLwDcYIeQ3y7Qp7mKYMxgUg+yAqn11djMYL4eYJWUg0CMiSV1T6Sk0eEBJR3hI7iZFb+2AaMlzhIIPouKAQBWpIUifMap/zjpGLJ2Zhc58YhKrKpzjib/+oM0lx5F73FVHn3+o+/NRgc2rQNxlEz42yW3844y68O+/y3PoRHfdEsYjDSziskGj4RDRuWRgy9UZjqzPcYgtH/U/VjKSO+Xya7xdoqd2j3fKtvmu3ysFv/0F9Wg75lD4/Ptd0v1mz/XLBvG9YrXrSl+VAf+Jbpu6b6ull0JV/Hw2413N8s6vu7oi/z71Xx1FTF6nH11LCH660vE+nzwKlj5wydSaOTpUUNgkKHCbyBFn+s8wujy3AYX8bx3e8AAAD//zEhu5dSAgAA"},{"compression":"gzip","source":"data:;base64,H4sIAAAAAAAC/5STT2/yOBDG7/spVr6WkkASVrDiQAOFAKUNgTeBwsE4k8QksVPbCYWK774KrPaPVl3p9cWWZjQ/PzPzfCEaM6ooZ6j3hSoQ8vZERtNo6ujaQFJxgWOooxHNQKLe+xcqsEpQD2mgiJZwqRjOATUQ4UwBU7JOJjwvBMg/y6EGkrwUBFAPhVjhXkOk0H5kPIQaktd3z2zr18a/iwvMSAJCq7M1wllE4+YZ59nPwX4/YAkdsxGOPy/zYJEcjnzm2APl2C7H4+4lHFvJwV/H83UaR/bTMQzceO67zLFJPPc3nzO3MEie6VuvJUJ/mm3a3XKbZ2zut6rteN1xbOe0OLq6E/P+t3J4oTQJqiweazFNmfyvhvhCi291TEzpDO7H1kzPlb4YuoPzuGuUs8qg7adl/lsWdzIW8bFlSjKxDlVM3clwYpgkTed4G74MUhZ1T6q1uqxyN3/VX4OlOXvYLp+mWiVMcBdJoC9YKw6P2uGlO55Gof081F/LB2e+6DhvyhKn/Nmwp/wyxNUw8u2HyvVSk7icBBbEL+1WNN2unl9+HJ6lcyHnjL56Vul6xWxg6Pb6w1ta042eXmaXoOgSK0qG21VafFBjTiJTwFrTlm9hRoNN66BvraHfObp6R9etODyugq7WGgWKWEEnXNPW6o0HVd0MTdMmmyif/jiNBoNB/5+z6BrXfb3NZ6kgD+t+l4yq+zb/PQX0vmZU7XdsCJIIWtS+6C9no/avXj25HRtECkSfgTpxkT5yllEGTYVFDGrHfMyU/C64Y+8eiIoS2O/Y6lxAnzOQCVc7NvoE4iksVP+/S7JjS8gxvYNHn1T1zyBv1RwmFc6y/Z0L4dO5n5eZoo+lBPEXFjUQMHzIIEQ9JUpooJtXe+jmvxuqKe//Qtf99frLHwEAAP//D4wVlBEEAAA="}]},"version":"3.3.0"}}

7
ships/rke2.yaml Normal file
View File

@ -0,0 +1,7 @@
variant: flatcar
version: 1.0.0
ignition:
config:
merge:
- local: base.ign
- local: rke2.ign

17
ships/start.sh Executable file
View File

@ -0,0 +1,17 @@
#!/bin/bash
TARGET=${1:-"k3s"}
CPU=${2:-2048}
echo "Booting $TARGET"
qemu-system-x86_64 \
-m $CPU \
-cpu host \
-enable-kvm \
-smp $(nproc) \
-drive if=pflash,format=raw,readonly=on,file=/usr/share/edk2/x64/OVMF_CODE.4m.fd \
-drive if=pflash,format=raw,file=uefi/OVMF_VARS.4m.fd \
-drive if=virtio,file=disks/$TARGET.qcow2,cache=none,aio=native \
-fw_cfg name=opt/org.flatcar-linux/config,file=$TARGET.ign \
-device virtio-net-pci,netdev=net0 \
-netdev user,id=net0

BIN
ships/uefi/OVMF_VARS.4m.fd Normal file

Binary file not shown.

View File

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

View File

@ -1,7 +0,0 @@
```release-note:feature
`provider`: Add a new attribute `burst_limit` for client-side throttling limit configuration.
```
```release-note:doc
`provider`: Add a new attribute `burst_limit`.
```

View File

@ -1,3 +0,0 @@
```release-note:doc
`resource/helm_release`: Add usage examples for `GCS` and `S3` plugins.
```

View File

@ -1,3 +0,0 @@
```release-note:doc
`data_source/helm_template`: Correct some errors in examples.
```

View File

@ -1,3 +0,0 @@
```release-note:dependency
Bump `github.com/containerd/containerd` from `1.6.6` to `1.6.12`
```

View File

@ -1,3 +0,0 @@
```release-note:doc
`resource/helm_release`: Add usage example for `OCI` repositories.
```

View File

@ -1,3 +0,0 @@
```release-note:dependency
Bump `helm.sh/helm/v3` from `3.9.4` to `3.11.0`
```

View File

@ -1,7 +0,0 @@
```release-note:dependency
Bump `k8s.io/client-go` from `0.24.2` to `0.26.1`
```
```release-note:note
`provider`: `kubernetes.exec.api_version` no longer supports `client.authentication.k8s.io/v1alpha1`. Please, switch to `client.authentication.k8s.io/v1beta1` or `client.authentication.k8s.io/v1`.
```

View File

@ -1,7 +0,0 @@
```release-note:enhancement
`data_source/helm_template`: Add a new attribute `crds` which when `include_crds` is set to `true` will be populated with a list of the manifests from the `crds/` folder of the chart.
```
```release-note:doc
`data_source/helm_template`: Add a new attribute `crds`
```

View File

@ -1,3 +0,0 @@
```release-note:doc
`provider`: Add a note regarding the `KUBECONFIG` environment variable.
```

View File

@ -1,3 +0,0 @@
```release-note:dependency
Bump `helm.sh/helm/v3` from `3.11.0` to `3.11.1`
```

View File

@ -1,3 +0,0 @@
```release-note:dependency
Bump `golang.org/x/crypto` from `0.5.0` to `0.6.0`
```

View File

@ -1,3 +0,0 @@
```release-note:feature
`helm/resource_release.go`: Add `set_list` attribute
```

View File

@ -1,3 +0,0 @@
```release-note:bug
`helm/resource_release.go`: Always recompute metadata when a release is updated
```

View File

@ -1,3 +0,0 @@
```release-note:dependency
Bump `helm.sh/helm/v3` from `3.11.2` to `3.12.0`
```

View File

@ -1,3 +0,0 @@
```release-note:bug
`helm/resource_release.go`: Fix: only recompute metadata if version actually changes
```

View File

@ -1,3 +0,0 @@
```release-note:bug
`helm/resource_release.go`: Fix: version conflicts when using local chart
```

View File

@ -1,3 +0,0 @@
```release-note:enhancement
`resource/helm_release`: add `name` field validation to be limited to 53 characters.
```

View File

@ -1,3 +0,0 @@
```release-note:bug
`resource/helm_release`: Add nil check for `set_list.value` to prevent provider ChartPathOptions
```

View File

@ -1,4 +0,0 @@
```release-note:bug
`helm_release`: Fix perpetual diff when version attribute is an empty string
```

View File

@ -1,3 +0,0 @@
```release-note:enhancement
resource/helm_release: add `upgrade_install` boolean attribute to enable idempotent release installation, addressing components of [GH-425](https://github.com/hashicorp/terraform-provider-helm/issues/425)
```

View File

@ -1,39 +0,0 @@
```release-note:dependency
Bump Golang from `1.20` to `1.21`.
```
```release-note:dependency
Bump helm.sh/helm/v3 from `v3.13.1` to `v3.13.2`.
```
```release-note:dependency
Bump github.com/hashicorp/go-cty from `v1.4.1-0.20200414143053-d3edf31b6320` to `v1.4.1-0.20200723130312-85980079f637`.
```
```release-note:dependency
Bump github.com/hashicorp/terraform-plugin-docs from `v0.14.1` to `v0.16.0`.
```
```release-note:dependency
Bump github.com/hashicorp/terraform-plugin-sdk/v2 from `v2.26.1` to `v2.30.0`.
```
```release-note:dependency
Bump golang.org/x/crypto from `v0.14.0` to `v0.16.0`.
```
```release-note:dependency
Bump k8s.io/api from `v0.28.3` to `v0.28.4`.
```
```release-note:dependency
Bump k8s.io/apimachinery from `v0.28.3` to `v0.28.4`.
```
```release-note:dependency
Bump k8s.io/client-go from `v0.28.3` to `v0.28.4`.
```
```release-note:dependency
Bump sigs.k8s.io/yaml from `v1.3.0` to `v1.4.0`.
```

View File

@ -1,3 +0,0 @@
```release-note:bug
`provider`: Fix manifest diff rendering for OCI charts.
```

View File

@ -1,3 +0,0 @@
```release-note:doc
`docs`: Use templatefile() instead of "template_file" provider in GKE example.
```

View File

@ -1,3 +0,0 @@
```release-note: enhancement
`resource/helm_release`: enable helm lockup function.
```

View File

@ -1,3 +0,0 @@
```release-note:enhancement
Add support for Terraform's experimental deferred actions
```

View File

@ -1,3 +0,0 @@
```release-note:dependency
Provider project ported from `terraform-plugin-sdk/v2` to `terraform-plugin-framework`
```

View File

@ -1,3 +0,0 @@
```release-note:enhancement
`helm_release`: add new attributes metadata.last_deployed, metadata.first_deployed, metadata.notes
```

View File

@ -1,3 +0,0 @@
```release-note:bug
`resource/helm_release`: Fix: only recompute metadata when the version in the metadata changes
```

View File

@ -1,3 +0,0 @@
```release-note:bug
`helm_release`: On destroy, do not error when release is not found
```

View File

@ -1,3 +0,0 @@
```release-note:bug
`helm_release`: Fix nil pointer deref panic on destroy when helm release is not found
```

View File

@ -1,3 +0,0 @@
```release-note:bug
`resoure/helm_release`: fix an issue where `postrender.args` is not parsed correctly.
```

View File

@ -1,3 +0,0 @@
```release-note:bug
change `set.value` && `set_list.value` to optional instead of required
```

View File

@ -1,4 +0,0 @@
```release-note:bug
`helm_release`: Fix namespace behaviour for dependency charts in non-default namespaces
```

View File

@ -1,3 +0,0 @@
```release-note:feature
`helm_release`: Add `set_wo` write-only attribute
```

Some files were not shown because too many files have changed in this diff Show More