postiz: reconcile HCL to live (adopt unmerged stack config), keep parked
All checks were successful
ci/woodpecker/push/default Pipeline was successful
All checks were successful
ci/woodpecker/push/default Pipeline was successful
postiz's live deployment (Helm + Temporal + Elasticsearch + Authentik OIDC + static-DB password) came from the never-merged branch `wizard/postiz-cnpg-oidc`, so master's HCL was stale and a `terragrunt apply` would have DESTROYED the stack. This lands that postiz config to master so HCL == state == live (CI green; destroy-landmine gone). Kept PARKED (postiz + temporal replicas = 0): IG-via-postiz is Meta- blocked (it hardcodes retired Instagram scopes → OAuth "Invalid Scopes"), which is why it was parked; IG runs via the instagram-poster service. To revive later: flip postiz `replicaCount` + temporal `replicas` back to 1 and re-check image pins. Notes captured in this reconcile: - ES image pinned to 7.17.28 (the branch's 7.17.24 was a DOWNGRADE vs the live data → ES refused to start "cannot downgrade node 7.17.28→7.17.24"; caught + rolled back during this work). - The 4 Authentik resources (app/provider/group/binding) were re-imported into state (adopted, not recreated — no duplicate AK objects); the obsolete `external_secret_jwt` ExternalSecret was removed (Retain → its synced secret was kept). - Vault-side cleanup (removing the unused pg-postiz rotated role) is deliberately NOT included here — deferred, postiz uses a static secret/postiz database_url. State was already reconciled by a local `scripts/tg apply`; this commit is the HCL catch-up (CI re-apply is a no-op). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
250d0fc334
commit
8236ae309d
4 changed files with 512 additions and 101 deletions
112
stacks/postiz/authentik.tf
Normal file
112
stacks/postiz/authentik.tf
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
# Authentik OIDC for the Postiz UI (2026-06-16, issue #45).
|
||||||
|
#
|
||||||
|
# Postiz keeps the Authentik forward-auth outer gate (ingress auth="required")
|
||||||
|
# AND offers "Login with Authentik" via generic OIDC. Postiz cannot disable its
|
||||||
|
# own local-login endpoint, so forward-auth stays in front to keep that endpoint
|
||||||
|
# off the public internet; OIDC is the SSO path. Access is gated to the
|
||||||
|
# `Postiz Users` group (Viktor + Anca) bound to this application — non-members
|
||||||
|
# cannot complete the OIDC flow. confidential client (Postiz is a server-side
|
||||||
|
# web app and can hold the secret); the secret is passed into the Helm release.
|
||||||
|
#
|
||||||
|
# Provider/app/group pattern mirrors stacks/tripit/authentik.tf. RS256 signing
|
||||||
|
# key (the self-signed cert) — required or Authentik signs HS256 with an empty
|
||||||
|
# JWKS and token verification fails.
|
||||||
|
|
||||||
|
data "vault_kv_secret_v2" "authentik_tf" {
|
||||||
|
mount = "secret"
|
||||||
|
name = "authentik"
|
||||||
|
}
|
||||||
|
|
||||||
|
provider "authentik" {
|
||||||
|
url = "https://authentik.viktorbarzin.me"
|
||||||
|
token = data.vault_kv_secret_v2.authentik_tf.data["tf_api_token"]
|
||||||
|
}
|
||||||
|
|
||||||
|
data "authentik_flow" "default_authorization_implicit_consent" {
|
||||||
|
slug = "default-provider-authorization-implicit-consent"
|
||||||
|
}
|
||||||
|
|
||||||
|
data "authentik_flow" "default_provider_invalidation" {
|
||||||
|
slug = "default-provider-invalidation-flow"
|
||||||
|
}
|
||||||
|
|
||||||
|
data "authentik_certificate_key_pair" "signing" {
|
||||||
|
name = "authentik Self-signed Certificate"
|
||||||
|
}
|
||||||
|
|
||||||
|
data "authentik_property_mapping_provider_scope" "openid" {
|
||||||
|
managed = "goauthentik.io/providers/oauth2/scope-openid"
|
||||||
|
}
|
||||||
|
|
||||||
|
data "authentik_property_mapping_provider_scope" "profile" {
|
||||||
|
managed = "goauthentik.io/providers/oauth2/scope-profile"
|
||||||
|
}
|
||||||
|
|
||||||
|
data "authentik_property_mapping_provider_scope" "email" {
|
||||||
|
managed = "goauthentik.io/providers/oauth2/scope-email"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Usernames in this Authentik instance ARE the user's email (see
|
||||||
|
# stacks/authentik/t3-users.tf). Viktor + Anca are the only Postiz users.
|
||||||
|
data "authentik_user" "viktor" {
|
||||||
|
username = "vbarzin@gmail.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
data "authentik_user" "anca" {
|
||||||
|
username = "ancaelena98@gmail.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "authentik_provider_oauth2" "postiz" {
|
||||||
|
name = "postiz"
|
||||||
|
client_id = "postiz"
|
||||||
|
client_type = "confidential"
|
||||||
|
# sub = the user's email (stable). Postiz keys OIDC accounts by the subject;
|
||||||
|
# an email sub keeps Anca's and Viktor's identities stable across logins.
|
||||||
|
sub_mode = "user_email"
|
||||||
|
|
||||||
|
authorization_flow = data.authentik_flow.default_authorization_implicit_consent.id
|
||||||
|
invalidation_flow = data.authentik_flow.default_provider_invalidation.id
|
||||||
|
|
||||||
|
# Postiz hardcodes its OIDC redirect to ${FRONTEND_URL}/settings.
|
||||||
|
allowed_redirect_uris = [
|
||||||
|
{
|
||||||
|
matching_mode = "strict"
|
||||||
|
url = "https://postiz.viktorbarzin.me/settings"
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
access_token_validity = "hours=1"
|
||||||
|
refresh_token_validity = "days=30"
|
||||||
|
include_claims_in_id_token = true
|
||||||
|
signing_key = data.authentik_certificate_key_pair.signing.id
|
||||||
|
|
||||||
|
property_mappings = [
|
||||||
|
data.authentik_property_mapping_provider_scope.openid.id,
|
||||||
|
data.authentik_property_mapping_provider_scope.profile.id,
|
||||||
|
data.authentik_property_mapping_provider_scope.email.id,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "authentik_application" "postiz" {
|
||||||
|
name = "Postiz"
|
||||||
|
slug = "postiz"
|
||||||
|
protocol_provider = authentik_provider_oauth2.postiz.id
|
||||||
|
meta_launch_url = "https://postiz.viktorbarzin.me"
|
||||||
|
# "any" + at least one binding => only members of a bound group get access.
|
||||||
|
policy_engine_mode = "any"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Access gate: only these two users may complete the Postiz OIDC flow.
|
||||||
|
resource "authentik_group" "postiz_users" {
|
||||||
|
name = "Postiz Users"
|
||||||
|
users = [
|
||||||
|
data.authentik_user.viktor.id,
|
||||||
|
data.authentik_user.anca.id,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "authentik_policy_binding" "postiz_access" {
|
||||||
|
target = authentik_application.postiz.uuid
|
||||||
|
group = authentik_group.postiz_users.id
|
||||||
|
order = 0
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,8 @@ variable "tls_secret_name" {
|
||||||
variable "nfs_server" { type = string }
|
variable "nfs_server" { type = string }
|
||||||
|
|
||||||
module "postiz" {
|
module "postiz" {
|
||||||
source = "./modules/postiz"
|
source = "./modules/postiz"
|
||||||
tls_secret_name = var.tls_secret_name
|
tls_secret_name = var.tls_secret_name
|
||||||
tier = local.tiers.aux
|
tier = local.tiers.aux
|
||||||
|
oauth_client_secret = authentik_provider_oauth2.postiz.client_secret
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,17 @@
|
||||||
# Chart: oci://ghcr.io/gitroomhq/postiz-helmchart/charts/postiz (v1.0.5)
|
# Chart: oci://ghcr.io/gitroomhq/postiz-helmchart/charts/postiz (v1.0.5)
|
||||||
# App : ghcr.io/gitroomhq/postiz-app:v2.21.7
|
# App : ghcr.io/gitroomhq/postiz-app:v2.21.7
|
||||||
#
|
#
|
||||||
# Layout:
|
# Layout (2026-06-16 — migrated off the bundled subcharts onto shared infra):
|
||||||
# - Bundled Postgres + Redis (chart subcharts) — fine for v1.
|
# - Postgres: shared CNPG cluster (pg-cluster-rw.dbaas). The `postiz` role
|
||||||
|
# uses a STATIC password in Vault KV secret/postiz (DB-engine rotation for
|
||||||
|
# pg-postiz was removed — see stacks/vault), so the chart carries
|
||||||
|
# DATABASE_URL directly with no ESO-merge race / no Reloader requirement.
|
||||||
|
# - Redis: shared standalone redis-master.redis on logical DB index 11.
|
||||||
# - Local file storage for uploads on a `proxmox-lvm` PVC mounted at /uploads.
|
# - Local file storage for uploads on a `proxmox-lvm` PVC mounted at /uploads.
|
||||||
# - JWT_SECRET is sourced from Vault via ESO. The chart's helper-templated
|
# - All secret env (DATABASE_URL, JWT_SECRET, Meta OAuth app creds) is sourced
|
||||||
# Secret name is `<release>-secrets`; we pin `fullnameOverride: postiz` so
|
# from Vault and rendered into the chart's `secrets:` block. fullnameOverride
|
||||||
# the Secret resolves to `postiz-secrets`. The chart already mounts that
|
# pins the Secret/Service to `postiz` so the instagram-poster pipeline's
|
||||||
# Secret via `envFrom: secretRef: <fullname>-secrets`, so ESO patching the
|
# internal URL (http://postiz.postiz.svc.cluster.local) keeps resolving.
|
||||||
# same Secret with `creationPolicy: Merge` injects `JWT_SECRET` into the
|
|
||||||
# pod env without forking the chart.
|
|
||||||
# - OAuth credentials for Meta/X/LinkedIn etc. are NOT pre-seeded — Postiz
|
|
||||||
# stores those in its own DB once the user adds providers via the UI.
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
resource "kubernetes_namespace" "postiz" {
|
resource "kubernetes_namespace" "postiz" {
|
||||||
|
|
@ -66,65 +66,120 @@ resource "kubernetes_persistent_volume_claim" "uploads" {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# ExternalSecret: patches the chart-managed `postiz-secrets` Secret with
|
# Vault-sourced secret env for the chart's `secrets:` block. The values are
|
||||||
# JWT_SECRET pulled from Vault. `creationPolicy: Merge` means ESO will not
|
# static, so injecting them straight into the chart-managed Secret avoids the
|
||||||
# take ownership — it just adds/updates the keys it manages, leaving the
|
# ESO-merge-vs-helm-reset race and the Reloader requirement.
|
||||||
# Helm-owned Secret resource intact. The chart's deployment already wires
|
# secret/postiz -> database_url (shared CNPG; postiz role, static pw)
|
||||||
# this Secret in via `envFrom: secretRef: postiz-secrets`.
|
# secret/instagram-poster -> JWT + Facebook/Instagram OAuth app creds (the same
|
||||||
resource "kubernetes_manifest" "external_secret_jwt" {
|
# Vault keys the old ESO used; shared with the
|
||||||
field_manager {
|
# instagram-poster pipeline that drives the public API)
|
||||||
force_conflicts = true
|
data "vault_kv_secret_v2" "postiz" {
|
||||||
}
|
mount = "secret"
|
||||||
manifest = {
|
name = "postiz"
|
||||||
apiVersion = "external-secrets.io/v1"
|
|
||||||
kind = "ExternalSecret"
|
|
||||||
metadata = {
|
|
||||||
name = "postiz-jwt-secret"
|
|
||||||
namespace = kubernetes_namespace.postiz.metadata[0].name
|
|
||||||
}
|
|
||||||
spec = {
|
|
||||||
refreshInterval = "15m"
|
|
||||||
secretStoreRef = {
|
|
||||||
name = "vault-kv"
|
|
||||||
kind = "ClusterSecretStore"
|
|
||||||
}
|
|
||||||
target = {
|
|
||||||
name = "postiz-secrets"
|
|
||||||
creationPolicy = "Merge"
|
|
||||||
}
|
|
||||||
data = [
|
|
||||||
{
|
|
||||||
secretKey = "JWT_SECRET"
|
|
||||||
remoteRef = { key = "instagram-poster", property = "postiz_jwt_secret" }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
secretKey = "FACEBOOK_APP_ID"
|
|
||||||
remoteRef = { key = "instagram-poster", property = "facebook_app_id" }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
secretKey = "FACEBOOK_APP_SECRET"
|
|
||||||
remoteRef = { key = "instagram-poster", property = "facebook_app_secret" }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
secretKey = "INSTAGRAM_APP_ID"
|
|
||||||
remoteRef = { key = "instagram-poster", property = "instagram_app_id" }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
secretKey = "INSTAGRAM_APP_SECRET"
|
|
||||||
remoteRef = { key = "instagram-poster", property = "instagram_app_secret" }
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
depends_on = [kubernetes_namespace.postiz]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# helm_release.postiz is intentionally NOT managed by Terraform (2026-05-30).
|
data "vault_kv_secret_v2" "instagram_poster" {
|
||||||
# The release is stuck in pending-install; importing it would force a helm
|
mount = "secret"
|
||||||
# upgrade. Left Helm-managed outside TF. The bundled PG/Redis + the postiz
|
name = "instagram-poster"
|
||||||
# Deployment/Service it creates therefore aren't TF resources either — only
|
}
|
||||||
# the wrapper resources (namespace, PVC, ESO, ingresses, temporal Service,
|
|
||||||
# nfs backup, backup CronJob) are TF-managed.
|
# Postiz Helm release — Terraform-managed (2026-06-16), replacing the stuck
|
||||||
|
# out-of-band pending-install release. Bundled PG/Redis subcharts disabled; the
|
||||||
|
# app runs against shared CNPG + shared Redis. Chart name is `postiz-app`.
|
||||||
|
resource "helm_release" "postiz" {
|
||||||
|
name = "postiz"
|
||||||
|
namespace = kubernetes_namespace.postiz.metadata[0].name
|
||||||
|
repository = "oci://ghcr.io/gitroomhq/postiz-helmchart/charts"
|
||||||
|
chart = "postiz-app"
|
||||||
|
version = var.chart_version
|
||||||
|
# No atomic/auto-rollback on first install so a bad boot is debuggable, not
|
||||||
|
# silently rolled back. wait=false so the apply doesn't block on pod readiness.
|
||||||
|
atomic = false
|
||||||
|
wait = false
|
||||||
|
timeout = 600
|
||||||
|
|
||||||
|
values = [yamlencode({
|
||||||
|
fullnameOverride = "postiz"
|
||||||
|
# PARKED (2026-06-28): postiz + temporal scaled to 0 — IG-via-postiz is
|
||||||
|
# Meta-blocked (retired scopes); IG runs via the instagram-poster service.
|
||||||
|
# To revive: set this + temporal replicas back to 1 (and re-check image pins).
|
||||||
|
replicaCount = 0
|
||||||
|
image = {
|
||||||
|
repository = "ghcr.io/gitroomhq/postiz-app"
|
||||||
|
tag = var.image_tag
|
||||||
|
pullPolicy = "IfNotPresent"
|
||||||
|
}
|
||||||
|
service = {
|
||||||
|
type = "ClusterIP"
|
||||||
|
port = 80
|
||||||
|
}
|
||||||
|
# Bundled subcharts OFF — use shared CNPG + shared Redis instead.
|
||||||
|
postgresql = { enabled = false }
|
||||||
|
redis = { enabled = false }
|
||||||
|
|
||||||
|
resources = {
|
||||||
|
# request lowered 2Gi->1Gi so the tier-4-aux ns requests.memory quota (3Gi)
|
||||||
|
# fits postiz + temporal + the new Elasticsearch; limit stays 3Gi (Burstable).
|
||||||
|
requests = { cpu = "100m", memory = "1Gi" }
|
||||||
|
limits = { memory = "3Gi" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Non-secret env (chart renders these into the postiz-config ConfigMap).
|
||||||
|
env = {
|
||||||
|
MAIN_URL = "https://postiz.viktorbarzin.me"
|
||||||
|
FRONTEND_URL = "https://postiz.viktorbarzin.me"
|
||||||
|
NEXT_PUBLIC_BACKEND_URL = "https://postiz.viktorbarzin.me/api"
|
||||||
|
BACKEND_INTERNAL_URL = "http://localhost:3000"
|
||||||
|
TEMPORAL_ADDRESS = "temporal:7233"
|
||||||
|
STORAGE_PROVIDER = "local"
|
||||||
|
UPLOAD_DIRECTORY = "/uploads"
|
||||||
|
NEXT_PUBLIC_UPLOAD_DIRECTORY = "/uploads"
|
||||||
|
IS_GENERAL = "true"
|
||||||
|
NX_ADD_PLUGINS = "false"
|
||||||
|
DISABLE_REGISTRATION = "true"
|
||||||
|
# Only Instagram + Facebook are enabled (shared Meta app creds); every
|
||||||
|
# other provider stays disabled until its own OAuth app is registered.
|
||||||
|
DISABLED_PROVIDERS = "x,linkedin,reddit,threads,youtube,tiktok,pinterest,dribbble,slack,discord,mastodon,bluesky,lemmy,warpcast,vk,beehiiv,telegram,wordpress,nostr,farcaster"
|
||||||
|
|
||||||
|
# Authentik OIDC ("Login with Authentik") — provider/app in authentik.tf.
|
||||||
|
# This Postiz version reads only the AUTH/TOKEN/USERINFO URLs + client
|
||||||
|
# id/secret (scope is hardcoded openid/profile/email; redirect is fixed to
|
||||||
|
# ${FRONTEND_URL}/settings). The NEXT_PUBLIC display name drives the login
|
||||||
|
# button label (best-effort: baked at image build, set here in case the
|
||||||
|
# image re-injects NEXT_PUBLIC vars at start).
|
||||||
|
POSTIZ_GENERIC_OAUTH = "true"
|
||||||
|
POSTIZ_OAUTH_AUTH_URL = "https://authentik.viktorbarzin.me/application/o/authorize/"
|
||||||
|
POSTIZ_OAUTH_TOKEN_URL = "https://authentik.viktorbarzin.me/application/o/token/"
|
||||||
|
POSTIZ_OAUTH_USERINFO_URL = "https://authentik.viktorbarzin.me/application/o/userinfo/"
|
||||||
|
POSTIZ_OAUTH_CLIENT_ID = "postiz"
|
||||||
|
NEXT_PUBLIC_POSTIZ_OAUTH_DISPLAY_NAME = "Authentik"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Secret env (chart renders these into the postiz-secrets Secret, envFrom).
|
||||||
|
secrets = {
|
||||||
|
DATABASE_URL = data.vault_kv_secret_v2.postiz.data["database_url"]
|
||||||
|
REDIS_URL = "redis://redis-master.redis.svc.cluster.local:6379/11"
|
||||||
|
JWT_SECRET = data.vault_kv_secret_v2.instagram_poster.data["postiz_jwt_secret"]
|
||||||
|
FACEBOOK_APP_ID = data.vault_kv_secret_v2.instagram_poster.data["facebook_app_id"]
|
||||||
|
FACEBOOK_APP_SECRET = data.vault_kv_secret_v2.instagram_poster.data["facebook_app_secret"]
|
||||||
|
INSTAGRAM_APP_ID = data.vault_kv_secret_v2.instagram_poster.data["instagram_app_id"]
|
||||||
|
INSTAGRAM_APP_SECRET = data.vault_kv_secret_v2.instagram_poster.data["instagram_app_secret"]
|
||||||
|
POSTIZ_OAUTH_CLIENT_SECRET = var.oauth_client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
# Persist uploaded media on the existing proxmox-lvm PVC.
|
||||||
|
extraVolumes = [{
|
||||||
|
name = "uploads-volume"
|
||||||
|
persistentVolumeClaim = { claimName = kubernetes_persistent_volume_claim.uploads.metadata[0].name }
|
||||||
|
}]
|
||||||
|
extraVolumeMounts = [{
|
||||||
|
name = "uploads-volume"
|
||||||
|
mountPath = "/uploads"
|
||||||
|
}]
|
||||||
|
})]
|
||||||
|
|
||||||
|
depends_on = [kubernetes_namespace.postiz, module.tls_secret]
|
||||||
|
}
|
||||||
|
|
||||||
# Two ingresses on the same host. /uploads/* must be reachable WITHOUT auth
|
# Two ingresses on the same host. /uploads/* must be reachable WITHOUT auth
|
||||||
# so Meta's IG Graph API fetcher can pull the JPEG when Postiz hands it the
|
# so Meta's IG Graph API fetcher can pull the JPEG when Postiz hands it the
|
||||||
|
|
@ -167,14 +222,263 @@ module "ingress" {
|
||||||
}
|
}
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# Temporal — Postiz's scheduled-post backend. The Deployment is intentionally
|
# Temporal — Postiz's workflow backend. RESTORED 2026-06-16 (#44). Postiz's
|
||||||
# NOT managed here: it was removed from the cluster and postiz currently runs
|
# backend REFUSES to start its HTTP server unless temporal:7233 is reachable at
|
||||||
# without it (immediate posting works; scheduled posting does not). Only the
|
# boot — so this is required for Postiz to serve ANYTHING (login + the public
|
||||||
# Service below is retained/adopted so the in-cluster `temporal:7233` name
|
# API), not just scheduled posting. Runs temporalio/auto-setup against the shared
|
||||||
# still resolves. To restore scheduled posting, re-add a temporalio/auto-setup
|
# CNPG cluster with the SQL/Postgres visibility store (no Elasticsearch). The
|
||||||
# Deployment (see git history: removed 2026-05-30 during postiz state adoption).
|
# `temporal` + `temporal_visibility` DBs already exist and are owned by the
|
||||||
|
# `postiz` role, so SKIP_DB_CREATE=true + the role's static password is enough
|
||||||
|
# (auto-setup only creates/updates schema, which the DB owner can do; it is NOT
|
||||||
|
# superuser and must not attempt CREATE DATABASE).
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Elasticsearch — Temporal's VISIBILITY store (#44). Required: Postiz registers
|
||||||
|
# >3 custom Text search attributes, which Temporal's Postgres visibility caps at
|
||||||
|
# 3 ("cannot have more than 3 search attribute of type Text"). Postiz's upstream
|
||||||
|
# docker-compose uses ES for exactly this reason. Single-node, security off.
|
||||||
|
# `node.store.allow_mmap=false` avoids the vm.max_map_count bootstrap check so we
|
||||||
|
# don't need a privileged sysctl init-container (blocked by Kyverno wave-1).
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
resource "kubernetes_persistent_volume_claim" "es" {
|
||||||
|
wait_until_bound = false
|
||||||
|
metadata {
|
||||||
|
name = "postiz-es-data"
|
||||||
|
namespace = kubernetes_namespace.postiz.metadata[0].name
|
||||||
|
annotations = {
|
||||||
|
"resize.topolvm.io/threshold" = "10%"
|
||||||
|
"resize.topolvm.io/increase" = "100%"
|
||||||
|
"resize.topolvm.io/storage_limit" = "20Gi"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
spec {
|
||||||
|
access_modes = ["ReadWriteOnce"]
|
||||||
|
storage_class_name = "proxmox-lvm"
|
||||||
|
resources {
|
||||||
|
requests = { storage = "8Gi" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycle {
|
||||||
|
ignore_changes = [spec[0].resources[0].requests]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "kubernetes_deployment_v1" "es" {
|
||||||
|
metadata {
|
||||||
|
name = "elasticsearch"
|
||||||
|
namespace = kubernetes_namespace.postiz.metadata[0].name
|
||||||
|
labels = { app = "elasticsearch" }
|
||||||
|
}
|
||||||
|
spec {
|
||||||
|
replicas = 1
|
||||||
|
selector { match_labels = { app = "elasticsearch" } }
|
||||||
|
strategy { type = "Recreate" }
|
||||||
|
template {
|
||||||
|
metadata { labels = { app = "elasticsearch" } }
|
||||||
|
spec {
|
||||||
|
security_context { fs_group = 1000 } # ES runs as uid 1000; make the PVC writable
|
||||||
|
# proxmox-lvm CSI doesn't honor fsGroup, so the PVC mounts root-owned and
|
||||||
|
# ES (uid 1000) can't write its data dir. Chown it via a root init-container
|
||||||
|
# (not privileged → passes Kyverno deny-privileged).
|
||||||
|
init_container {
|
||||||
|
name = "fix-data-perms"
|
||||||
|
image = "busybox:1.36"
|
||||||
|
command = ["sh", "-c", "chown -R 1000:1000 /usr/share/elasticsearch/data"]
|
||||||
|
security_context { run_as_user = 0 }
|
||||||
|
volume_mount {
|
||||||
|
name = "data"
|
||||||
|
mount_path = "/usr/share/elasticsearch/data"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
container {
|
||||||
|
name = "elasticsearch"
|
||||||
|
# docker.io/ prefix → matches the Kyverno-trusted `docker.io/*` allowlist
|
||||||
|
# (Elastic also publishes the official image to Docker Hub). ES 7.17 == Temporal ES_VERSION=v7.
|
||||||
|
image = "docker.io/library/elasticsearch:7.17.28"
|
||||||
|
env {
|
||||||
|
name = "discovery.type"
|
||||||
|
value = "single-node"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "xpack.security.enabled"
|
||||||
|
value = "false"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "node.store.allow_mmap"
|
||||||
|
value = "false"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "cluster.routing.allocation.disk.threshold_enabled"
|
||||||
|
value = "false"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "ingest.geoip.downloader.enabled"
|
||||||
|
value = "false"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "ES_JAVA_OPTS"
|
||||||
|
value = "-Xms512m -Xmx512m"
|
||||||
|
}
|
||||||
|
port {
|
||||||
|
name = "http"
|
||||||
|
container_port = 9200
|
||||||
|
}
|
||||||
|
volume_mount {
|
||||||
|
name = "data"
|
||||||
|
mount_path = "/usr/share/elasticsearch/data"
|
||||||
|
}
|
||||||
|
resources {
|
||||||
|
requests = { cpu = "100m", memory = "1Gi" }
|
||||||
|
limits = { memory = "1536Mi" }
|
||||||
|
}
|
||||||
|
readiness_probe {
|
||||||
|
http_get {
|
||||||
|
path = "/_cluster/health?local=true"
|
||||||
|
port = 9200
|
||||||
|
}
|
||||||
|
initial_delay_seconds = 20
|
||||||
|
period_seconds = 10
|
||||||
|
failure_threshold = 18
|
||||||
|
}
|
||||||
|
}
|
||||||
|
volume {
|
||||||
|
name = "data"
|
||||||
|
persistent_volume_claim {
|
||||||
|
claim_name = kubernetes_persistent_volume_claim.es.metadata[0].name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycle {
|
||||||
|
ignore_changes = [spec[0].template[0].spec[0].dns_config] # KYVERNO_LIFECYCLE_V1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "kubernetes_service" "es" {
|
||||||
|
metadata {
|
||||||
|
name = "elasticsearch"
|
||||||
|
namespace = kubernetes_namespace.postiz.metadata[0].name
|
||||||
|
}
|
||||||
|
spec {
|
||||||
|
selector = { app = "elasticsearch" }
|
||||||
|
port {
|
||||||
|
name = "http"
|
||||||
|
port = 9200
|
||||||
|
target_port = 9200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "kubernetes_secret" "temporal_db" {
|
||||||
|
metadata {
|
||||||
|
name = "temporal-db"
|
||||||
|
namespace = kubernetes_namespace.postiz.metadata[0].name
|
||||||
|
}
|
||||||
|
data = {
|
||||||
|
POSTGRES_PWD = data.vault_kv_secret_v2.postiz.data["db_password"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "kubernetes_deployment_v1" "temporal" {
|
||||||
|
metadata {
|
||||||
|
name = "temporal"
|
||||||
|
namespace = kubernetes_namespace.postiz.metadata[0].name
|
||||||
|
labels = { app = "temporal" }
|
||||||
|
}
|
||||||
|
spec {
|
||||||
|
replicas = 0 # PARKED with postiz (2026-06-28) — revive: set to 1
|
||||||
|
selector { match_labels = { app = "temporal" } }
|
||||||
|
strategy { type = "Recreate" }
|
||||||
|
template {
|
||||||
|
metadata { labels = { app = "temporal" } }
|
||||||
|
spec {
|
||||||
|
container {
|
||||||
|
name = "temporal"
|
||||||
|
image = "temporalio/auto-setup:1.28.1"
|
||||||
|
|
||||||
|
env {
|
||||||
|
name = "DB"
|
||||||
|
value = "postgres12"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "SKIP_DB_CREATE"
|
||||||
|
value = "true"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "DBNAME"
|
||||||
|
value = "temporal"
|
||||||
|
}
|
||||||
|
# Visibility = Elasticsearch (Postiz needs >3 Text search attributes,
|
||||||
|
# which SQL visibility can't hold). Persistence stays on CNPG (DBNAME).
|
||||||
|
env {
|
||||||
|
name = "ENABLE_ES"
|
||||||
|
value = "true"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "ES_SEEDS"
|
||||||
|
value = "elasticsearch.postiz.svc.cluster.local"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "ES_VERSION"
|
||||||
|
value = "v7"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "ES_PORT"
|
||||||
|
value = "9200"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "ES_SCHEME"
|
||||||
|
value = "http"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "POSTGRES_SEEDS"
|
||||||
|
value = "pg-cluster-rw.dbaas.svc.cluster.local"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "DB_PORT"
|
||||||
|
value = "5432"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "POSTGRES_USER"
|
||||||
|
value = "postiz"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "POSTGRES_PWD"
|
||||||
|
value_from {
|
||||||
|
secret_key_ref {
|
||||||
|
name = kubernetes_secret.temporal_db.metadata[0].name
|
||||||
|
key = "POSTGRES_PWD"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
port {
|
||||||
|
name = "grpc"
|
||||||
|
container_port = 7233
|
||||||
|
}
|
||||||
|
resources {
|
||||||
|
requests = { cpu = "50m", memory = "256Mi" }
|
||||||
|
limits = { memory = "512Mi" }
|
||||||
|
}
|
||||||
|
readiness_probe {
|
||||||
|
tcp_socket { port = 7233 }
|
||||||
|
initial_delay_seconds = 20
|
||||||
|
period_seconds = 10
|
||||||
|
failure_threshold = 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
depends_on = [kubernetes_deployment_v1.es, kubernetes_service.es]
|
||||||
|
lifecycle {
|
||||||
|
ignore_changes = [spec[0].template[0].spec[0].dns_config] # KYVERNO_LIFECYCLE_V1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
resource "kubernetes_service" "temporal" {
|
resource "kubernetes_service" "temporal" {
|
||||||
metadata {
|
metadata {
|
||||||
name = "temporal"
|
name = "temporal"
|
||||||
|
|
@ -191,18 +495,17 @@ resource "kubernetes_service" "temporal" {
|
||||||
}
|
}
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# Backup CronJob — nightly pg_dump of the postiz database to NFS.
|
# Backup CronJob — nightly pg_dump of the bundled postiz-postgresql to NFS.
|
||||||
#
|
#
|
||||||
# Postiz's database lives on the SHARED CNPG cluster
|
# The bundled PostgreSQL StatefulSet uses local-path storage on the K8s node
|
||||||
# (pg-cluster-rw.dbaas.svc.cluster.local/postiz) — the chart's bundled
|
# OS disk (chart default), which is NOT covered by Layer 1 (LVM thin
|
||||||
# PostgreSQL was dropped in the CNPG migration, so the old `postiz-postgresql`
|
# snapshots) or Layer 2 (sda file backup) of the 3-2-1 pipeline. A pg_dump
|
||||||
# host no longer resolves (this CronJob was failing on it for weeks —
|
# CronJob writing to /srv/nfs/postiz-backup/ closes the gap: dumps land on
|
||||||
# BackupCronJobFailed; repointed 2026-06-26). The dump now connects via the
|
# Proxmox host NFS → covered by inotify-driven offsite sync to Synology.
|
||||||
# app's own DATABASE_URL (from the postiz-secrets Secret) so it always tracks
|
# Three databases are dumped: postiz (app data), temporal (workflow engine),
|
||||||
# the live host + credentials. Dumps land on /srv/nfs/postiz-backup/ → covered
|
# temporal_visibility (workflow search). Bitnami chart-default credentials
|
||||||
# by inotify-driven offsite sync to Synology, closing the gap (CNPG data PVCs
|
# are used — same creds the Postiz pod itself uses, scoped to the postiz
|
||||||
# live in dbaas, excluded from the LVM-snapshot leg). Only the postiz app DB is
|
# namespace via ClusterIP-only Services.
|
||||||
# dumped here; temporal's DBs are not.
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
module "nfs_backup_host" {
|
module "nfs_backup_host" {
|
||||||
|
|
@ -252,9 +555,10 @@ resource "kubernetes_cron_job_v1" "postgres_backup" {
|
||||||
STATUS=0
|
STATUS=0
|
||||||
for db in postiz; do
|
for db in postiz; do
|
||||||
echo "Dumping $db..."
|
echo "Dumping $db..."
|
||||||
if pg_dump -d "$DATABASE_URL" \
|
if PGPASSWORD=postiz-password pg_dump -h postiz-postgresql -U postiz \
|
||||||
--format=custom --compress=6 \
|
--format=custom --compress=6 \
|
||||||
--file="$BACKUP_DIR/$db-$TIMESTAMP.dump"; then
|
--file="$BACKUP_DIR/$db-$TIMESTAMP.dump" \
|
||||||
|
"$db"; then
|
||||||
echo " OK: $db ($(du -h "$BACKUP_DIR/$db-$TIMESTAMP.dump" | cut -f1))"
|
echo " OK: $db ($(du -h "$BACKUP_DIR/$db-$TIMESTAMP.dump" | cut -f1))"
|
||||||
else
|
else
|
||||||
echo " FAIL: $db" >&2
|
echo " FAIL: $db" >&2
|
||||||
|
|
@ -271,18 +575,6 @@ resource "kubernetes_cron_job_v1" "postgres_backup" {
|
||||||
exit $STATUS
|
exit $STATUS
|
||||||
EOT
|
EOT
|
||||||
]
|
]
|
||||||
# Connect to the live CNPG database using the app's own
|
|
||||||
# DATABASE_URL (postgresql://postiz:…@pg-cluster-rw.dbaas…/postiz)
|
|
||||||
# instead of a hardcoded host/password — survives credential changes.
|
|
||||||
env {
|
|
||||||
name = "DATABASE_URL"
|
|
||||||
value_from {
|
|
||||||
secret_key_ref {
|
|
||||||
name = "postiz-secrets"
|
|
||||||
key = "DATABASE_URL"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
volume_mount {
|
volume_mount {
|
||||||
name = "backup"
|
name = "backup"
|
||||||
mount_path = "/backup"
|
mount_path = "/backup"
|
||||||
|
|
|
||||||
|
|
@ -38,3 +38,9 @@ variable "storage_size" {
|
||||||
default = "20Gi"
|
default = "20Gi"
|
||||||
description = "Persistent volume size for /uploads."
|
description = "Persistent volume size for /uploads."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
variable "oauth_client_secret" {
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
description = "Authentik OIDC client secret for Postiz generic OAuth (from authentik_provider_oauth2.postiz)."
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue