From 73eb01f994b4feaf3cfeab02ef19c724e9557217 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 9 May 2026 00:07:44 +0000 Subject: [PATCH] add postiz + instagram-poster stacks for IG Stories pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ 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. --- stacks/instagram-poster/main.tf | 17 ++ .../modules/instagram-poster/main.tf | 275 ++++++++++++++++++ .../modules/instagram-poster/variables.tf | 15 + stacks/instagram-poster/secrets | 1 + stacks/instagram-poster/terragrunt.hcl | 23 ++ stacks/n8n/main.tf | 79 +++++ stacks/n8n/workflows/instagram-approval.json | 269 +++++++++++++++++ stacks/n8n/workflows/instagram-discover.json | 132 +++++++++ stacks/n8n/workflows/instagram-post.json | 177 +++++++++++ stacks/postiz/main.tf | 11 + stacks/postiz/modules/postiz/main.tf | 223 ++++++++++++++ stacks/postiz/modules/postiz/variables.tf | 40 +++ stacks/postiz/secrets | 1 + stacks/postiz/terragrunt.hcl | 13 + 14 files changed, 1276 insertions(+) create mode 100644 stacks/instagram-poster/main.tf create mode 100644 stacks/instagram-poster/modules/instagram-poster/main.tf create mode 100644 stacks/instagram-poster/modules/instagram-poster/variables.tf create mode 120000 stacks/instagram-poster/secrets create mode 100644 stacks/instagram-poster/terragrunt.hcl create mode 100644 stacks/n8n/workflows/instagram-approval.json create mode 100644 stacks/n8n/workflows/instagram-discover.json create mode 100644 stacks/n8n/workflows/instagram-post.json create mode 100644 stacks/postiz/main.tf create mode 100644 stacks/postiz/modules/postiz/main.tf create mode 100644 stacks/postiz/modules/postiz/variables.tf create mode 120000 stacks/postiz/secrets create mode 100644 stacks/postiz/terragrunt.hcl diff --git a/stacks/instagram-poster/main.tf b/stacks/instagram-poster/main.tf new file mode 100644 index 00000000..7440cf63 --- /dev/null +++ b/stacks/instagram-poster/main.tf @@ -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 +} diff --git a/stacks/instagram-poster/modules/instagram-poster/main.tf b/stacks/instagram-poster/modules/instagram-poster/main.tf new file mode 100644 index 00000000..c4879bab --- /dev/null +++ b/stacks/instagram-poster/modules/instagram-poster/main.tf @@ -0,0 +1,275 @@ +locals { + namespace = "instagram-poster" + # Forgejo registry consolidation (2026-05-07): all custom service images + # live under forgejo.viktorbarzin.me/viktor/. 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/ 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 +} diff --git a/stacks/instagram-poster/modules/instagram-poster/variables.tf b/stacks/instagram-poster/modules/instagram-poster/variables.tf new file mode 100644 index 00000000..430210f5 --- /dev/null +++ b/stacks/instagram-poster/modules/instagram-poster/variables.tf @@ -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" +} diff --git a/stacks/instagram-poster/secrets b/stacks/instagram-poster/secrets new file mode 120000 index 00000000..ca54a7cf --- /dev/null +++ b/stacks/instagram-poster/secrets @@ -0,0 +1 @@ +../../secrets \ No newline at end of file diff --git a/stacks/instagram-poster/terragrunt.hcl b/stacks/instagram-poster/terragrunt.hcl new file mode 100644 index 00000000..0e5d00ba --- /dev/null +++ b/stacks/instagram-poster/terragrunt.hcl @@ -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" +} diff --git a/stacks/n8n/main.tf b/stacks/n8n/main.tf index 62e9a528..020a59fe 100644 --- a/stacks/n8n/main.tf +++ b/stacks/n8n/main.tf @@ -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" diff --git a/stacks/n8n/workflows/instagram-approval.json b/stacks/n8n/workflows/instagram-approval.json new file mode 100644 index 00000000..81cae2d6 --- /dev/null +++ b/stacks/n8n/workflows/instagram-approval.json @@ -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": {} +} diff --git a/stacks/n8n/workflows/instagram-discover.json b/stacks/n8n/workflows/instagram-discover.json new file mode 100644 index 00000000..055c1f15 --- /dev/null +++ b/stacks/n8n/workflows/instagram-discover.json @@ -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 'New Immich candidate',\n '',\n '' + assetId + '',\n '',\n 'File: ' + filename\n];\nif (dateStr) lines.push('Taken: ' + dateStr);\nif (location) lines.push('Where: ' + 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": {} +} diff --git a/stacks/n8n/workflows/instagram-post.json b/stacks/n8n/workflows/instagram-post.json new file mode 100644 index 00000000..8fe50dbc --- /dev/null +++ b/stacks/n8n/workflows/instagram-post.json @@ -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": {} +} diff --git a/stacks/postiz/main.tf b/stacks/postiz/main.tf new file mode 100644 index 00000000..c5717893 --- /dev/null +++ b/stacks/postiz/main.tf @@ -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 +} diff --git a/stacks/postiz/modules/postiz/main.tf b/stacks/postiz/modules/postiz/main.tf new file mode 100644 index 00000000..6da6db89 --- /dev/null +++ b/stacks/postiz/modules/postiz/main.tf @@ -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 `-secrets`; we pin `fullnameOverride: postiz` so +# the Secret resolves to `postiz-secrets`. The chart already mounts that +# Secret via `envFrom: secretRef: -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" = "" + } +} diff --git a/stacks/postiz/modules/postiz/variables.tf b/stacks/postiz/modules/postiz/variables.tf new file mode 100644 index 00000000..469b8c71 --- /dev/null +++ b/stacks/postiz/modules/postiz/variables.tf @@ -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." +} diff --git a/stacks/postiz/secrets b/stacks/postiz/secrets new file mode 120000 index 00000000..ca54a7cf --- /dev/null +++ b/stacks/postiz/secrets @@ -0,0 +1 @@ +../../secrets \ No newline at end of file diff --git a/stacks/postiz/terragrunt.hcl b/stacks/postiz/terragrunt.hcl new file mode 100644 index 00000000..f4c920ab --- /dev/null +++ b/stacks/postiz/terragrunt.hcl @@ -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 +}