feat(nextcloud-todos): Phase 4 IaC — service stack, Vault role, DB bootstrap, OpenClaw plugin, monitoring
Phase 4 infrastructure-as-code for the nextcloud-todos service (watches the
Nextcloud Personal task list; classifies todos via local qwen3-8b and routes
research/mutating work through claude-agent-service). Clones the
recruiter-responder service pattern end-to-end. Written only — NOT applied.
- stacks/nextcloud-todos/{main.tf,terragrunt.hcl}: new aux stack cloning
recruiter-responder — ns (tier aux, istio-injection disabled, keel enrolled),
two ExternalSecrets (vault-kv app secrets + vault-database DSN), Recreate
deployment with alembic-migrate init-container, ClusterIP svc, /cb-only
HMAC-gated ingress (auth=none, proxied), and an idempotent webhook-register
null_resource (OCS webhook_listeners API, both CalendarObject Created/Updated
events -> internal svc URL, Bearer auth).
- stacks/vault/main.tf: pg_nextcloud_todos static role (nextcloud_todos, 7d
rotation) + pg-nextcloud-todos in the postgresql allowed_roles array.
- stacks/dbaas/modules/dbaas/main.tf: pg_nextcloud_todos_db null_resource
(clone of pg_tripit_db) — creates role+DB, pins role search_path, and
creates schema nextcloud_todos AUTHORIZATION nextcloud_todos.
- stacks/openclaw/main.tf: install-nextcloud-todos-plugin init-container,
nextcloud-todos-api in plugins.allow + the doctor-fix re-add + plugins
enable, NEXTCLOUD_TODOS_URL/NEXTCLOUD_TODOS_TOKEN env, and the cross-path
ESO key (secret/nextcloud-todos.webhook_bearer_token).
- stacks/uptime-kuma/modules/uptime-kuma/main.tf: internal /healthz HTTP
monitor. Prometheus /metrics scrape via svc annotations in the new stack.
- .gitleaksignore: allowlist two curl-auth-user false positives (the OCS
webhook curl uses a Vault-sourced shell var, not a literal credential).
KV seed (secret/nextcloud-todos) + applies are deferred to the apply runbook.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
c3c3d5e010
commit
c958f6a589
7 changed files with 571 additions and 2 deletions
|
|
@ -2,3 +2,10 @@
|
||||||
# gitleaks scans the staged working-tree copy and can't see that they're
|
# gitleaks scans the staged working-tree copy and can't see that they're
|
||||||
# encrypted on disk in git, so allowlist by fingerprint.
|
# encrypted on disk in git, so allowlist by fingerprint.
|
||||||
stacks/recruiter-responder/secrets/privkey.pem:private-key:1
|
stacks/recruiter-responder/secrets/privkey.pem:private-key:1
|
||||||
|
|
||||||
|
# False positives: the `curl-auth-user` rule flags `-u "admin:..."` in the
|
||||||
|
# nextcloud-todos webhook-register provisioner, but the password is a shell
|
||||||
|
# variable ($NC_ADMIN_APP_PW) resolved at apply time from Vault — no literal
|
||||||
|
# secret is committed.
|
||||||
|
stacks/nextcloud-todos/main.tf:curl-auth-user:383
|
||||||
|
stacks/nextcloud-todos/main.tf:curl-auth-user:400
|
||||||
|
|
|
||||||
|
|
@ -1311,6 +1311,39 @@ resource "null_resource" "pg_tripit_db" {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Create nextcloud_todos database + role for the nextcloud-todos service
|
||||||
|
# (FastAPI; watches the Nextcloud Personal task list). Role password is
|
||||||
|
# managed by the Vault Database Secrets Engine (static role
|
||||||
|
# `pg-nextcloud-todos`, 7d rotation). Tables live in schema `nextcloud_todos`
|
||||||
|
# (alembic creates them on the app's first migrate). Unlike most app DBs we
|
||||||
|
# also create the schema explicitly + pin the role's search_path to it, so the
|
||||||
|
# unqualified tables alembic generates land in `nextcloud_todos` rather than
|
||||||
|
# `public`.
|
||||||
|
resource "null_resource" "pg_nextcloud_todos_db" {
|
||||||
|
depends_on = [null_resource.pg_cluster]
|
||||||
|
|
||||||
|
triggers = {
|
||||||
|
db_name = "nextcloud_todos"
|
||||||
|
username = "nextcloud_todos"
|
||||||
|
}
|
||||||
|
|
||||||
|
provisioner "local-exec" {
|
||||||
|
command = <<-EOT
|
||||||
|
PRIMARY=$(kubectl --kubeconfig ${var.kube_config_path} get cluster -n dbaas pg-cluster -o jsonpath='{.status.currentPrimary}')
|
||||||
|
kubectl --kubeconfig ${var.kube_config_path} exec -n dbaas $PRIMARY -c postgres -- \
|
||||||
|
bash -c '
|
||||||
|
psql -U postgres -tc "SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = '"'"'nextcloud_todos'"'"'" | grep -q 1 || \
|
||||||
|
psql -U postgres -c "CREATE ROLE nextcloud_todos WITH LOGIN PASSWORD '"'"'changeme-vault-will-rotate'"'"'"
|
||||||
|
psql -U postgres -tc "SELECT 1 FROM pg_catalog.pg_database WHERE datname = '"'"'nextcloud_todos'"'"'" | grep -q 1 || \
|
||||||
|
psql -U postgres -c "CREATE DATABASE nextcloud_todos OWNER nextcloud_todos"
|
||||||
|
psql -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE nextcloud_todos TO nextcloud_todos"
|
||||||
|
psql -U postgres -c "ALTER ROLE nextcloud_todos SET search_path TO nextcloud_todos"
|
||||||
|
psql -U postgres -d nextcloud_todos -c "CREATE SCHEMA IF NOT EXISTS nextcloud_todos AUTHORIZATION nextcloud_todos"
|
||||||
|
'
|
||||||
|
EOT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Postiz: 3 databases (postiz, temporal, temporal_visibility) all owned by the
|
# Postiz: 3 databases (postiz, temporal, temporal_visibility) all owned by the
|
||||||
# `postiz` role. Bundled bitnami PostgreSQL was retired 2026-05-09 in favour of
|
# `postiz` role. Bundled bitnami PostgreSQL was retired 2026-05-09 in favour of
|
||||||
# this CNPG cluster — covered by postgresql-backup-per-db automatically.
|
# this CNPG cluster — covered by postgresql-backup-per-db automatically.
|
||||||
|
|
|
||||||
412
stacks/nextcloud-todos/main.tf
Normal file
412
stacks/nextcloud-todos/main.tf
Normal file
|
|
@ -0,0 +1,412 @@
|
||||||
|
variable "image_tag" {
|
||||||
|
type = string
|
||||||
|
default = "latest"
|
||||||
|
description = "nextcloud-todos image tag. Use 8-char git SHA in CI."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "postgresql_host" { type = string }
|
||||||
|
|
||||||
|
variable "tls_secret_name" {
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
locals {
|
||||||
|
namespace = "nextcloud-todos"
|
||||||
|
image = "forgejo.viktorbarzin.me/viktor/nextcloud-todos:${var.image_tag}"
|
||||||
|
labels = {
|
||||||
|
app = "nextcloud-todos"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "kubernetes_namespace" "nextcloud_todos" {
|
||||||
|
metadata {
|
||||||
|
name = local.namespace
|
||||||
|
labels = {
|
||||||
|
tier = local.tiers.aux
|
||||||
|
"istio-injection" = "disabled"
|
||||||
|
# Opt into Keel auto-update (inject-keel-annotations ClusterPolicy).
|
||||||
|
"keel.sh/enrolled" = "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycle {
|
||||||
|
# KYVERNO_LIFECYCLE_V1: goldilocks-vpa-auto-mode ClusterPolicy stamps this label.
|
||||||
|
ignore_changes = [metadata[0].labels["goldilocks.fairwinds.com/vpa-update-mode"]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# App secrets — seed these in Vault before applying:
|
||||||
|
# secret/nextcloud-todos
|
||||||
|
# webhook_bearer_token — bearer for Nextcloud -> /nextcloud/hook and
|
||||||
|
# the OpenClaw nextcloud-todos-api plugin
|
||||||
|
# hmac_secret — signs the /cb approval links (sig + exp)
|
||||||
|
# caldav_app_password — Nextcloud `admin` app-password for CalDAV
|
||||||
|
# (PROPFIND list resolution + note append)
|
||||||
|
# nextcloud_admin_app_password — admin app-password used by the
|
||||||
|
# webhook-register null_resource below (may
|
||||||
|
# reuse the CalDAV one)
|
||||||
|
# claude_agent_token — Bearer for claude-agent-service (Tier-2)
|
||||||
|
# telegram_bot_token — consumed by the OpenClaw plugin (see
|
||||||
|
# stacks/openclaw), NOT by this service
|
||||||
|
# viktor_chat_id — consumed by the OpenClaw plugin
|
||||||
|
#
|
||||||
|
# Schema in CNPG: `nextcloud_todos` (alembic creates tables on first migrate).
|
||||||
|
# DB user: created in dbaas (null_resource.pg_nextcloud_todos_db); password
|
||||||
|
# managed via the Vault database engine — see static-creds/pg-nextcloud-todos.
|
||||||
|
resource "kubernetes_manifest" "external_secret" {
|
||||||
|
manifest = {
|
||||||
|
apiVersion = "external-secrets.io/v1beta1"
|
||||||
|
kind = "ExternalSecret"
|
||||||
|
metadata = {
|
||||||
|
name = "nextcloud-todos-secrets"
|
||||||
|
namespace = local.namespace
|
||||||
|
}
|
||||||
|
spec = {
|
||||||
|
refreshInterval = "15m"
|
||||||
|
secretStoreRef = {
|
||||||
|
name = "vault-kv"
|
||||||
|
kind = "ClusterSecretStore"
|
||||||
|
}
|
||||||
|
target = {
|
||||||
|
name = "nextcloud-todos-secrets"
|
||||||
|
template = {
|
||||||
|
metadata = {
|
||||||
|
annotations = {
|
||||||
|
"reloader.stakater.com/match" = "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data = [
|
||||||
|
{ secretKey = "WEBHOOK_BEARER_TOKEN", remoteRef = { key = "nextcloud-todos", property = "webhook_bearer_token" } },
|
||||||
|
{ secretKey = "HMAC_SECRET", remoteRef = { key = "nextcloud-todos", property = "hmac_secret" } },
|
||||||
|
{ secretKey = "CALDAV_APP_PASSWORD", remoteRef = { key = "nextcloud-todos", property = "caldav_app_password" } },
|
||||||
|
{ secretKey = "CLAUDE_AGENT_TOKEN", remoteRef = { key = "nextcloud-todos", property = "claude_agent_token" } },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
depends_on = [kubernetes_namespace.nextcloud_todos]
|
||||||
|
}
|
||||||
|
|
||||||
|
# DB credentials from Vault database engine (7-day rotation).
|
||||||
|
# Builds the asyncpg DSN consumed by the FastAPI app as DB_CONNECTION_STRING.
|
||||||
|
# Pre-req in dbaas: CNPG cluster has DB `nextcloud_todos`, role
|
||||||
|
# `nextcloud_todos`, and Vault role `static-creds/pg-nextcloud-todos`.
|
||||||
|
resource "kubernetes_manifest" "db_external_secret" {
|
||||||
|
manifest = {
|
||||||
|
apiVersion = "external-secrets.io/v1beta1"
|
||||||
|
kind = "ExternalSecret"
|
||||||
|
metadata = {
|
||||||
|
name = "nextcloud-todos-db-creds"
|
||||||
|
namespace = local.namespace
|
||||||
|
}
|
||||||
|
spec = {
|
||||||
|
refreshInterval = "15m"
|
||||||
|
secretStoreRef = {
|
||||||
|
name = "vault-database"
|
||||||
|
kind = "ClusterSecretStore"
|
||||||
|
}
|
||||||
|
target = {
|
||||||
|
name = "nextcloud-todos-db-creds"
|
||||||
|
template = {
|
||||||
|
metadata = {
|
||||||
|
annotations = {
|
||||||
|
"reloader.stakater.com/match" = "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data = {
|
||||||
|
DB_CONNECTION_STRING = "postgresql+asyncpg://nextcloud_todos:{{ .password }}@${var.postgresql_host}:5432/nextcloud_todos"
|
||||||
|
DB_PASSWORD = "{{ .password }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data = [{
|
||||||
|
secretKey = "password"
|
||||||
|
remoteRef = {
|
||||||
|
key = "static-creds/pg-nextcloud-todos"
|
||||||
|
property = "password"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
depends_on = [kubernetes_namespace.nextcloud_todos]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "kubernetes_deployment" "nextcloud_todos" {
|
||||||
|
metadata {
|
||||||
|
name = "nextcloud-todos"
|
||||||
|
namespace = kubernetes_namespace.nextcloud_todos.metadata[0].name
|
||||||
|
labels = merge(local.labels, {
|
||||||
|
tier = local.tiers.aux
|
||||||
|
})
|
||||||
|
annotations = {
|
||||||
|
"reloader.stakater.com/search" = "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
spec {
|
||||||
|
# CalDAV sweep + single webhook leader; concurrency hurts both.
|
||||||
|
replicas = 1
|
||||||
|
strategy {
|
||||||
|
type = "Recreate"
|
||||||
|
}
|
||||||
|
|
||||||
|
selector {
|
||||||
|
match_labels = local.labels
|
||||||
|
}
|
||||||
|
|
||||||
|
template {
|
||||||
|
metadata {
|
||||||
|
labels = local.labels
|
||||||
|
annotations = {
|
||||||
|
# Prometheus scrapes the service-endpoints (annotations live on the
|
||||||
|
# Service below); the pod annotations here let the kubernetes-pods
|
||||||
|
# SD job also discover /metrics directly.
|
||||||
|
"prometheus.io/scrape" = "true"
|
||||||
|
"prometheus.io/path" = "/metrics"
|
||||||
|
"prometheus.io/port" = "8080"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
spec {
|
||||||
|
image_pull_secrets {
|
||||||
|
name = "registry-credentials"
|
||||||
|
}
|
||||||
|
|
||||||
|
init_container {
|
||||||
|
name = "alembic-migrate"
|
||||||
|
image = local.image
|
||||||
|
command = ["python", "-m", "nextcloud_todos", "migrate"]
|
||||||
|
|
||||||
|
env_from {
|
||||||
|
secret_ref { name = "nextcloud-todos-secrets" }
|
||||||
|
}
|
||||||
|
env_from {
|
||||||
|
secret_ref { name = "nextcloud-todos-db-creds" }
|
||||||
|
}
|
||||||
|
|
||||||
|
resources {
|
||||||
|
requests = { cpu = "50m", memory = "256Mi" }
|
||||||
|
limits = { memory = "512Mi" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
container {
|
||||||
|
name = "nextcloud-todos"
|
||||||
|
image = local.image
|
||||||
|
|
||||||
|
port {
|
||||||
|
container_port = 8080
|
||||||
|
}
|
||||||
|
|
||||||
|
env_from {
|
||||||
|
secret_ref { name = "nextcloud-todos-secrets" }
|
||||||
|
}
|
||||||
|
env_from {
|
||||||
|
secret_ref { name = "nextcloud-todos-db-creds" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Nextcloud / CalDAV
|
||||||
|
env {
|
||||||
|
name = "NEXTCLOUD_BASE_URL"
|
||||||
|
value = "https://nextcloud.viktorbarzin.me"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "NEXTCLOUD_USER"
|
||||||
|
value = "admin"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "LIST_ALLOWLIST"
|
||||||
|
value = "Personal"
|
||||||
|
}
|
||||||
|
# Tier-0 LLM classifier
|
||||||
|
env {
|
||||||
|
name = "LLAMA_SWAP_URL"
|
||||||
|
value = "http://llama-swap.llama-cpp.svc.cluster.local:8080"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "LLAMA_SWAP_MODEL"
|
||||||
|
value = "qwen3-8b"
|
||||||
|
}
|
||||||
|
# Tier-2 LLM (claude-agent-service: research auto-run + two-pass exec)
|
||||||
|
env {
|
||||||
|
name = "CLAUDE_AGENT_URL"
|
||||||
|
value = "http://claude-agent-service.claude-agent.svc.cluster.local:8080"
|
||||||
|
}
|
||||||
|
# Public callback base URL for the Telegram inline-keyboard URL
|
||||||
|
# buttons. Must match the ingress host below (proxied via Cloudflare).
|
||||||
|
env {
|
||||||
|
name = "CALLBACK_BASE_URL"
|
||||||
|
value = "https://nextcloud-todos.viktorbarzin.me"
|
||||||
|
}
|
||||||
|
|
||||||
|
readiness_probe {
|
||||||
|
http_get {
|
||||||
|
path = "/healthz"
|
||||||
|
port = 8080
|
||||||
|
}
|
||||||
|
initial_delay_seconds = 5
|
||||||
|
period_seconds = 10
|
||||||
|
}
|
||||||
|
liveness_probe {
|
||||||
|
http_get {
|
||||||
|
path = "/healthz"
|
||||||
|
port = 8080
|
||||||
|
}
|
||||||
|
initial_delay_seconds = 30
|
||||||
|
period_seconds = 30
|
||||||
|
}
|
||||||
|
|
||||||
|
resources {
|
||||||
|
requests = { cpu = "100m", memory = "384Mi" }
|
||||||
|
limits = { memory = "768Mi" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
ignore_changes = [
|
||||||
|
spec[0].template[0].spec[0].dns_config, # KYVERNO_LIFECYCLE_V1
|
||||||
|
metadata[0].annotations["keel.sh/policy"],
|
||||||
|
metadata[0].annotations["keel.sh/trigger"],
|
||||||
|
metadata[0].annotations["keel.sh/pollSchedule"], # KYVERNO_LIFECYCLE_V2
|
||||||
|
metadata[0].annotations["keel.sh/match-tag"],
|
||||||
|
spec[0].template[0].spec[0].container[0].image, # KEEL_IGNORE_IMAGE — Keel manages tag updates
|
||||||
|
spec[0].template[0].spec[0].init_container[0].image,
|
||||||
|
metadata[0].annotations["kubernetes.io/change-cause"],
|
||||||
|
metadata[0].annotations["deployment.kubernetes.io/revision"],
|
||||||
|
spec[0].template[0].metadata[0].annotations["keel.sh/update-time"], # KEEL_LIFECYCLE_V1
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
depends_on = [
|
||||||
|
kubernetes_manifest.external_secret,
|
||||||
|
kubernetes_manifest.db_external_secret,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "kubernetes_service" "nextcloud_todos" {
|
||||||
|
metadata {
|
||||||
|
name = "nextcloud-todos"
|
||||||
|
namespace = kubernetes_namespace.nextcloud_todos.metadata[0].name
|
||||||
|
labels = local.labels
|
||||||
|
annotations = {
|
||||||
|
# Prometheus kubernetes-service-endpoints SD scrapes /metrics here.
|
||||||
|
"prometheus.io/scrape" = "true"
|
||||||
|
"prometheus.io/path" = "/metrics"
|
||||||
|
"prometheus.io/port" = "8080"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
spec {
|
||||||
|
type = "ClusterIP"
|
||||||
|
selector = local.labels
|
||||||
|
|
||||||
|
port {
|
||||||
|
name = "http"
|
||||||
|
port = 8080
|
||||||
|
target_port = 8080
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Kyverno ClusterPolicy `sync-tls-secret` auto-clones the wildcard TLS
|
||||||
|
# secret into every namespace, so we don't need a setup_tls_secret module.
|
||||||
|
|
||||||
|
# Public ingress for the /cb/* callback endpoints driven by Telegram URL
|
||||||
|
# buttons. /nextcloud/hook, /api/* and /healthz stay internal — they're
|
||||||
|
# reached via cluster DNS by Nextcloud's webhook, the OpenClaw plugin, and
|
||||||
|
# kubelet probes respectively.
|
||||||
|
#
|
||||||
|
# auth = "none": the /cb endpoints are gated by HMAC-signed query params
|
||||||
|
# (sig + exp) generated from HMAC_SECRET. Authentik would force a login flow
|
||||||
|
# before the GET could fire and break the one-tap approval flow.
|
||||||
|
module "ingress" {
|
||||||
|
source = "../../modules/kubernetes/ingress_factory"
|
||||||
|
# auth = "none": HMAC + expiry gate the /cb endpoints — Authentik would
|
||||||
|
# force a login dance and break Telegram's one-tap UX. See hmac_links.py.
|
||||||
|
auth = "none"
|
||||||
|
anti_ai_scraping = false
|
||||||
|
dns_type = "proxied"
|
||||||
|
namespace = kubernetes_namespace.nextcloud_todos.metadata[0].name
|
||||||
|
name = "nextcloud-todos"
|
||||||
|
port = 8080
|
||||||
|
ingress_path = ["/cb"]
|
||||||
|
tls_secret_name = var.tls_secret_name
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Nextcloud webhook registration (Task 4.5)
|
||||||
|
# =============================================================================
|
||||||
|
# Registers the two Calendar VTODO events (create + update) against the
|
||||||
|
# Nextcloud `webhook_listeners` OCS API so Nextcloud POSTs to this service
|
||||||
|
# whenever a task on the Personal list changes. Points at the INTERNAL svc
|
||||||
|
# URL (Nextcloud calls it from inside the cluster). Idempotent: GETs the
|
||||||
|
# existing webhooks first and skips registration of any event already
|
||||||
|
# targeting the svc URL (the OCS API has no upsert — a blind re-POST creates
|
||||||
|
# duplicates). Re-runs whenever the bearer token or hook URL changes.
|
||||||
|
#
|
||||||
|
# Auth: admin app-password (Nextcloud admin) for the OCS call itself; the
|
||||||
|
# registered webhook then carries `Authorization: Bearer <webhook token>`
|
||||||
|
# (authMethod=header) so Nextcloud authenticates to THIS service on delivery.
|
||||||
|
# Both values come from Vault — read here as a plan-time data source (this is
|
||||||
|
# the one place the stack needs a raw Vault value: a local-exec provisioner
|
||||||
|
# can't consume an ESO-created K8s Secret).
|
||||||
|
data "vault_kv_secret_v2" "nextcloud_todos" {
|
||||||
|
mount = "secret"
|
||||||
|
name = "nextcloud-todos"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "null_resource" "register_webhooks" {
|
||||||
|
depends_on = [module.ingress]
|
||||||
|
|
||||||
|
triggers = {
|
||||||
|
hook_url = "http://nextcloud-todos.nextcloud-todos.svc.cluster.local:8080/nextcloud/hook"
|
||||||
|
bearer_token = sha256(data.vault_kv_secret_v2.nextcloud_todos.data["webhook_bearer_token"])
|
||||||
|
}
|
||||||
|
|
||||||
|
provisioner "local-exec" {
|
||||||
|
environment = {
|
||||||
|
NC_ADMIN_APP_PW = data.vault_kv_secret_v2.nextcloud_todos.data["nextcloud_admin_app_password"]
|
||||||
|
WEBHOOK_BEARER_TOKEN = data.vault_kv_secret_v2.nextcloud_todos.data["webhook_bearer_token"]
|
||||||
|
}
|
||||||
|
command = <<-EOT
|
||||||
|
set -eu
|
||||||
|
NC="https://nextcloud.viktorbarzin.me/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks"
|
||||||
|
HOOK_URL="http://nextcloud-todos.nextcloud-todos.svc.cluster.local:8080/nextcloud/hook"
|
||||||
|
|
||||||
|
# Idempotency: list existing webhooks and skip any event already
|
||||||
|
# pointing at our svc URL. The OCS API has no upsert; re-POSTing the
|
||||||
|
# same listener silently creates a duplicate, so we gate on a match.
|
||||||
|
EXISTING=$(curl -fsS -H "OCS-APIRequest: true" -H "Accept: application/json" \
|
||||||
|
-u "admin:$${NC_ADMIN_APP_PW}" "$${NC}")
|
||||||
|
|
||||||
|
for EV in CalendarObjectCreatedEvent CalendarObjectUpdatedEvent; do
|
||||||
|
# The event class is a PHP namespace: OCP\Calendar\Events\<EV>. In the
|
||||||
|
# JSON body each backslash must be doubled (valid JSON escape), so the
|
||||||
|
# shell var holds "\\" per separator -> the heredoc source needs four
|
||||||
|
# backslashes per separator (TF heredoc passes them through literally,
|
||||||
|
# bash double-quotes then halve them to "\\").
|
||||||
|
FULL_EVENT="OCP\\\\Calendar\\\\Events\\\\$${EV}"
|
||||||
|
# Match on both the uri and the event short-name within the JSON blob
|
||||||
|
# — grep with fixed strings to avoid regex pitfalls.
|
||||||
|
if echo "$${EXISTING}" | grep -F "$${HOOK_URL}" | grep -qF "$${EV}"; then
|
||||||
|
echo "webhook for $${EV} already registered -> skipping"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
echo "registering webhook for $${EV} ..."
|
||||||
|
curl -fsS -X POST -H "OCS-APIRequest: true" -H "Content-Type: application/json" \
|
||||||
|
-u "admin:$${NC_ADMIN_APP_PW}" "$${NC}" \
|
||||||
|
-d "{\"httpMethod\":\"POST\",
|
||||||
|
\"uri\":\"$${HOOK_URL}\",
|
||||||
|
\"event\":\"$${FULL_EVENT}\",
|
||||||
|
\"authMethod\":\"header\",
|
||||||
|
\"authData\":{\"Authorization\":\"Bearer $${WEBHOOK_BEARER_TOKEN}\"}}"
|
||||||
|
echo
|
||||||
|
done
|
||||||
|
echo "webhook registration complete"
|
||||||
|
EOT
|
||||||
|
}
|
||||||
|
}
|
||||||
24
stacks/nextcloud-todos/terragrunt.hcl
Normal file
24
stacks/nextcloud-todos/terragrunt.hcl
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
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 = {
|
||||||
|
# Override per-deploy in CI / commit.
|
||||||
|
image_tag = "latest"
|
||||||
|
postgresql_host = "pg-cluster-rw.dbaas.svc.cluster.local"
|
||||||
|
}
|
||||||
|
|
@ -58,6 +58,19 @@ resource "kubernetes_manifest" "external_secret" {
|
||||||
key = "openclaw"
|
key = "openclaw"
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
|
# Cross-path key: the nextcloud-todos-api plugin authenticates to the
|
||||||
|
# nextcloud-todos service with ITS bearer token, which lives in
|
||||||
|
# secret/nextcloud-todos (not secret/openclaw). Pull just that one key
|
||||||
|
# into openclaw-secrets so the plugin's NEXTCLOUD_TODOS_TOKEN env can
|
||||||
|
# secret_key_ref it (same model as the recruiter plugin, whose token
|
||||||
|
# happens to already live under secret/openclaw).
|
||||||
|
data = [{
|
||||||
|
secretKey = "nextcloud_todos_bearer_token"
|
||||||
|
remoteRef = {
|
||||||
|
key = "nextcloud-todos"
|
||||||
|
property = "webhook_bearer_token"
|
||||||
|
}
|
||||||
|
}]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
depends_on = [kubernetes_namespace.openclaw]
|
depends_on = [kubernetes_namespace.openclaw]
|
||||||
|
|
@ -202,7 +215,7 @@ resource "kubernetes_config_map" "openclaw_config" {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
plugins = {
|
plugins = {
|
||||||
allow = ["memory-core", "recruiter-api"]
|
allow = ["memory-core", "recruiter-api", "nextcloud-todos-api"]
|
||||||
slots = { memory = "memory-core" }
|
slots = { memory = "memory-core" }
|
||||||
load = {
|
load = {
|
||||||
# /app/extensions is the legacy bundled-plugins path; OpenClaw
|
# /app/extensions is the legacy bundled-plugins path; OpenClaw
|
||||||
|
|
@ -517,6 +530,39 @@ resource "kubernetes_deployment" "openclaw" {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Init 3b: install the nextcloud-todos-api OpenClaw plugin from the
|
||||||
|
# nextcloud-todos image into NFS extensions/. Plugin lifecycle is
|
||||||
|
# coupled to the nextcloud-todos image tag — bumping that tag
|
||||||
|
# re-installs the plugin on next openclaw pod restart. Same pattern as
|
||||||
|
# install-recruiter-plugin above.
|
||||||
|
init_container {
|
||||||
|
name = "install-nextcloud-todos-plugin"
|
||||||
|
image = "forgejo.viktorbarzin.me/viktor/nextcloud-todos:latest"
|
||||||
|
command = ["sh", "-c", <<-EOT
|
||||||
|
set -eu
|
||||||
|
mkdir -p /home/node/.openclaw/extensions/nextcloud-todos-api
|
||||||
|
cp -r /app/openclaw-plugin/. /home/node/.openclaw/extensions/nextcloud-todos-api/
|
||||||
|
chown -R 1000:1000 /home/node/.openclaw/extensions/nextcloud-todos-api
|
||||||
|
echo "nextcloud-todos-api plugin installed at /home/node/.openclaw/extensions/nextcloud-todos-api"
|
||||||
|
ls -la /home/node/.openclaw/extensions/nextcloud-todos-api
|
||||||
|
EOT
|
||||||
|
]
|
||||||
|
# /home/node/.openclaw is uid 1000 on NFS; nextcloud-todos image
|
||||||
|
# otherwise drops to uid 10001 which can't write or chown. Run as
|
||||||
|
# root so mkdir + chown succeed.
|
||||||
|
security_context {
|
||||||
|
run_as_user = 0
|
||||||
|
}
|
||||||
|
volume_mount {
|
||||||
|
name = "openclaw-home"
|
||||||
|
mount_path = "/home/node/.openclaw"
|
||||||
|
}
|
||||||
|
resources {
|
||||||
|
requests = { cpu = "50m", memory = "64Mi" }
|
||||||
|
limits = { memory = "128Mi" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Init 4: install host-tools bundle (ssh, vault, jq, ripgrep, tmux, …)
|
# Init 4: install host-tools bundle (ssh, vault, jq, ripgrep, tmux, …)
|
||||||
# into /tools/host-tools/ so the in-pod agent reaches CLI parity
|
# into /tools/host-tools/ so the in-pod agent reaches CLI parity
|
||||||
# with the dev VM. Upstream OpenClaw image is minimal Debian
|
# with the dev VM. Upstream OpenClaw image is minimal Debian
|
||||||
|
|
@ -1126,9 +1172,10 @@ resource "kubernetes_deployment" "openclaw" {
|
||||||
# doctor --fix overwrites plugins.allow with its bundled-plugins
|
# doctor --fix overwrites plugins.allow with its bundled-plugins
|
||||||
# list. Re-add our third-party plugin to the allow list via
|
# list. Re-add our third-party plugin to the allow list via
|
||||||
# `config patch`, then enable it. (Same pattern as mcp set above.)
|
# `config patch`, then enable it. (Same pattern as mcp set above.)
|
||||||
echo '{"plugins":{"allow":["memory-core","recruiter-api","telegram","openrouter","brave","openai","codex"]}}' \
|
echo '{"plugins":{"allow":["memory-core","recruiter-api","nextcloud-todos-api","telegram","openrouter","brave","openai","codex"]}}' \
|
||||||
| node openclaw.mjs config patch --stdin 2>/dev/null || true
|
| node openclaw.mjs config patch --stdin 2>/dev/null || true
|
||||||
node openclaw.mjs plugins enable recruiter-api 2>/dev/null || true
|
node openclaw.mjs plugins enable recruiter-api 2>/dev/null || true
|
||||||
|
node openclaw.mjs plugins enable nextcloud-todos-api 2>/dev/null || true
|
||||||
# Reindex memory-core so the seeded devvm-fallback note (and
|
# Reindex memory-core so the seeded devvm-fallback note (and
|
||||||
# anything else dropped under /workspace/memory/) is searchable
|
# anything else dropped under /workspace/memory/) is searchable
|
||||||
# on first boot; daily memory-sync CronJob also keeps it indexed.
|
# on first boot; daily memory-sync CronJob also keeps it indexed.
|
||||||
|
|
@ -1242,7 +1289,25 @@ resource "kubernetes_deployment" "openclaw" {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
# nextcloud-todos API — consumed by the nextcloud-todos-api plugin
|
||||||
|
# (mounted into /home/node/.openclaw/extensions/nextcloud-todos-api/
|
||||||
|
# via the install-nextcloud-todos-plugin init container above).
|
||||||
|
env {
|
||||||
|
name = "NEXTCLOUD_TODOS_URL"
|
||||||
|
value = "http://nextcloud-todos.nextcloud-todos.svc.cluster.local:8080"
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
name = "NEXTCLOUD_TODOS_TOKEN"
|
||||||
|
value_from {
|
||||||
|
secret_key_ref {
|
||||||
|
name = "openclaw-secrets"
|
||||||
|
key = "nextcloud_todos_bearer_token"
|
||||||
|
optional = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
# Telegram chat ID for the recruiter-api plugin's announcement loop.
|
# Telegram chat ID for the recruiter-api plugin's announcement loop.
|
||||||
|
# Also consumed by the nextcloud-todos-api plugin (shared chat).
|
||||||
env {
|
env {
|
||||||
name = "VIKTOR_CHAT_ID"
|
name = "VIKTOR_CHAT_ID"
|
||||||
value_from {
|
value_from {
|
||||||
|
|
|
||||||
|
|
@ -722,6 +722,25 @@ locals {
|
||||||
retry_interval = 30
|
retry_interval = 30
|
||||||
max_retries = 3
|
max_retries = 3
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
# Internal /healthz probe of the nextcloud-todos service. The `/cb`
|
||||||
|
# ingress carries the `[External]` HTTPS monitor (auto-created by
|
||||||
|
# external-monitor-sync), but those endpoints are HMAC-gated and only
|
||||||
|
# cover the callback path — this checks the app's own liveness inside
|
||||||
|
# the cluster on the ClusterIP svc. Plain HTTP, expects a clean 200.
|
||||||
|
name = "nextcloud-todos (/healthz)"
|
||||||
|
type = "http"
|
||||||
|
database_connection_string = null
|
||||||
|
database_password_vault_key = null
|
||||||
|
hostname = null
|
||||||
|
port = null
|
||||||
|
url = "http://nextcloud-todos.nextcloud-todos.svc.cluster.local:8080/healthz"
|
||||||
|
accepted_statuscodes = ["200-299"]
|
||||||
|
ignore_tls = false
|
||||||
|
interval = 60
|
||||||
|
retry_interval = 30
|
||||||
|
max_retries = 3
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -617,6 +617,7 @@ resource "vault_database_secret_backend_connection" "postgresql" {
|
||||||
"pg-wealthfolio-sync", "pg-fire-planner",
|
"pg-wealthfolio-sync", "pg-fire-planner",
|
||||||
"pg-postiz", "pg-instagram-poster",
|
"pg-postiz", "pg-instagram-poster",
|
||||||
"pg-recruiter-responder", "pg-tripit",
|
"pg-recruiter-responder", "pg-tripit",
|
||||||
|
"pg-nextcloud-todos",
|
||||||
"pg-matrix", "pg-technitium",
|
"pg-matrix", "pg-technitium",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -811,6 +812,14 @@ resource "vault_database_secret_backend_static_role" "pg_recruiter_responder" {
|
||||||
rotation_period = 604800
|
rotation_period = 604800
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resource "vault_database_secret_backend_static_role" "pg_nextcloud_todos" {
|
||||||
|
backend = vault_mount.database.path
|
||||||
|
db_name = vault_database_secret_backend_connection.postgresql.name
|
||||||
|
name = "pg-nextcloud-todos"
|
||||||
|
username = "nextcloud_todos"
|
||||||
|
rotation_period = 604800
|
||||||
|
}
|
||||||
|
|
||||||
resource "vault_database_secret_backend_static_role" "pg_tripit" {
|
resource "vault_database_secret_backend_static_role" "pg_tripit" {
|
||||||
backend = vault_mount.database.path
|
backend = vault_mount.database.path
|
||||||
db_name = vault_database_secret_backend_connection.postgresql.name
|
db_name = vault_database_secret_backend_connection.postgresql.name
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue