add postiz + instagram-poster stacks for IG Stories pipeline
New stacks: - stacks/postiz/ — Postiz scheduler (Helm chart v1.0.5, image v2.21.7) with bundled PG/Redis, /uploads PVC on proxmox-lvm, JWT_SECRET via ESO from secret/instagram-poster. - stacks/instagram-poster/ — custom Python service that polls Immich for the 'instagram' tag, reformats photos to 9:16 with blurred-bg letterbox, exposes /image/<asset_id> publicly so Postiz can fetch. Image: forgejo.viktorbarzin.me/viktor/instagram-poster. n8n: 3 new workflows (discover, approval, post) for the Telegram inline-button approval UX. Adds ExternalSecret + env vars for TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, IMMICH_API_KEY, plus static URLs for the new service. Vault: seed secret/instagram-poster with telegram_bot_token, telegram_chat_id, immich_api_key, postiz_api_token, postiz_jwt_secret before applying.
This commit is contained in:
parent
badc341669
commit
73eb01f994
14 changed files with 1276 additions and 0 deletions
17
stacks/instagram-poster/main.tf
Normal file
17
stacks/instagram-poster/main.tf
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
variable "tls_secret_name" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "image_tag" {
|
||||
type = string
|
||||
default = "latest"
|
||||
description = "instagram-poster image tag. Use 8-char git SHA in CI; :latest only for local trials."
|
||||
}
|
||||
|
||||
module "instagram_poster" {
|
||||
source = "./modules/instagram-poster"
|
||||
tier = local.tiers.aux
|
||||
tls_secret_name = var.tls_secret_name
|
||||
image_tag = var.image_tag
|
||||
}
|
||||
275
stacks/instagram-poster/modules/instagram-poster/main.tf
Normal file
275
stacks/instagram-poster/modules/instagram-poster/main.tf
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
locals {
|
||||
namespace = "instagram-poster"
|
||||
# Forgejo registry consolidation (2026-05-07): all custom service images
|
||||
# live under forgejo.viktorbarzin.me/viktor/<name>. The old 10.0.20.10
|
||||
# private registry was decommissioned the same day.
|
||||
image = "forgejo.viktorbarzin.me/viktor/instagram-poster:${var.image_tag}"
|
||||
labels = {
|
||||
app = "instagram-poster"
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_namespace" "instagram_poster" {
|
||||
metadata {
|
||||
name = local.namespace
|
||||
labels = {
|
||||
tier = var.tier
|
||||
"istio-injection" = "disabled"
|
||||
}
|
||||
}
|
||||
lifecycle {
|
||||
# KYVERNO_LIFECYCLE_V1: goldilocks-vpa-auto-mode ClusterPolicy stamps this label on every namespace
|
||||
ignore_changes = [metadata[0].labels["goldilocks.fairwinds.com/vpa-update-mode"]]
|
||||
}
|
||||
}
|
||||
|
||||
# App secrets sourced from Vault KV `secret/instagram-poster`.
|
||||
# Seed these manually in Vault before applying:
|
||||
# secret/instagram-poster -> properties:
|
||||
# - immich_api_key (required)
|
||||
# - postiz_api_token (required)
|
||||
# - immich_tag_instagram (optional — auto-resolved if missing)
|
||||
# - immich_tag_posted (optional — auto-resolved if missing)
|
||||
resource "kubernetes_manifest" "external_secret" {
|
||||
manifest = {
|
||||
apiVersion = "external-secrets.io/v1beta1"
|
||||
kind = "ExternalSecret"
|
||||
metadata = {
|
||||
name = "instagram-poster-secrets"
|
||||
namespace = local.namespace
|
||||
}
|
||||
spec = {
|
||||
refreshInterval = "15m"
|
||||
secretStoreRef = {
|
||||
name = "vault-kv"
|
||||
kind = "ClusterSecretStore"
|
||||
}
|
||||
target = {
|
||||
name = "instagram-poster-secrets"
|
||||
template = {
|
||||
metadata = {
|
||||
annotations = {
|
||||
"reloader.stakater.com/match" = "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
data = [
|
||||
{
|
||||
secretKey = "IMMICH_API_KEY"
|
||||
remoteRef = {
|
||||
key = "instagram-poster"
|
||||
property = "immich_api_key"
|
||||
}
|
||||
},
|
||||
{
|
||||
secretKey = "POSTIZ_API_TOKEN"
|
||||
remoteRef = {
|
||||
key = "instagram-poster"
|
||||
property = "postiz_api_token"
|
||||
}
|
||||
},
|
||||
{
|
||||
secretKey = "IMMICH_TAG_INSTAGRAM"
|
||||
remoteRef = {
|
||||
key = "instagram-poster"
|
||||
property = "immich_tag_instagram"
|
||||
}
|
||||
},
|
||||
{
|
||||
secretKey = "IMMICH_TAG_POSTED"
|
||||
remoteRef = {
|
||||
key = "instagram-poster"
|
||||
property = "immich_tag_posted"
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
depends_on = [kubernetes_namespace.instagram_poster]
|
||||
}
|
||||
|
||||
# Persistent state: SQLite + image cache. Sensitive (API tokens may end up
|
||||
# in cached images / debug logs), but the proxmox-lvm-encrypted SC is for
|
||||
# user-data DBs; this is a small app cache so plain proxmox-lvm fits the
|
||||
# infra/.claude/CLAUDE.md decision rule.
|
||||
resource "kubernetes_persistent_volume_claim" "data" {
|
||||
wait_until_bound = false
|
||||
metadata {
|
||||
name = "instagram-poster-data"
|
||||
namespace = kubernetes_namespace.instagram_poster.metadata[0].name
|
||||
annotations = {
|
||||
"resize.topolvm.io/threshold" = "80%"
|
||||
"resize.topolvm.io/increase" = "100%"
|
||||
"resize.topolvm.io/storage_limit" = "20Gi"
|
||||
}
|
||||
}
|
||||
spec {
|
||||
access_modes = ["ReadWriteOnce"]
|
||||
storage_class_name = "proxmox-lvm"
|
||||
resources {
|
||||
requests = {
|
||||
storage = "10Gi"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_deployment" "instagram_poster" {
|
||||
metadata {
|
||||
name = "instagram-poster"
|
||||
namespace = kubernetes_namespace.instagram_poster.metadata[0].name
|
||||
labels = merge(local.labels, {
|
||||
tier = var.tier
|
||||
})
|
||||
annotations = {
|
||||
"reloader.stakater.com/search" = "true"
|
||||
}
|
||||
}
|
||||
|
||||
spec {
|
||||
replicas = 1
|
||||
# RWO PVC — cannot rolling-update.
|
||||
strategy {
|
||||
type = "Recreate"
|
||||
}
|
||||
|
||||
selector {
|
||||
match_labels = local.labels
|
||||
}
|
||||
|
||||
template {
|
||||
metadata {
|
||||
labels = local.labels
|
||||
annotations = {
|
||||
# Diun watches this image tag and POSTs the auto-upgrade pipeline.
|
||||
"diun.enable" = "true"
|
||||
}
|
||||
}
|
||||
|
||||
spec {
|
||||
image_pull_secrets {
|
||||
name = "registry-credentials"
|
||||
}
|
||||
|
||||
container {
|
||||
name = "instagram-poster"
|
||||
image = local.image
|
||||
|
||||
port {
|
||||
container_port = 8000
|
||||
}
|
||||
|
||||
env_from {
|
||||
secret_ref {
|
||||
name = "instagram-poster-secrets"
|
||||
}
|
||||
}
|
||||
|
||||
env {
|
||||
name = "IMMICH_BASE_URL"
|
||||
value = "https://immich.viktorbarzin.me"
|
||||
}
|
||||
env {
|
||||
name = "POSTIZ_BASE_URL"
|
||||
value = "http://postiz.postiz.svc.cluster.local"
|
||||
}
|
||||
env {
|
||||
name = "PUBLIC_BASE_URL"
|
||||
value = "https://instagram-poster.viktorbarzin.me"
|
||||
}
|
||||
env {
|
||||
name = "DATA_DIR"
|
||||
value = "/data"
|
||||
}
|
||||
env {
|
||||
name = "LOG_LEVEL"
|
||||
value = "INFO"
|
||||
}
|
||||
|
||||
volume_mount {
|
||||
name = "data"
|
||||
mount_path = "/data"
|
||||
}
|
||||
|
||||
readiness_probe {
|
||||
http_get {
|
||||
path = "/healthz"
|
||||
port = 8000
|
||||
}
|
||||
initial_delay_seconds = 5
|
||||
period_seconds = 10
|
||||
}
|
||||
|
||||
liveness_probe {
|
||||
http_get {
|
||||
path = "/healthz"
|
||||
port = 8000
|
||||
}
|
||||
initial_delay_seconds = 15
|
||||
period_seconds = 20
|
||||
}
|
||||
|
||||
resources {
|
||||
requests = {
|
||||
cpu = "50m"
|
||||
memory = "64Mi"
|
||||
}
|
||||
limits = {
|
||||
memory = "512Mi"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
volume {
|
||||
name = "data"
|
||||
persistent_volume_claim {
|
||||
claim_name = kubernetes_persistent_volume_claim.data.metadata[0].name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [spec[0].template[0].spec[0].dns_config] # KYVERNO_LIFECYCLE_V1
|
||||
}
|
||||
|
||||
depends_on = [
|
||||
kubernetes_manifest.external_secret,
|
||||
]
|
||||
}
|
||||
|
||||
resource "kubernetes_service" "instagram_poster" {
|
||||
metadata {
|
||||
name = "instagram-poster"
|
||||
namespace = kubernetes_namespace.instagram_poster.metadata[0].name
|
||||
labels = local.labels
|
||||
}
|
||||
|
||||
spec {
|
||||
type = "ClusterIP"
|
||||
selector = local.labels
|
||||
|
||||
port {
|
||||
name = "http"
|
||||
port = 80
|
||||
target_port = 8000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Public ingress. No UI — entire host is API-only and Meta needs to fetch
|
||||
# /image/<asset_id> unauthenticated to render preview cards. We therefore
|
||||
# leave `protected = false` so Authentik forward-auth doesn't run on any
|
||||
# path. Inbound auth is the API's own concern (Postiz webhook signature
|
||||
# / shared secret as configured by the parallel agent).
|
||||
module "ingress" {
|
||||
source = "../../../../modules/kubernetes/ingress_factory"
|
||||
dns_type = "proxied"
|
||||
namespace = kubernetes_namespace.instagram_poster.metadata[0].name
|
||||
name = "instagram-poster"
|
||||
tls_secret_name = var.tls_secret_name
|
||||
protected = false
|
||||
port = 80
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
variable "tls_secret_name" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "image_tag" {
|
||||
type = string
|
||||
default = "latest"
|
||||
description = "instagram-poster image tag. Use 8-char git SHA in CI; :latest only for local trials."
|
||||
}
|
||||
|
||||
variable "tier" {
|
||||
type = string
|
||||
default = "4-aux"
|
||||
}
|
||||
1
stacks/instagram-poster/secrets
Symbolic link
1
stacks/instagram-poster/secrets
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../secrets
|
||||
23
stacks/instagram-poster/terragrunt.hcl
Normal file
23
stacks/instagram-poster/terragrunt.hcl
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
include "root" {
|
||||
path = find_in_parent_folders()
|
||||
}
|
||||
|
||||
dependency "platform" {
|
||||
config_path = "../platform"
|
||||
skip_outputs = true
|
||||
}
|
||||
|
||||
dependency "vault" {
|
||||
config_path = "../vault"
|
||||
skip_outputs = true
|
||||
}
|
||||
|
||||
dependency "external-secrets" {
|
||||
config_path = "../external-secrets"
|
||||
skip_outputs = true
|
||||
}
|
||||
|
||||
inputs = {
|
||||
# Bump per deploy. Use 8-char git SHA in CI; :latest only for local trials.
|
||||
image_tag = "latest"
|
||||
}
|
||||
|
|
@ -80,6 +80,44 @@ resource "kubernetes_manifest" "external_secret_claude_agent" {
|
|||
depends_on = [kubernetes_namespace.n8n]
|
||||
}
|
||||
|
||||
# Shared secrets for the Immich → Telegram → Postiz Instagram pipeline.
|
||||
# Workflows in stacks/n8n/workflows/instagram-*.json reference these env vars.
|
||||
resource "kubernetes_manifest" "external_secret_instagram_pipeline" {
|
||||
manifest = {
|
||||
apiVersion = "external-secrets.io/v1beta1"
|
||||
kind = "ExternalSecret"
|
||||
metadata = {
|
||||
name = "instagram-pipeline-secrets"
|
||||
namespace = "n8n"
|
||||
}
|
||||
spec = {
|
||||
refreshInterval = "15m"
|
||||
secretStoreRef = {
|
||||
name = "vault-kv"
|
||||
kind = "ClusterSecretStore"
|
||||
}
|
||||
target = {
|
||||
name = "instagram-pipeline-secrets"
|
||||
}
|
||||
data = [
|
||||
{
|
||||
secretKey = "telegram_bot_token"
|
||||
remoteRef = { key = "instagram-poster", property = "telegram_bot_token" }
|
||||
},
|
||||
{
|
||||
secretKey = "telegram_chat_id"
|
||||
remoteRef = { key = "instagram-poster", property = "telegram_chat_id" }
|
||||
},
|
||||
{
|
||||
secretKey = "immich_api_key"
|
||||
remoteRef = { key = "instagram-poster", property = "immich_api_key" }
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
depends_on = [kubernetes_namespace.n8n]
|
||||
}
|
||||
|
||||
resource "kubernetes_persistent_volume_claim" "data_encrypted" {
|
||||
wait_until_bound = false
|
||||
metadata {
|
||||
|
|
@ -253,6 +291,47 @@ resource "kubernetes_deployment" "n8n" {
|
|||
name = "N8N_BLOCK_ENV_ACCESS_IN_NODE"
|
||||
value = "false"
|
||||
}
|
||||
# Instagram pipeline env (consumed by workflows in
|
||||
# stacks/n8n/workflows/instagram-*.json).
|
||||
env {
|
||||
name = "TELEGRAM_BOT_TOKEN"
|
||||
value_from {
|
||||
secret_key_ref {
|
||||
name = "instagram-pipeline-secrets"
|
||||
key = "telegram_bot_token"
|
||||
}
|
||||
}
|
||||
}
|
||||
env {
|
||||
name = "TELEGRAM_CHAT_ID"
|
||||
value_from {
|
||||
secret_key_ref {
|
||||
name = "instagram-pipeline-secrets"
|
||||
key = "telegram_chat_id"
|
||||
}
|
||||
}
|
||||
}
|
||||
env {
|
||||
name = "IMMICH_API_KEY"
|
||||
value_from {
|
||||
secret_key_ref {
|
||||
name = "instagram-pipeline-secrets"
|
||||
key = "immich_api_key"
|
||||
}
|
||||
}
|
||||
}
|
||||
env {
|
||||
name = "IMMICH_BASE_URL"
|
||||
value = "https://immich.viktorbarzin.me"
|
||||
}
|
||||
env {
|
||||
name = "INSTAGRAM_POSTER_INTERNAL_URL"
|
||||
value = "http://instagram-poster.instagram-poster.svc.cluster.local"
|
||||
}
|
||||
env {
|
||||
name = "PUBLIC_INSTAGRAM_POSTER_URL"
|
||||
value = "https://instagram-poster.viktorbarzin.me"
|
||||
}
|
||||
volume_mount {
|
||||
name = "data"
|
||||
mount_path = "/home/node/.n8n"
|
||||
|
|
|
|||
269
stacks/n8n/workflows/instagram-approval.json
Normal file
269
stacks/n8n/workflows/instagram-approval.json
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
{
|
||||
"name": "Instagram Approval",
|
||||
"active": true,
|
||||
"id": "483773c0-0b62-4ae5-b1b1-345f5df7b133",
|
||||
"versionId": "483773c0-0b62-4ae5-b1b1-345f5df7b133",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"updates": ["callback_query"],
|
||||
"additionalFields": {}
|
||||
},
|
||||
"id": "telegram-trigger",
|
||||
"name": "Telegram callback_query",
|
||||
"type": "n8n-nodes-base.telegramTrigger",
|
||||
"typeVersion": 1.1,
|
||||
"position": [250, 400],
|
||||
"webhookId": "f2c7c254-ebaf-4f66-b1b4-5c1629c07e08",
|
||||
"credentials": {
|
||||
"telegramApi": {
|
||||
"id": "telegram-bot",
|
||||
"name": "Telegram Bot"
|
||||
}
|
||||
},
|
||||
"notes": "Listens for inline-button taps. Requires a Telegram credential bound to the same bot whose token is in TELEGRAM_BOT_TOKEN."
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const cb = $input.first().json.callback_query || {};\nconst data = cb.data || '';\nconst [action, assetId] = data.split(':');\nconst message = cb.message || {};\nconst chatId = (message.chat || {}).id;\nconst messageId = message.message_id;\nconst originalCaption = message.caption || '';\nconst callbackQueryId = cb.id;\n\nif (!action || !assetId) {\n throw new Error('Malformed callback_data: ' + data);\n}\n\nreturn [{\n json: {\n action,\n asset_id: assetId,\n chat_id: chatId,\n message_id: messageId,\n original_caption: originalCaption,\n callback_query_id: callbackQueryId\n }\n}];"
|
||||
},
|
||||
"id": "parse-callback",
|
||||
"name": "Parse callback_data",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [470, 400],
|
||||
"notes": "Splits callback_data into action + asset_id, captures chat_id/message_id/caption for later edits and answerCallbackQuery."
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"rules": {
|
||||
"values": [
|
||||
{
|
||||
"conditions": {
|
||||
"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "strict"},
|
||||
"conditions": [{"id": "is-approve", "leftValue": "={{ $json.action }}", "rightValue": "approve", "operator": {"type": "string", "operation": "equals"}}],
|
||||
"combinator": "and"
|
||||
},
|
||||
"outputKey": "approve"
|
||||
},
|
||||
{
|
||||
"conditions": {
|
||||
"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "strict"},
|
||||
"conditions": [{"id": "is-reject", "leftValue": "={{ $json.action }}", "rightValue": "reject", "operator": {"type": "string", "operation": "equals"}}],
|
||||
"combinator": "and"
|
||||
},
|
||||
"outputKey": "reject"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "switch-action",
|
||||
"name": "Switch on action",
|
||||
"type": "n8n-nodes-base.switch",
|
||||
"typeVersion": 3.2,
|
||||
"position": [690, 400],
|
||||
"notes": "Branches on action; unknown actions fall through and are dropped."
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "={{ $env.INSTAGRAM_POSTER_INTERNAL_URL }}/enqueue",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{"name": "Authorization", "value": "=Bearer {{ $env.INSTAGRAM_POSTER_TOKEN }}"},
|
||||
{"name": "Content-Type", "value": "application/json"}
|
||||
]
|
||||
},
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ asset_id: $json.asset_id }) }}",
|
||||
"options": {"timeout": 30000}
|
||||
},
|
||||
"id": "approve-enqueue",
|
||||
"name": "Approve: enqueue asset",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [910, 250],
|
||||
"onError": "continueErrorOutput",
|
||||
"notes": "Calls instagram-poster /enqueue. continueErrorOutput so we can fall through to a Telegram error message on failure."
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "={{ $env.INSTAGRAM_POSTER_INTERNAL_URL }}/reject",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{"name": "Authorization", "value": "=Bearer {{ $env.INSTAGRAM_POSTER_TOKEN }}"},
|
||||
{"name": "Content-Type", "value": "application/json"}
|
||||
]
|
||||
},
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ asset_id: $json.asset_id }) }}",
|
||||
"options": {"timeout": 30000}
|
||||
},
|
||||
"id": "reject-mark",
|
||||
"name": "Reject: mark seen",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [910, 550],
|
||||
"onError": "continueErrorOutput",
|
||||
"notes": "Calls instagram-poster /reject so the asset is recorded and not re-surfaced."
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const upstream = $('Parse callback_data').item.json;\nconst suffix = '\\n\\nQueued for posting';\nreturn [{ json: { ...upstream, new_caption: (upstream.original_caption || '') + suffix } }];"
|
||||
},
|
||||
"id": "approve-caption",
|
||||
"name": "Approve: build new caption",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [1130, 250],
|
||||
"notes": "Append a confirmation suffix to the original caption."
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const upstream = $('Parse callback_data').item.json;\nconst suffix = '\\n\\nRejected';\nreturn [{ json: { ...upstream, new_caption: (upstream.original_caption || '') + suffix } }];"
|
||||
},
|
||||
"id": "reject-caption",
|
||||
"name": "Reject: build new caption",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [1130, 550],
|
||||
"notes": "Append rejection suffix to the original caption."
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/editMessageCaption",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{"name": "Content-Type", "value": "application/json"}
|
||||
]
|
||||
},
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ chat_id: $json.chat_id, message_id: $json.message_id, caption: $json.new_caption, parse_mode: 'HTML' }) }}",
|
||||
"options": {"timeout": 30000}
|
||||
},
|
||||
"id": "edit-caption",
|
||||
"name": "Telegram editMessageCaption",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [1350, 400],
|
||||
"notes": "Updates the original DM caption to show the resulting state. Strips inline buttons in the same call by omitting reply_markup combined with the next node."
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/editMessageReplyMarkup",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{"name": "Content-Type", "value": "application/json"}
|
||||
]
|
||||
},
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ chat_id: $json.chat_id, message_id: $json.message_id, reply_markup: { inline_keyboard: [] } }) }}",
|
||||
"options": {"timeout": 30000}
|
||||
},
|
||||
"id": "edit-reply-markup",
|
||||
"name": "Telegram editMessageReplyMarkup",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [1570, 400],
|
||||
"notes": "Strips the inline approve/reject buttons so the original DM no longer offers them after a decision."
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/answerCallbackQuery",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{"name": "Content-Type", "value": "application/json"}
|
||||
]
|
||||
},
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ callback_query_id: $json.callback_query_id, text: 'Recorded' }) }}",
|
||||
"options": {"timeout": 15000}
|
||||
},
|
||||
"id": "answer-callback",
|
||||
"name": "Telegram answerCallbackQuery",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [1790, 400],
|
||||
"notes": "Dismisses the loading spinner on the user's tap."
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const cb = $('Parse callback_data').item.json;\nconst err = $input.first().json.error || $input.first().json;\nconst msg = (err && (err.message || err.description || JSON.stringify(err))) || 'unknown error';\nreturn [{ json: { chat_id: cb.chat_id, text: 'Instagram poster error for ' + cb.asset_id + ':\\n' + msg } }];"
|
||||
},
|
||||
"id": "build-error-msg",
|
||||
"name": "Build error message",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [1130, 750],
|
||||
"notes": "Catches non-2xx from /enqueue or /reject and formats a Telegram alert text."
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/sendMessage",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{"name": "Content-Type", "value": "application/json"}
|
||||
]
|
||||
},
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ chat_id: $json.chat_id, text: $json.text }) }}",
|
||||
"options": {"timeout": 15000}
|
||||
},
|
||||
"id": "telegram-error-msg",
|
||||
"name": "Telegram error notice",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [1350, 750],
|
||||
"notes": "Sends the error text to the user as a follow-up message."
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Telegram callback_query": {"main": [[{"node": "Parse callback_data", "type": "main", "index": 0}]]},
|
||||
"Parse callback_data": {"main": [[{"node": "Switch on action", "type": "main", "index": 0}]]},
|
||||
"Switch on action": {
|
||||
"main": [
|
||||
[{"node": "Approve: enqueue asset", "type": "main", "index": 0}],
|
||||
[{"node": "Reject: mark seen", "type": "main", "index": 0}]
|
||||
]
|
||||
},
|
||||
"Approve: enqueue asset": {
|
||||
"main": [
|
||||
[{"node": "Approve: build new caption", "type": "main", "index": 0}],
|
||||
[{"node": "Build error message", "type": "main", "index": 0}]
|
||||
]
|
||||
},
|
||||
"Reject: mark seen": {
|
||||
"main": [
|
||||
[{"node": "Reject: build new caption", "type": "main", "index": 0}],
|
||||
[{"node": "Build error message", "type": "main", "index": 0}]
|
||||
]
|
||||
},
|
||||
"Approve: build new caption": {"main": [[{"node": "Telegram editMessageCaption", "type": "main", "index": 0}]]},
|
||||
"Reject: build new caption": {"main": [[{"node": "Telegram editMessageCaption", "type": "main", "index": 0}]]},
|
||||
"Telegram editMessageCaption": {"main": [[{"node": "Telegram editMessageReplyMarkup", "type": "main", "index": 0}]]},
|
||||
"Telegram editMessageReplyMarkup": {"main": [[{"node": "Telegram answerCallbackQuery", "type": "main", "index": 0}]]},
|
||||
"Build error message": {"main": [[{"node": "Telegram error notice", "type": "main", "index": 0}]]}
|
||||
},
|
||||
"settings": {"executionOrder": "v1", "saveExecutionProgress": false, "saveManualExecutions": true},
|
||||
"staticData": null,
|
||||
"meta": {"templateCredsSetupCompleted": false},
|
||||
"pinData": {}
|
||||
}
|
||||
132
stacks/n8n/workflows/instagram-discover.json
Normal file
132
stacks/n8n/workflows/instagram-discover.json
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
{
|
||||
"name": "Instagram Discover",
|
||||
"active": true,
|
||||
"id": "3bae241e-c693-49aa-b271-51af0ec811dc",
|
||||
"versionId": "3bae241e-c693-49aa-b271-51af0ec811dc",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"rule": {
|
||||
"interval": [{"field": "minutes", "minutesInterval": 30}]
|
||||
}
|
||||
},
|
||||
"id": "cron-30min",
|
||||
"name": "Every 30 minutes",
|
||||
"type": "n8n-nodes-base.scheduleTrigger",
|
||||
"typeVersion": 1.1,
|
||||
"position": [250, 300],
|
||||
"notes": "Trigger every 30 minutes. Polling cadence is conservative for a personal pipeline; matches instagram-poster scan rate."
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "={{ $env.INSTAGRAM_POSTER_INTERNAL_URL }}/scan",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{"name": "Authorization", "value": "=Bearer {{ $env.INSTAGRAM_POSTER_TOKEN }}"},
|
||||
{"name": "Content-Type", "value": "application/json"}
|
||||
]
|
||||
},
|
||||
"sendBody": false,
|
||||
"options": {"timeout": 60000}
|
||||
},
|
||||
"id": "scan-immich",
|
||||
"name": "Scan Immich for new candidates",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [500, 300],
|
||||
"notes": "Calls instagram-poster /scan endpoint. Returns {\"new_items\": [\"asset-uuid\", ...]}. Authorization header is optional (internal cluster service); leave INSTAGRAM_POSTER_TOKEN blank if unused."
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"fieldToSplitOut": "new_items",
|
||||
"options": {}
|
||||
},
|
||||
"id": "split-items",
|
||||
"name": "Split new_items array",
|
||||
"type": "n8n-nodes-base.splitOut",
|
||||
"typeVersion": 1,
|
||||
"position": [750, 300],
|
||||
"notes": "Fan out one candidate per execution branch so each photo gets its own approval message."
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"batchSize": 1,
|
||||
"options": {}
|
||||
},
|
||||
"id": "batch-loop",
|
||||
"name": "Loop one at a time",
|
||||
"type": "n8n-nodes-base.splitInBatches",
|
||||
"typeVersion": 3,
|
||||
"position": [970, 300],
|
||||
"notes": "Process one asset at a time so errors on a single asset don't fan out and spam Telegram."
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "GET",
|
||||
"url": "={{ $env.IMMICH_BASE_URL }}/api/assets/{{ $json.new_items }}",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{"name": "x-api-key", "value": "={{ $env.IMMICH_API_KEY }}"},
|
||||
{"name": "Accept", "value": "application/json"}
|
||||
]
|
||||
},
|
||||
"options": {"timeout": 30000}
|
||||
},
|
||||
"id": "fetch-asset-meta",
|
||||
"name": "Fetch Immich asset metadata",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [1190, 300],
|
||||
"notes": "Pull asset metadata from Immich for caption preview (filename, fileCreatedAt, EXIF location)."
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const item = $input.first().json;\nconst assetId = item.id || $('Loop one at a time').item.json.new_items;\nconst filename = item.originalFileName || item.originalPath || 'unknown';\nconst createdAt = item.fileCreatedAt || item.localDateTime || '';\nconst exif = item.exifInfo || {};\nconst city = exif.city || '';\nconst country = exif.country || '';\nconst location = [city, country].filter(Boolean).join(', ');\n\nconst dateStr = createdAt ? new Date(createdAt).toISOString().slice(0, 10) : '';\n\nconst lines = [\n '<b>New Immich candidate</b>',\n '',\n '<code>' + assetId + '</code>',\n '',\n '<b>File:</b> ' + filename\n];\nif (dateStr) lines.push('<b>Taken:</b> ' + dateStr);\nif (location) lines.push('<b>Where:</b> ' + location);\nlines.push('');\nlines.push('Approve to enqueue for posting, reject to mark seen.');\n\nreturn [{ json: { asset_id: assetId, caption: lines.join('\\n') } }];"
|
||||
},
|
||||
"id": "build-caption",
|
||||
"name": "Build caption + asset id",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [1410, 300],
|
||||
"notes": "Compose the caption from Immich metadata. HTML parse_mode is used downstream."
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/sendPhoto",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{"name": "Content-Type", "value": "application/json"}
|
||||
]
|
||||
},
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ chat_id: $env.TELEGRAM_CHAT_ID, photo: $env.PUBLIC_INSTAGRAM_POSTER_URL + '/image/' + $json.asset_id, caption: $json.caption, parse_mode: 'HTML', reply_markup: { inline_keyboard: [[ { text: 'Approve', callback_data: 'approve:' + $json.asset_id }, { text: 'Reject', callback_data: 'reject:' + $json.asset_id } ]] } }) }}",
|
||||
"options": {"timeout": 30000}
|
||||
},
|
||||
"id": "telegram-send-photo",
|
||||
"name": "Telegram sendPhoto with buttons",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [1630, 300],
|
||||
"notes": "Telegram needs a public URL for the photo (it fetches the image from outside the cluster). callback_data uses the action:asset_id format consumed by instagram-approval."
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Every 30 minutes": {"main": [[{"node": "Scan Immich for new candidates", "type": "main", "index": 0}]]},
|
||||
"Scan Immich for new candidates": {"main": [[{"node": "Split new_items array", "type": "main", "index": 0}]]},
|
||||
"Split new_items array": {"main": [[{"node": "Loop one at a time", "type": "main", "index": 0}]]},
|
||||
"Loop one at a time": {"main": [[{"node": "Fetch Immich asset metadata", "type": "main", "index": 0}]]},
|
||||
"Fetch Immich asset metadata": {"main": [[{"node": "Build caption + asset id", "type": "main", "index": 0}]]},
|
||||
"Build caption + asset id": {"main": [[{"node": "Telegram sendPhoto with buttons", "type": "main", "index": 0}]]},
|
||||
"Telegram sendPhoto with buttons": {"main": [[{"node": "Loop one at a time", "type": "main", "index": 0}]]}
|
||||
},
|
||||
"settings": {"executionOrder": "v1", "saveExecutionProgress": false, "saveManualExecutions": true},
|
||||
"staticData": null,
|
||||
"meta": {"templateCredsSetupCompleted": false},
|
||||
"pinData": {}
|
||||
}
|
||||
177
stacks/n8n/workflows/instagram-post.json
Normal file
177
stacks/n8n/workflows/instagram-post.json
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
{
|
||||
"name": "Instagram Post",
|
||||
"active": true,
|
||||
"id": "8964902b-b106-4cea-8965-77724baa71be",
|
||||
"versionId": "8964902b-b106-4cea-8965-77724baa71be",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"rule": {
|
||||
"interval": [{"field": "days", "daysInterval": 1, "triggerAtHour": 11, "triggerAtMinute": 0}]
|
||||
}
|
||||
},
|
||||
"id": "cron-daily-11",
|
||||
"name": "Daily 11:00 Europe/London",
|
||||
"type": "n8n-nodes-base.scheduleTrigger",
|
||||
"typeVersion": 1.1,
|
||||
"position": [250, 300],
|
||||
"notes": "Fires once a day. Postiz handles per-platform scheduling windows; this just feeds the next approved asset to the poster service."
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "={{ $env.INSTAGRAM_POSTER_INTERNAL_URL }}/post-next",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{"name": "Authorization", "value": "=Bearer {{ $env.INSTAGRAM_POSTER_TOKEN }}"},
|
||||
{"name": "Content-Type", "value": "application/json"}
|
||||
]
|
||||
},
|
||||
"sendBody": false,
|
||||
"options": {
|
||||
"timeout": 60000,
|
||||
"response": {"response": {"fullResponse": true, "neverError": true}}
|
||||
}
|
||||
},
|
||||
"id": "post-next",
|
||||
"name": "Call /post-next",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [500, 300],
|
||||
"notes": "neverError + fullResponse gives us the status code so we can branch on 200 / 404 / 5xx without throwing."
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"rules": {
|
||||
"values": [
|
||||
{
|
||||
"conditions": {
|
||||
"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "strict"},
|
||||
"conditions": [{"id": "is-200", "leftValue": "={{ $json.statusCode }}", "rightValue": 200, "operator": {"type": "number", "operation": "equals"}}],
|
||||
"combinator": "and"
|
||||
},
|
||||
"outputKey": "ok"
|
||||
},
|
||||
{
|
||||
"conditions": {
|
||||
"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "strict"},
|
||||
"conditions": [{"id": "is-404", "leftValue": "={{ $json.statusCode }}", "rightValue": 404, "operator": {"type": "number", "operation": "equals"}}],
|
||||
"combinator": "and"
|
||||
},
|
||||
"outputKey": "empty"
|
||||
},
|
||||
{
|
||||
"conditions": {
|
||||
"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "strict"},
|
||||
"conditions": [{"id": "is-5xx", "leftValue": "={{ $json.statusCode }}", "rightValue": 500, "operator": {"type": "number", "operation": "largerEqual"}}],
|
||||
"combinator": "and"
|
||||
},
|
||||
"outputKey": "error"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {"fallbackOutput": "extra", "renameFallbackOutput": "other"}
|
||||
},
|
||||
"id": "switch-status",
|
||||
"name": "Switch on status code",
|
||||
"type": "n8n-nodes-base.switch",
|
||||
"typeVersion": 3.2,
|
||||
"position": [750, 300],
|
||||
"notes": "200 -> success notify, 404 -> silent no-op, 5xx -> alert. Other 4xx falls into the fallback branch and is also alerted."
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const body = $input.first().json.body || $input.first().json;\nconst assetId = (body && (body.asset_id || body.id)) || 'unknown';\nreturn [{ json: { chat_id: $env.TELEGRAM_CHAT_ID, text: 'Story scheduled: ' + assetId } }];"
|
||||
},
|
||||
"id": "build-success-msg",
|
||||
"name": "Build success message",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [1000, 150],
|
||||
"notes": "Pulls asset_id out of the response body for the confirmation Telegram message."
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/sendMessage",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{"name": "Content-Type", "value": "application/json"}
|
||||
]
|
||||
},
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ chat_id: $json.chat_id, text: $json.text }) }}",
|
||||
"options": {"timeout": 15000}
|
||||
},
|
||||
"id": "telegram-success",
|
||||
"name": "Telegram success notice",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [1250, 150],
|
||||
"notes": "Confirms the scheduled post to the user."
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const r = $input.first().json;\nconst body = r.body || {};\nconst err = body.error || JSON.stringify(body) || ('HTTP ' + r.statusCode);\nreturn [{ json: { chat_id: $env.TELEGRAM_CHAT_ID, text: 'Instagram post-next failed (HTTP ' + r.statusCode + '): ' + err } }];"
|
||||
},
|
||||
"id": "build-error-msg",
|
||||
"name": "Build error message",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [1000, 450],
|
||||
"notes": "Formats a Telegram alert with status code + body error message."
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/sendMessage",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{"name": "Content-Type", "value": "application/json"}
|
||||
]
|
||||
},
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ chat_id: $json.chat_id, text: $json.text }) }}",
|
||||
"options": {"timeout": 15000}
|
||||
},
|
||||
"id": "telegram-error",
|
||||
"name": "Telegram error alert",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [1250, 450],
|
||||
"notes": "Sends the error message to the user."
|
||||
},
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "noop-empty",
|
||||
"name": "Empty queue (no-op)",
|
||||
"type": "n8n-nodes-base.noOp",
|
||||
"typeVersion": 1,
|
||||
"position": [1000, 300],
|
||||
"notes": "404 means there are no approved items waiting; do nothing instead of spamming Telegram."
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Daily 11:00 Europe/London": {"main": [[{"node": "Call /post-next", "type": "main", "index": 0}]]},
|
||||
"Call /post-next": {"main": [[{"node": "Switch on status code", "type": "main", "index": 0}]]},
|
||||
"Switch on status code": {
|
||||
"main": [
|
||||
[{"node": "Build success message", "type": "main", "index": 0}],
|
||||
[{"node": "Empty queue (no-op)", "type": "main", "index": 0}],
|
||||
[{"node": "Build error message", "type": "main", "index": 0}],
|
||||
[{"node": "Build error message", "type": "main", "index": 0}]
|
||||
]
|
||||
},
|
||||
"Build success message": {"main": [[{"node": "Telegram success notice", "type": "main", "index": 0}]]},
|
||||
"Build error message": {"main": [[{"node": "Telegram error alert", "type": "main", "index": 0}]]}
|
||||
},
|
||||
"settings": {"executionOrder": "v1", "saveExecutionProgress": false, "saveManualExecutions": true},
|
||||
"staticData": null,
|
||||
"meta": {"templateCredsSetupCompleted": false},
|
||||
"pinData": {}
|
||||
}
|
||||
11
stacks/postiz/main.tf
Normal file
11
stacks/postiz/main.tf
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
variable "tls_secret_name" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
variable "nfs_server" { type = string }
|
||||
|
||||
module "postiz" {
|
||||
source = "./modules/postiz"
|
||||
tls_secret_name = var.tls_secret_name
|
||||
tier = local.tiers.aux
|
||||
}
|
||||
223
stacks/postiz/modules/postiz/main.tf
Normal file
223
stacks/postiz/modules/postiz/main.tf
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Postiz — social media post scheduler (Instagram Stories + others).
|
||||
#
|
||||
# Chart: oci://ghcr.io/gitroomhq/postiz-helmchart/charts/postiz (v1.0.5)
|
||||
# App : ghcr.io/gitroomhq/postiz-app:v2.21.7
|
||||
#
|
||||
# Layout:
|
||||
# - Bundled Postgres + Redis (chart subcharts) — fine for v1.
|
||||
# - 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
|
||||
# Secret name is `<release>-secrets`; we pin `fullnameOverride: postiz` so
|
||||
# the Secret resolves to `postiz-secrets`. The chart already mounts that
|
||||
# Secret via `envFrom: secretRef: <fullname>-secrets`, so ESO patching the
|
||||
# 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" {
|
||||
metadata {
|
||||
name = var.namespace
|
||||
labels = {
|
||||
tier = var.tier
|
||||
}
|
||||
}
|
||||
lifecycle {
|
||||
# KYVERNO_LIFECYCLE_V1: goldilocks-vpa-auto-mode ClusterPolicy stamps this label on every namespace
|
||||
ignore_changes = [metadata[0].labels["goldilocks.fairwinds.com/vpa-update-mode"]]
|
||||
}
|
||||
}
|
||||
|
||||
module "tls_secret" {
|
||||
source = "../../../../modules/kubernetes/setup_tls_secret"
|
||||
namespace = kubernetes_namespace.postiz.metadata[0].name
|
||||
tls_secret_name = var.tls_secret_name
|
||||
}
|
||||
|
||||
# /uploads PVC — keeps user-uploaded media across pod restarts.
|
||||
resource "kubernetes_persistent_volume_claim" "uploads" {
|
||||
wait_until_bound = false
|
||||
metadata {
|
||||
name = "postiz-uploads"
|
||||
namespace = kubernetes_namespace.postiz.metadata[0].name
|
||||
annotations = {
|
||||
"resize.topolvm.io/threshold" = "80%"
|
||||
"resize.topolvm.io/increase" = "100%"
|
||||
"resize.topolvm.io/storage_limit" = "50Gi"
|
||||
}
|
||||
}
|
||||
spec {
|
||||
access_modes = ["ReadWriteOnce"]
|
||||
storage_class_name = "proxmox-lvm"
|
||||
resources {
|
||||
requests = {
|
||||
storage = var.storage_size
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ExternalSecret: patches the chart-managed `postiz-secrets` Secret with
|
||||
# JWT_SECRET pulled from Vault. `creationPolicy: Merge` means ESO will not
|
||||
# take ownership — it just adds/updates the keys it manages, leaving the
|
||||
# Helm-owned Secret resource intact. The chart's deployment already wires
|
||||
# this Secret in via `envFrom: secretRef: postiz-secrets`.
|
||||
resource "kubernetes_manifest" "external_secret_jwt" {
|
||||
manifest = {
|
||||
apiVersion = "external-secrets.io/v1beta1"
|
||||
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"
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
depends_on = [kubernetes_namespace.postiz]
|
||||
}
|
||||
|
||||
resource "helm_release" "postiz" {
|
||||
namespace = kubernetes_namespace.postiz.metadata[0].name
|
||||
name = "postiz"
|
||||
create_namespace = false
|
||||
atomic = true
|
||||
timeout = 600
|
||||
|
||||
repository = "oci://ghcr.io/gitroomhq/postiz-helmchart/charts"
|
||||
chart = "postiz"
|
||||
version = var.chart_version
|
||||
|
||||
values = [yamlencode({
|
||||
fullnameOverride = "postiz"
|
||||
|
||||
image = {
|
||||
repository = "ghcr.io/gitroomhq/postiz-app"
|
||||
tag = var.image_tag
|
||||
pullPolicy = "IfNotPresent"
|
||||
}
|
||||
|
||||
service = {
|
||||
type = "ClusterIP"
|
||||
port = 80 # chart maps Service port 80 -> targetPort http (containerPort 5000)
|
||||
}
|
||||
|
||||
# Non-secret env. Note: BACKEND_INTERNAL_URL stays in-pod (Postiz convention).
|
||||
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"
|
||||
STORAGE_PROVIDER = "local"
|
||||
UPLOAD_DIRECTORY = "/uploads"
|
||||
NEXT_PUBLIC_UPLOAD_DIRECTORY = "/uploads"
|
||||
# Set true after first admin user is created via UI
|
||||
DISABLE_REGISTRATION = "false"
|
||||
IS_GENERAL = "true"
|
||||
NX_ADD_PLUGINS = "false"
|
||||
}
|
||||
|
||||
# Empty placeholder for chart-rendered Secret. ESO patches JWT_SECRET via
|
||||
# creationPolicy=Merge above. DATABASE_URL/REDIS_URL are auto-wired by the
|
||||
# chart's bundled subcharts and don't need to be set here.
|
||||
secrets = {
|
||||
DATABASE_URL = ""
|
||||
REDIS_URL = ""
|
||||
JWT_SECRET = ""
|
||||
X_API_KEY = ""
|
||||
X_API_SECRET = ""
|
||||
LINKEDIN_CLIENT_ID = ""
|
||||
LINKEDIN_CLIENT_SECRET = ""
|
||||
REDDIT_CLIENT_ID = ""
|
||||
REDDIT_CLIENT_SECRET = ""
|
||||
GITHUB_CLIENT_ID = ""
|
||||
GITHUB_CLIENT_SECRET = ""
|
||||
RESEND_API_KEY = ""
|
||||
CLOUDFLARE_ACCOUNT_ID = ""
|
||||
CLOUDFLARE_ACCESS_KEY = ""
|
||||
CLOUDFLARE_SECRET_ACCESS_KEY = ""
|
||||
CLOUDFLARE_BUCKETNAME = ""
|
||||
CLOUDFLARE_BUCKET_URL = ""
|
||||
}
|
||||
|
||||
# Use our PVC for uploads (overrides the chart's emptyDir default).
|
||||
extraVolumes = [{
|
||||
name = "uploads-volume"
|
||||
persistentVolumeClaim = {
|
||||
claimName = kubernetes_persistent_volume_claim.uploads.metadata[0].name
|
||||
}
|
||||
}]
|
||||
extraVolumeMounts = [{
|
||||
name = "uploads-volume"
|
||||
mountPath = "/uploads"
|
||||
}]
|
||||
|
||||
resources = {
|
||||
requests = {
|
||||
cpu = "100m"
|
||||
memory = "256Mi"
|
||||
}
|
||||
limits = {
|
||||
memory = "2Gi"
|
||||
}
|
||||
}
|
||||
|
||||
# Bundled stateful deps — fine for v1, reconsider promotion to CNPG later.
|
||||
# Subchart passwords intentionally left to chart defaults; the bundled
|
||||
# PG/Redis Services are ClusterIP and only routable from the postiz
|
||||
# namespace, so the credentials never leave the pod network. Promotion to
|
||||
# CNPG with Vault-rotated creds is the next step.
|
||||
postgresql = {
|
||||
enabled = true
|
||||
auth = {
|
||||
username = "postiz"
|
||||
database = "postiz"
|
||||
}
|
||||
}
|
||||
|
||||
redis = {
|
||||
enabled = true
|
||||
}
|
||||
})]
|
||||
|
||||
depends_on = [
|
||||
kubernetes_persistent_volume_claim.uploads,
|
||||
kubernetes_manifest.external_secret_jwt,
|
||||
]
|
||||
}
|
||||
|
||||
module "ingress" {
|
||||
source = "../../../../modules/kubernetes/ingress_factory"
|
||||
dns_type = "proxied"
|
||||
namespace = kubernetes_namespace.postiz.metadata[0].name
|
||||
name = "postiz"
|
||||
host = var.host
|
||||
service_name = "postiz" # chart Service name resolves to fullnameOverride
|
||||
port = 80
|
||||
tls_secret_name = var.tls_secret_name
|
||||
extra_annotations = {
|
||||
"gethomepage.dev/enabled" = "true"
|
||||
"gethomepage.dev/name" = "Postiz"
|
||||
"gethomepage.dev/description" = "Social media post scheduler"
|
||||
"gethomepage.dev/icon" = "postiz.png"
|
||||
"gethomepage.dev/group" = "Automation"
|
||||
"gethomepage.dev/pod-selector" = ""
|
||||
}
|
||||
}
|
||||
40
stacks/postiz/modules/postiz/variables.tf
Normal file
40
stacks/postiz/modules/postiz/variables.tf
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
variable "tls_secret_name" {
|
||||
type = string
|
||||
sensitive = true
|
||||
description = "Name of the wildcard TLS Secret to copy into the postiz namespace."
|
||||
}
|
||||
|
||||
variable "tier" {
|
||||
type = string
|
||||
description = "Workload tier label applied to the namespace (e.g. 4-aux)."
|
||||
}
|
||||
|
||||
variable "namespace" {
|
||||
type = string
|
||||
default = "postiz"
|
||||
description = "Kubernetes namespace for Postiz."
|
||||
}
|
||||
|
||||
variable "host" {
|
||||
type = string
|
||||
default = "postiz"
|
||||
description = "Ingress hostname label (joined with root_domain by ingress_factory)."
|
||||
}
|
||||
|
||||
variable "image_tag" {
|
||||
type = string
|
||||
default = "v2.21.7"
|
||||
description = "Postiz container image tag."
|
||||
}
|
||||
|
||||
variable "chart_version" {
|
||||
type = string
|
||||
default = "1.0.5"
|
||||
description = "Postiz Helm chart version (OCI ghcr.io/gitroomhq/postiz-helmchart)."
|
||||
}
|
||||
|
||||
variable "storage_size" {
|
||||
type = string
|
||||
default = "20Gi"
|
||||
description = "Persistent volume size for /uploads."
|
||||
}
|
||||
1
stacks/postiz/secrets
Symbolic link
1
stacks/postiz/secrets
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../secrets
|
||||
13
stacks/postiz/terragrunt.hcl
Normal file
13
stacks/postiz/terragrunt.hcl
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
include "root" {
|
||||
path = find_in_parent_folders()
|
||||
}
|
||||
|
||||
dependency "platform" {
|
||||
config_path = "../platform"
|
||||
skip_outputs = true
|
||||
}
|
||||
|
||||
dependency "vault" {
|
||||
config_path = "../vault"
|
||||
skip_outputs = true
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue