infra/stacks/dbaas/modules/dbaas/main.tf
Viktor Barzin 82b0f6c4cb truenas deprecation: migrate all non-immich storage to proxmox NFS
- Migrate 7 backup CronJobs to Proxmox host NFS (192.168.1.127)
  (etcd, mysql, postgresql, nextcloud, redis, vaultwarden, plotting-book)
- Migrate headscale backup, ebook2audiobook, osm_routing to Proxmox NFS
- Migrate servarr (lidarr, readarr, soulseek) NFS refs to Proxmox
- Remove 79 orphaned TrueNAS NFS module declarations from 49 stacks
- Delete stacks/platform/modules/ (27 dead module copies, 65MB)
- Update nfs-truenas StorageClass to point to Proxmox (192.168.1.127)
- Remove iscsi DNS record from config.tfvars
- Fix woodpecker persistence config and alertmanager PV

Only Immich (8 PVCs, ~1.4TB) remains on TrueNAS.
2026-04-12 14:35:39 +01:00

1284 lines
42 KiB
HCL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# DB as a service. Installs MySQL operator
variable "tls_secret_name" {}
variable "tier" { type = string }
variable "dbaas_root_password" {}
variable "cluster_master_service" {
default = "mysql"
}
variable "postgresql_root_password" {}
variable "pgadmin_password" {}
variable "prod" {
default = false
type = bool
}
variable "nfs_server" { type = string }
variable "kube_config_path" {
type = string
sensitive = true
}
resource "kubernetes_namespace" "dbaas" {
metadata {
name = "dbaas"
labels = {
tier = var.tier
"resource-governance/custom-quota" = "true"
}
}
}
# Override Kyverno tier-1-cluster LimitRange (max 4Gi) to allow MySQL 6Gi limit
resource "kubernetes_limit_range" "dbaas" {
metadata {
name = "tier-defaults"
namespace = kubernetes_namespace.dbaas.metadata[0].name
}
spec {
limit {
type = "Container"
default = {
memory = "256Mi"
}
default_request = {
cpu = "50m"
memory = "256Mi"
}
max = {
memory = "8Gi"
}
}
}
}
resource "kubernetes_resource_quota" "dbaas" {
metadata {
name = "dbaas-quota"
namespace = kubernetes_namespace.dbaas.metadata[0].name
}
spec {
hard = {
"requests.cpu" = "8"
"requests.memory" = "40Gi"
"limits.memory" = "40Gi"
pods = "30"
}
}
}
module "tls_secret" {
source = "../../../../modules/kubernetes/setup_tls_secret"
namespace = kubernetes_namespace.dbaas.metadata[0].name
tls_secret_name = var.tls_secret_name
}
#### MYSQL — InnoDB Cluster via MySQL Operator
#
# 3 MySQL servers with Group Replication + 1 MySQL Router for auto-failover.
# Operator installed in mysql-operator namespace (toleration for control-plane).
# Init containers are slow (~20 min each) due to mysqlsh plugin loading.
resource "kubernetes_namespace" "mysql_operator" {
metadata {
name = "mysql-operator"
labels = {
tier = "1-cluster"
}
}
}
resource "helm_release" "mysql_operator" {
namespace = kubernetes_namespace.mysql_operator.metadata[0].name
create_namespace = false
name = "mysql-operator"
timeout = 300
repository = "https://mysql.github.io/mysql-operator/"
chart = "mysql-operator"
version = "2.2.7"
# NOTE: The mysql-operator chart (2.2.7) does NOT expose a resources values key.
# The resources block below is ignored by the chart. Without explicit resources
# on the deployment, the LimitRange default (256Mi) applies silently.
# Fix: kubectl patch deployment mysql-operator -n mysql-operator --type=json \
# -p='[{"op":"replace","path":"/spec/template/spec/containers/0/resources","value":{"requests":{"cpu":"100m","memory":"256Mi"},"limits":{"memory":"512Mi"}}}]'
values = [yamlencode({
resources = {
requests = {
cpu = "100m"
memory = "256Mi"
}
limits = {
memory = "512Mi"
}
}
})]
}
# The mysql-sidecar ClusterRole created by the Helm chart is missing
# namespace and CRD list/watch permissions needed by the kopf framework
# in the sidecar container. Without these, the sidecar enters degraded
# mode and never completes InnoDB cluster join operations.
resource "kubernetes_cluster_role" "mysql_sidecar_extra" {
metadata {
name = "mysql-sidecar-extra"
}
rule {
api_groups = [""]
resources = ["namespaces"]
verbs = ["list", "watch"]
}
rule {
api_groups = ["apiextensions.k8s.io"]
resources = ["customresourcedefinitions"]
verbs = ["list", "watch"]
}
}
resource "kubernetes_cluster_role_binding" "mysql_sidecar_extra" {
metadata {
name = "mysql-sidecar-extra"
}
role_ref {
api_group = "rbac.authorization.k8s.io"
kind = "ClusterRole"
name = kubernetes_cluster_role.mysql_sidecar_extra.metadata[0].name
}
subject {
kind = "ServiceAccount"
name = "mysql-cluster-sa"
namespace = kubernetes_namespace.dbaas.metadata[0].name
}
}
# ConfigMap for MySQL extra config — mounted as subPath over 99-extra.cnf
# This is the only reliable way to persist innodb_doublewrite=OFF because:
# - spec.mycnf only applies on initial cluster creation
# - The operator's initconf container overwrites 99-extra.cnf on every pod start
# - SET PERSIST doesn't support innodb_doublewrite (static variable)
resource "kubernetes_config_map" "mysql_extra_cnf" {
metadata {
name = "mysql-extra-cnf"
namespace = kubernetes_namespace.dbaas.metadata[0].name
}
data = {
"99-extra.cnf" = <<-EOT
[mysqld]
innodb_doublewrite=OFF
EOT
}
}
resource "helm_release" "mysql_cluster" {
namespace = kubernetes_namespace.dbaas.metadata[0].name
create_namespace = false
name = "mysql-cluster"
timeout = 900
repository = "https://mysql.github.io/mysql-operator/"
chart = "mysql-innodbcluster"
version = "2.2.7"
values = [yamlencode({
serverInstances = 3
routerInstances = 1
serverVersion = "8.4.4"
credentials = {
root = {
user = "root"
password = var.dbaas_root_password
host = "%"
}
}
tls = {
useSelfSigned = true
}
datadirVolumeClaimTemplate = {
storageClassName = "proxmox-lvm"
metadata = {
annotations = {
"resize.topolvm.io/threshold" = "80%"
"resize.topolvm.io/increase" = "20%"
"resize.topolvm.io/storage_limit" = "100Gi"
}
}
resources = {
requests = {
storage = "30Gi"
}
}
}
serverConfig = {
mycnf = <<-EOT
[mysqld]
skip-name-resolve
# Auto-recovery after crashes: rejoin group without manual intervention
group_replication_autorejoin_tries=2016
group_replication_exit_state_action=OFFLINE_MODE
group_replication_member_expel_timeout=30
group_replication_unreachable_majority_timeout=60
group_replication_start_on_boot=ON
# Cap XCom cache to prevent unbounded growth (default 1GB causes OOM)
group_replication_message_cache_size=134217728
# Reduce log buffer (16MB sufficient for this workload, was 64MB)
innodb_log_buffer_size=16777216
# Limit connections (peak usage ~40, no need for 151)
max_connections=80
# --- Disk write reduction (HDD/LVM thin) ---
# Flush redo log once per second, not per commit. Up to 1s data loss on MySQL crash,
# but group replication provides redundancy across 3 nodes.
innodb_flush_log_at_trx_commit=0
# OS decides when to flush binlog (not per commit)
sync_binlog=0
# HDD-tuned I/O capacity (default 200/2000 is for SSD)
innodb_io_capacity=100
innodb_io_capacity_max=200
# 1GB redo log capacity — larger log means less frequent checkpoint flushes
innodb_redo_log_capacity=1073741824
# 1GB buffer pool
innodb_buffer_pool_size=1073741824
# Disable doublewrite — halves write amplification. Safe with group replication
# (crashed node can re-clone from healthy replica rather than relying on local recovery)
innodb_doublewrite=OFF
# Flush neighbors on HDD (coalesce adjacent dirty pages into single I/O)
innodb_flush_neighbors=1
# Reduce page cleaner aggressiveness
innodb_lru_scan_depth=256
innodb_page_cleaners=1
# Reduce adaptive flushing — let dirty pages accumulate longer before background flush
innodb_adaptive_flushing_lwm=10
innodb_max_dirty_pages_pct=90
innodb_max_dirty_pages_pct_lwm=10
EOT
}
# Top-level resources apply to SIDECAR container
# VPA shows sidecar needs only 248Mi target / 334Mi upper bound
# Setting to 350Mi (was 2Gi/4Gi - 17× over-provisioned)
resources = {
requests = {
cpu = "250m"
memory = "350Mi"
}
limits = {
memory = "350Mi"
}
}
podSpec = {
affinity = {
nodeAffinity = {
requiredDuringSchedulingIgnoredDuringExecution = {
nodeSelectorTerms = [{
matchExpressions = [{
key = "kubernetes.io/hostname"
operator = "NotIn"
values = ["k8s-node1"]
}]
}]
}
}
podAntiAffinity = {
preferredDuringSchedulingIgnoredDuringExecution = [{
weight = 100
podAffinityTerm = {
labelSelector = {
matchLabels = {
"component" = "mysqld"
}
}
topologyKey = "kubernetes.io/hostname"
}
}]
}
}
# Container-specific resources for MYSQL container
# VPA shows 2.98Gi target / 5.26Gi upper bound
# Current usage ~1.8Gi peak. Reducing limit from 4Gi to 3Gi
containers = [
{
name = "mysql"
resources = {
requests = {
memory = "2Gi"
cpu = "250m"
}
limits = {
memory = "3Gi"
}
}
},
{
# MySQL operator sidecar (kopf Python control loop)
# VPA upper bound: 334Mi. Was 6Gi limit — 17× over-provisioned.
name = "sidecar"
resources = {
requests = {
memory = "350Mi"
cpu = "50m"
}
limits = {
memory = "512Mi"
}
}
}
]
initContainers = [
{
name = "fixdatadir"
resources = {
requests = { memory = "64Mi", cpu = "25m" }
limits = { memory = "64Mi" }
}
},
{
name = "initconf"
resources = {
requests = { memory = "256Mi", cpu = "50m" }
limits = { memory = "256Mi" }
}
},
{
name = "initmysql"
resources = {
requests = { memory = "512Mi", cpu = "250m" }
limits = { memory = "512Mi" }
}
}
]
}
# MySQL Router - explicitly set resources (chart does not expose router.resources)
# VPA shows 100Mi upper bound, setting to 128Mi
# Note: This requires manual kubectl patch after helm release:
# kubectl patch deployment mysql-cluster-router -n dbaas --type=json -p='[
# {"op": "replace", "path": "/spec/template/spec/containers/0/resources",
# "value": {"requests": {"cpu": "25m", "memory": "128Mi"}, "limits": {"memory": "128Mi"}}}]'
# TODO: migrate to mysql-operator fork or wait for upstream router.resources support
})]
depends_on = [helm_release.mysql_operator]
}
# Compatibility service: mysql.dbaas points at InnoDB Cluster mysqld pods
# When router is available it handles failover, but we fall back to direct
# mysqld access to avoid total outage during partial cluster failures
resource "kubernetes_service" "mysql" {
metadata {
name = var.cluster_master_service
namespace = kubernetes_namespace.dbaas.metadata[0].name
}
spec {
publish_not_ready_addresses = true # bypass InnoDB Cluster readiness gate during partial failures
selector = {
"component" = "mysqld"
"mysql.oracle.com/cluster" = "mysql-cluster"
"mysql.oracle.com/cluster-role" = "PRIMARY"
}
port {
port = 3306
target_port = 3306
}
}
depends_on = [helm_release.mysql_cluster]
}
module "nfs_mysql_backup_host" {
source = "../../../../modules/kubernetes/nfs_volume"
name = "dbaas-mysql-backup-host"
namespace = kubernetes_namespace.dbaas.metadata[0].name
nfs_server = "192.168.1.127"
nfs_path = "/srv/nfs/mysql-backup"
}
resource "kubernetes_persistent_volume_claim" "pgadmin_proxmox" {
wait_until_bound = false
metadata {
name = "dbaas-pgadmin-proxmox"
namespace = kubernetes_namespace.dbaas.metadata[0].name
annotations = {
"resize.topolvm.io/threshold" = "80%"
"resize.topolvm.io/increase" = "100%"
"resize.topolvm.io/storage_limit" = "5Gi"
}
}
spec {
access_modes = ["ReadWriteOnce"]
storage_class_name = "proxmox-lvm"
resources {
requests = {
storage = "1Gi"
}
}
}
}
module "nfs_postgresql_backup_host" {
source = "../../../../modules/kubernetes/nfs_volume"
name = "dbaas-postgresql-backup-host"
namespace = kubernetes_namespace.dbaas.metadata[0].name
nfs_server = "192.168.1.127"
nfs_path = "/srv/nfs/postgresql-backup"
}
resource "kubernetes_cron_job_v1" "mysql-backup" {
metadata {
name = "mysql-backup"
namespace = kubernetes_namespace.dbaas.metadata[0].name
}
spec {
concurrency_policy = "Replace"
failed_jobs_history_limit = 5
schedule = "30 0 * * *"
# schedule = "* * * * *"
starting_deadline_seconds = 10
successful_jobs_history_limit = 10
job_template {
metadata {}
spec {
backoff_limit = 3
ttl_seconds_after_finished = 10
template {
metadata {}
spec {
container {
name = "mysql-backup"
image = "docker.io/library/mysql:8.0"
env {
name = "MYSQL_PWD"
value_from {
secret_key_ref {
name = "cluster-secret"
key = "ROOT_PASSWORD"
}
}
}
command = ["/bin/bash", "-c", <<-EOT
set -euxo pipefail
_t0=$(date +%s)
_rb0=$(awk '/^read_bytes/{print $2}' /proc/$$/io 2>/dev/null || echo 0)
_wb0=$(awk '/^write_bytes/{print $2}' /proc/$$/io 2>/dev/null || echo 0)
export now=$(date +"%Y_%m_%d_%H_%M")
mysqldump --all-databases -u root --host mysql.dbaas.svc.cluster.local | gzip -9 > /backup/dump_$now.sql.gz
# Rotate — 14 day retention
cd /backup
find . -name "dump_*.sql.gz" -type f -mtime +14 -delete
find . -name "dump_*.sql" -type f -mtime +14 -delete # clean up old uncompressed
_dur=$(($(date +%s) - _t0))
_rb1=$(awk '/^read_bytes/{print $2}' /proc/$$/io 2>/dev/null || echo 0)
_wb1=$(awk '/^write_bytes/{print $2}' /proc/$$/io 2>/dev/null || echo 0)
echo "=== Backup IO Stats ==="
echo "duration: $${_dur}s"
echo "read: $(( (_rb1 - _rb0) / 1048576 )) MiB"
echo "written: $(( (_wb1 - _wb0) / 1048576 )) MiB"
echo "output: $(ls -lh /backup/dump_$now.sql.gz | awk '{print $5}')"
_out_bytes=$(stat -c%s /backup/dump_$now.sql.gz)
curl -sf --data-binary @- "http://prometheus-prometheus-pushgateway.monitoring:9091/metrics/job/mysql-backup" <<PGEOF || true
backup_duration_seconds $${_dur}
backup_read_bytes $(( _rb1 - _rb0 ))
backup_written_bytes $(( _wb1 - _wb0 ))
backup_output_bytes $${_out_bytes}
backup_last_success_timestamp $(date +%s)
PGEOF
EOT
]
# To restore (from outside of the cluster):
# run kubectl port-forward to pod e.g.:
# > kb port-forward mysql-647cfd4969-46rmw --address 0.0.0.0 3307:3306
# run mysql import (and specify non-localhost address to avoid using unix socket): (password is in tfvars)
# > mysql -u root -p --host 10.0.10.10 --port 3307 < /mnt/nfs/2024_01_06_13_54.sql
volume_mount {
name = "mysql-backup"
mount_path = "/backup"
}
}
volume {
name = "mysql-backup"
persistent_volume_claim {
claim_name = module.nfs_mysql_backup_host.claim_name
}
}
}
}
}
}
}
}
# resource "kubernetes_persistent_volume" "mysql" {
# metadata {
# name = "mysql-pv"
# }
# spec {
# capacity = {
# "storage" = "10Gi"
# }
# access_modes = ["ReadWriteOnce"]
# persistent_volume_source {
# iscsi {
# target_portal = "iscsi.viktorbarzin.lan:3260"
# iqn = "iqn.2020-12.lan.viktorbarzin:storage:dbaas:mysql"
# lun = 0
# fs_type = "ext4"
# }
# }
# }
# }
# resource "helm_release" "mysql" {
# namespace = kubernetes_namespace.dbaas.metadata[0].name
# create_namespace = false
# name = "mysql"
# repository = "https://presslabs.github.io/charts"
# chart = "mysql-operator"
# # version = "v0.5.0-rc.3"
# values = [templatefile("${path.module}/mysql_chart_values.yaml", { secretName = var.tls_secret_name })]
# atomic = true
# depends_on = [kubernetes_namespace.dbaas]
# }
# # resource "helm_release" "mysql" {
# # namespace = kubernetes_namespace.dbaas.metadata[0].name
# # create_namespace = false
# # name = "mysql-operator"
# # repository = "https://mysql.github.io/mysql-operator/"
# # chart = "mysql-operator"
# # atomic = true
# # depends_on = [kubernetes_namespace.dbaas]
# # }
# # resource "helm_release" "innodb-cluster" {
# # namespace = kubernetes_namespace.dbaas.metadata[0].name
# # create_namespace = false
# # name = var.cluster_master_service
# # repository = "https://mysql.github.io/mysql-operator/"
# # chart = "mysql-innodbcluster"
# # atomic = true
# # depends_on = [kubernetes_namespace.dbaas]
# # values = [templatefile("${path.module}/chart_values.tpl", { root_password = var.dbaas_root_password })]
# # }
# resource "kubernetes_persistent_volume" "mysql-operator" {
# metadata {
# name = "mysql-operator-pv"
# }
# spec {
# capacity = {
# "storage" = "1Gi"
# }
# access_modes = ["ReadWriteOnce"]
# persistent_volume_source {
# iscsi {
# target_portal = "iscsi.viktorbarzin.lan:3260"
# iqn = "iqn.2020-12.lan.viktorbarzin:storage:dbaas:operator"
# lun = 0
# fs_type = "ext4"
# }
# }
# }
# }
resource "kubernetes_secret" "cluster-password" {
metadata {
name = "cluster-secret"
namespace = kubernetes_namespace.dbaas.metadata[0].name
annotations = {
"reloader.stakater.com/match" = "true"
}
}
type = "Opaque"
data = {
"ROOT_PASSWORD" = var.dbaas_root_password
}
}
# resource "kubernetes_ingress_v1" "dbaas" {
# metadata {
# name = "orchestrator-ingress"
# namespace = kubernetes_namespace.dbaas.metadata[0].name
# annotations = {
# "kubernetes.io/ingress.class" = "nginx"
# "nginx.ingress.kubernetes.io/auth-tls-verify-client" = "on"
# "nginx.ingress.kubernetes.io/auth-tls-secret" = "default/ca-secret"
# }
# }
# spec {
# tls {
# hosts = ["db.viktorbarzin.me"]
# secret_name = var.tls_secret_name
# }
# rule {
# host = "db.viktorbarzin.me"
# http {
# path {
# path = "/"
# backend {
# service {
# name = "mysql-mysql-operator"
# port {
# number = 80
# }
# }
# }
# }
# }
# }
# }
# }
# PHPMyAdmin instance
resource "kubernetes_deployment" "phpmyadmin" {
metadata {
name = "phpmyadmin"
namespace = kubernetes_namespace.dbaas.metadata[0].name
labels = {
"app" = "phpmyadmin"
tier = var.tier
}
annotations = {
"reloader.stakater.com/search" = "true"
}
}
spec {
replicas = "1"
selector {
match_labels = {
"app" = "phpmyadmin"
}
}
template {
metadata {
labels = {
"app" = "phpmyadmin"
}
}
spec {
container {
name = "phpmyadmin"
image = "phpmyadmin/phpmyadmin:5.2.3"
port {
container_port = 80
}
env {
name = "PMA_HOST"
value = var.cluster_master_service
}
env {
name = "PMA_PORT"
value = "3306"
}
env {
name = "MYSQL_ROOT_PASSWORD"
value_from {
secret_key_ref {
name = "cluster-secret"
key = "ROOT_PASSWORD"
}
}
}
env {
name = "UPLOAD_LIMIT"
value = "300M"
}
resources {
requests = {
cpu = "15m"
memory = "100Mi"
}
limits = {
memory = "100Mi"
}
}
}
dns_config {
option {
name = "ndots"
value = "2"
}
}
}
}
}
}
resource "kubernetes_service" "phpmyadmin" {
metadata {
name = "pma"
namespace = kubernetes_namespace.dbaas.metadata[0].name
}
spec {
selector = {
"app" = "phpmyadmin"
}
port {
name = "web"
port = 80
}
}
}
module "ingress" {
source = "../../../../modules/kubernetes/ingress_factory"
namespace = kubernetes_namespace.dbaas.metadata[0].name
name = "pma"
tls_secret_name = var.tls_secret_name
protected = true
extra_annotations = {}
rybbit_site_id = "942c76b8bd4d"
custom_content_security_policy = "script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' https://rybbit.viktorbarzin.me"
}
# resource "kubectl_manifest" "mysql-cluster" {
# yaml_body = <<-YAML
# apiVersion: mysql.presslabs.org/v1alpha1
# kind: MysqlCluster
# metadata:
# name: mysql-cluster
# namespace = kubernetes_namespace.dbaas.metadata[0].name
# spec:
# mysqlVersion: "5.7"
# replicas: 1
# secretName: cluster-secret
# mysqlConf:
# # read_only: 0 # mysql forms a single transaction for each sql statement, autocommit for each statement
# # automatic_sp_privileges: "ON" # automatically grants the EXECUTE and ALTER ROUTINE privileges to the creator of a stored routine
# # auto_generate_certs: "ON" # Auto Generation of Certificate
# # auto_increment_increment: 1 # Auto Incrementing value from +1
# # auto_increment_offset: 1 # Auto Increment Offset
# # binlog-format: "STATEMENT" # contains various options such ROW(SLOW,SAFE) STATEMENT(FAST,UNSAFE), MIXED(combination of both)
# # wait_timeout: 31536000 # 28800 number of seconds the server waits for activity on a non-interactive connection before closing it, You might encounter MySQL server has gone away error, you then tweak this value acccordingly
# # interactive_timeout: 28800 # The number of seconds the server waits for activity on an interactive connection before closing it.
# # max_allowed_packet: "512M" # Maximum size of MYSQL Network protocol packet that the server can create or read 4MB, 8MB, 16MB, 32MB
# # max-binlog-size: 1073741824 # binary logs contains the events that describe database changes, this parameter describe size for the bin_log file.
# # log_output: "TABLE" # Format in which the logout will be dumped
# # master-info-repository: "TABLE" # Format in which the master info will be dumped
# # relay_log_info_repository: "TABLE" # Format in which the relay info will be dumped
# volumeSpec:
# persistentVolumeClaim:
# accessModes:
# - ReadWriteOnce
# resources:
# requests:
# storage: 10Gi
# YAML
# depends_on = [helm_release.mysql]
# # manifest = {
# # apiVersion = "mysql.presslabs.org/v1alpha1"
# # kind = "MysqlCluster"
# # metadata = {
# # name = "mysql-cluster"
# # namespace = kubernetes_namespace.dbaas.metadata[0].name
# # }
# # spec = {
# # mysqlVersion = "5.7"
# # replicas = 1
# # secretName = "cluster-secret"
# # mysqlConf = {
# # read_only = 0
# # }
# # volumeSpec = {
# # persistentVolumeClaim = {
# # resources = {
# # requests = {
# # storage = "10Gi"
# # }
# # }
# # }
# # }
# # }
# # }
# }
# For some unknwown reason not all CRDs are installed. Add them manually
# resource "kubectl_manifest" "mysql-user" {
# yaml_body = <<-EOF
# apiVersion: apiextensions.k8s.io/v1
# kind: CustomResourceDefinition
# metadata:
# annotations:
# controller-gen.kubebuilder.io/version: v0.5.0
# helm.sh/hook: crd-install
# name: mysqlusers.mysql.presslabs.org
# labels:
# app: mysql-operator
# spec:
# group: mysql.presslabs.org
# names:
# kind: MysqlUser
# listKind: MysqlUserList
# plural: mysqlusers
# singular: mysqluser
# scope:namespace = kubernetes_namespace.dbaas.metadata[0].name
# versions:
# - additionalPrinterColumns:
# - description: The user status
# jsonPath: .status.conditions[?(@.type == 'Ready')].status
# name: Ready
# type: string
# - jsonPath: .spec.clusterRef.name
# name: Cluster
# type: string
# - jsonPath: .spec.user
# name: UserName
# type: string
# - jsonPath: .metadata.creationTimestamp
# name: Age
# type: date
# name: v1alpha1
# schema:
# openAPIV3Schema:
# description: MysqlUser is the Schema for the MySQL User API
# properties:
# apiVersion:
# description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
# type: string
# kind:
# description: 'Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
# type: string
# metadata:
# type: object
# spec:
# description: MysqlUserSpec defines the desired state of MysqlUserSpec
# properties:
# allowedHosts:
# description: AllowedHosts is the allowed host to connect from.
# items:
# type: string
# type: array
# clusterRef:
# description: ClusterRef represents a reference to the MySQL cluster. This field should be immutable.
# properties:
# name:
# description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names TODO: Add other useful fields. apiVersion, kind, uid?'
# type: string
# namespace = kubernetes_namespace.dbaas.metadata[0].name
# description:namespace = kubernetes_namespace.dbaas.metadata[0].name
# type: string
# type: object
# password:
# description: Password is the password for the user.
# properties:
# key:
# description: The key of the secret to select from. Must be a valid secret key.
# type: string
# name:
# description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names TODO: Add other useful fields. apiVersion, kind, uid?'
# type: string
# optional:
# description: Specify whether the Secret or its key must be defined
# type: boolean
# required:
# - key
# type: object
# permissions:
# description: Permissions is the list of roles that user has in the specified database.
# items:
# description: MysqlPermission defines a MySQL schema permission
# properties:
# permissions:
# description: Permissions represents the permissions granted on the schema/tables
# items:
# type: string
# type: array
# schema:
# description: Schema represents the schema to which the permission applies
# type: string
# tables:
# description: Tables represents the tables inside the schema to which the permission applies
# items:
# type: string
# type: array
# required:
# - permissions
# - schema
# - tables
# type: object
# type: array
# resourceLimits:
# additionalProperties:
# anyOf:
# - type: integer
# - type: string
# pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
# x-kubernetes-int-or-string: true
# description: 'ResourceLimits allow settings limit per mysql user as defined here: https://dev.mysql.com/doc/refman/5.7/en/user-resources.html'
# type: object
# user:
# description: User is the name of the user that will be created with will access the specified database. This field should be immutable.
# type: string
# required:
# - allowedHosts
# - clusterRef
# - password
# - user
# type: object
# status:
# description: MysqlUserStatus defines the observed state of MysqlUser
# properties:
# allowedHosts:
# description: AllowedHosts contains the list of hosts that the user is allowed to connect from.
# items:
# type: string
# type: array
# conditions:
# description: Conditions represents the MysqlUser resource conditions list.
# items:
# description: MySQLUserCondition defines the condition struct for a MysqlUser resource
# properties:
# lastTransitionTime:
# description: Last time the condition transitioned from one status to another.
# format: date-time
# type: string
# lastUpdateTime:
# description: The last time this condition was updated.
# format: date-time
# type: string
# message:
# description: A human readable message indicating details about the transition.
# type: string
# reason:
# description: The reason for the condition's last transition.
# type: string
# status:
# description: Status of the condition, one of True, False, Unknown.
# type: string
# type:
# description: Type of MysqlUser condition.
# type: string
# required:
# - lastTransitionTime
# - message
# - reason
# - status
# - type
# type: object
# type: array
# type: object
# type: object
# served: true
# storage: true
# subresources:
# status: {}
# EOF
# }
#### POSTGRESQL — CloudNativePG Cluster
#
# Migrated from single NFS-backed pod to CNPG on local-path storage.
# CNPG Cluster is managed via kubectl apply (not kubernetes_manifest)
# because the CNPG webhook mutates the spec on apply, causing
# Terraform provider "inconsistent result" errors.
#
# Rollback: apply old deployment yaml, revert service selector to app=postgresql.
# Ensure the CNPG cluster manifest exists (idempotent kubectl apply)
resource "null_resource" "pg_cluster" {
triggers = {
instances = "2"
image = "ghcr.io/cloudnative-pg/postgis:16"
storage_size = "20Gi"
storage_class = "proxmox-lvm"
memory_limit = "2Gi"
pg_params = "v2-shared512-walcomp-workmem16"
}
provisioner "local-exec" {
command = <<-EOT
kubectl --kubeconfig ${var.kube_config_path} apply -f - <<'EOF'
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: pg-cluster
namespace: dbaas
spec:
instances: 2
imageName: ghcr.io/cloudnative-pg/postgis:16
postgresql:
parameters:
search_path: '"$user", public'
shared_buffers: "512MB"
effective_cache_size: "1536MB"
work_mem: "16MB"
wal_compression: "on"
random_page_cost: "4"
checkpoint_completion_target: "0.9"
enableAlterSystem: true
enableSuperuserAccess: true
inheritedMetadata:
annotations:
resize.topolvm.io/threshold: "80%"
resize.topolvm.io/increase: "20%"
resize.topolvm.io/storage_limit: "100Gi"
storage:
size: 20Gi
storageClass: proxmox-lvm
resources:
requests:
cpu: "50m"
memory: "2Gi"
limits:
memory: "2Gi"
EOF
EOT
}
}
# Service that maintains the original postgresql.dbaas endpoint,
# now pointing at the CNPG primary pod instead of the old deployment.
resource "kubernetes_service" "postgresql" {
metadata {
name = "postgresql"
namespace = kubernetes_namespace.dbaas.metadata[0].name
}
spec {
selector = {
"cnpg.io/cluster" = "pg-cluster"
"cnpg.io/instanceRole" = "primary"
}
port {
name = "postgresql"
port = 5432
target_port = 5432
}
}
}
# Old PostgreSQL deployment — kept commented for rollback reference
# resource "kubernetes_deployment" "postgres" {
# metadata {
# name = "postgresql"
# namespace = kubernetes_namespace.dbaas.metadata[0].name
# labels = { tier = var.tier }
# }
# spec {
# replicas = 0 # scaled to 0 during CNPG migration
# selector { match_labels = { app = "postgresql" } }
# strategy { type = "Recreate" }
# template {
# metadata { labels = { app = "postgresql" } }
# spec {
# container {
# image = "viktorbarzin/postgres:16-master"
# name = "postgresql"
# env { name = "POSTGRES_PASSWORD"; value = var.postgresql_root_password }
# env { name = "POSTGRES_USER"; value = "root" }
# port { container_port = 5432; protocol = "TCP"; name = "postgresql" }
# volume_mount { name = "postgresql-persistent-storage"; mount_path = "/var/lib/postgresql/data" }
# }
# volume {
# name = "postgresql-persistent-storage"
# nfs { path = "/mnt/main/postgresql/data"; server = var.nfs_server }
# }
# }
# }
# }
# }
#### PGADMIN
resource "kubernetes_deployment" "pgadmin" {
metadata {
name = "pgadmin"
namespace = kubernetes_namespace.dbaas.metadata[0].name
annotations = {
"reloader.stakater.com/search" = "true"
}
labels = {
tier = var.tier
}
}
spec {
strategy {
type = "Recreate"
}
selector {
match_labels = {
app = "pgadmin"
}
}
template {
metadata {
labels = {
app = "pgadmin"
}
}
spec {
container {
image = "dpage/pgadmin4"
name = "pgadmin"
env {
name = "PGADMIN_DEFAULT_EMAIL"
value = "me@viktorbarzin.me"
}
env {
name = "PGADMIN_DEFAULT_PASSWORD"
# Changed at startup
value = var.pgadmin_password
}
port {
container_port = 80
name = "web"
}
volume_mount {
name = "pgadmin"
mount_path = "/var/lib/pgadmin/"
}
resources {
requests = {
cpu = "25m"
memory = "450Mi"
}
limits = {
memory = "450Mi"
}
}
}
volume {
name = "pgadmin"
# config_map {
# name = "pgadmin-config"
# }
persistent_volume_claim {
claim_name = kubernetes_persistent_volume_claim.pgadmin_proxmox.metadata[0].name
}
}
dns_config {
option {
name = "ndots"
value = "2"
}
}
}
}
}
}
resource "kubernetes_service" "pgadmin" {
metadata {
name = "pgadmin"
namespace = kubernetes_namespace.dbaas.metadata[0].name
}
spec {
selector = {
"app" = "pgadmin"
}
port {
name = "pgadmin"
port = 80
}
}
}
module "ingress-pgadmin" {
source = "../../../../modules/kubernetes/ingress_factory"
namespace = kubernetes_namespace.dbaas.metadata[0].name
name = "pgadmin"
tls_secret_name = var.tls_secret_name
protected = true
rybbit_site_id = "7cef78e30485"
}
resource "kubernetes_cron_job_v1" "postgresql-backup" {
metadata {
name = "postgresql-backup"
namespace = kubernetes_namespace.dbaas.metadata[0].name
}
spec {
concurrency_policy = "Replace"
failed_jobs_history_limit = 5
schedule = "0 0 * * *"
# schedule = "* * * * *"
starting_deadline_seconds = 10
successful_jobs_history_limit = 10
job_template {
metadata {}
spec {
backoff_limit = 3
ttl_seconds_after_finished = 10
template {
metadata {}
spec {
container {
name = "postgresql-backup"
image = "docker.io/library/postgres:16.4-bullseye"
env {
name = "PGPASSWORD"
value_from {
secret_key_ref {
name = "pg-cluster-superuser"
key = "password"
}
}
}
command = ["/bin/bash", "-c", <<-EOT
set -euxo pipefail
apt-get update -qq && apt-get install -yqq curl >/dev/null 2>&1 || true
_t0=$(date +%s)
_rb0=$(awk '/^read_bytes/{print $2}' /proc/$$/io 2>/dev/null || echo 0)
_wb0=$(awk '/^write_bytes/{print $2}' /proc/$$/io 2>/dev/null || echo 0)
export now=$(date +"%Y_%m_%d_%H_%M")
PGPASSWORD=$PGPASSWORD pg_dumpall -h pg-cluster-rw.dbaas -U postgres | gzip -9 > /backup/dump_$now.sql.gz
# Rotate — 14 day retention
cd /backup
find . -name "dump_*.sql.gz" -type f -mtime +14 -delete
find . -name "dump_*.sql" -type f -mtime +14 -delete # clean up old uncompressed
_dur=$(($(date +%s) - _t0))
_rb1=$(awk '/^read_bytes/{print $2}' /proc/$$/io 2>/dev/null || echo 0)
_wb1=$(awk '/^write_bytes/{print $2}' /proc/$$/io 2>/dev/null || echo 0)
echo "=== Backup IO Stats ==="
echo "duration: $${_dur}s"
echo "read: $(( (_rb1 - _rb0) / 1048576 )) MiB"
echo "written: $(( (_wb1 - _wb0) / 1048576 )) MiB"
echo "output: $(ls -lh /backup/dump_$now.sql.gz | awk '{print $5}')"
_out_bytes=$(stat -c%s /backup/dump_$now.sql.gz)
curl -sf --data-binary @- "http://prometheus-prometheus-pushgateway.monitoring:9091/metrics/job/postgresql-backup" <<PGEOF || true
backup_duration_seconds $${_dur}
backup_read_bytes $(( _rb1 - _rb0 ))
backup_written_bytes $(( _wb1 - _wb0 ))
backup_output_bytes $${_out_bytes}
backup_last_success_timestamp $(date +%s)
PGEOF
EOT
]
volume_mount {
name = "postgresql-backup"
mount_path = "/backup"
}
}
volume {
name = "postgresql-backup"
persistent_volume_claim {
claim_name = module.nfs_postgresql_backup_host.claim_name
}
}
}
}
}
}
}
}