mirror of
https://github.com/immich-app/yucca.git
synced 2026-09-30 13:33:00 +08:00
feat(infra): deploy futo-backups-bot (#545)
This commit is contained in:
@@ -26,3 +26,12 @@ OP_ACCOUNT="team-futo.1password.com"
|
||||
# POSTMARK_API_URL="https://api.postmarkapp.com"
|
||||
# POSTMARK_SERVER_TOKEN="op://yucca_tf_staging/POSTMARK_SERVER_TOKEN/password"
|
||||
# EMAIL_FROM_ADDRESS="FUTO Backups <noreply@backups.futo.cloud>"
|
||||
|
||||
# futo-backups-bot: without a token the bot idles. Point it at your dev guild
|
||||
# (ids from Discord's developer mode) to run the real flow.
|
||||
# DISCORD_BOT_TOKEN="op://yucca_tf_dev/YUCCA_DISCORD_BOT_TOKEN/password"
|
||||
# DISCORD_GUILD_ID=
|
||||
# DISCORD_STAFF_ROLE_ID=
|
||||
# DISCORD_SUPPORT_CHANNEL_ID=
|
||||
# DISCORD_TICKET_CATEGORY_ID=
|
||||
# DISCORD_ARCHIVE_CATEGORY_ID=
|
||||
|
||||
@@ -52,6 +52,7 @@ jobs:
|
||||
- { name: yucca-api, dockerfile: packages/yucca-api/Dockerfile }
|
||||
- { name: yucca-admin-api, dockerfile: packages/yucca-admin-api/Dockerfile }
|
||||
- { name: yucca-metrics-worker, dockerfile: packages/yucca-metrics-worker/Dockerfile }
|
||||
- { name: futo-backups-bot, dockerfile: packages/futo-backups-bot/Dockerfile }
|
||||
- { name: web, dockerfile: packages/web/Dockerfile }
|
||||
- { name: michael, dockerfile: packages/michael/Dockerfile }
|
||||
steps:
|
||||
|
||||
@@ -12,8 +12,8 @@ set -euo pipefail
|
||||
# Charts are role-grouped: apps/* (services), platform/* (operators/CRs),
|
||||
# lib/yucca-common (shared library), dev/* (dev-only). Paths below are
|
||||
# relative to charts/.
|
||||
LIB_CONSUMERS=(apps/yucca-api apps/yucca-admin-api apps/yucca-metrics-worker apps/web apps/meta apps/michael dev/mock-oidc dev/mock-postmark dev/mailpit)
|
||||
ALL_CHARTS=(apps/yucca-api apps/yucca-admin-api apps/yucca-metrics-worker apps/web apps/meta apps/michael dev/mock-oidc dev/mock-postmark dev/mailpit platform/cnpg-cluster platform/ceph-objectuser platform/rook-ceph-cluster)
|
||||
LIB_CONSUMERS=(apps/yucca-api apps/yucca-admin-api apps/yucca-metrics-worker apps/futo-backups-bot apps/web apps/meta apps/michael dev/mock-oidc dev/mock-postmark dev/mailpit)
|
||||
ALL_CHARTS=(apps/yucca-api apps/yucca-admin-api apps/yucca-metrics-worker apps/futo-backups-bot apps/web apps/meta apps/michael dev/mock-oidc dev/mock-postmark dev/mailpit platform/cnpg-cluster platform/ceph-objectuser platform/rook-ceph-cluster)
|
||||
|
||||
echo "==> helm dependency build (yucca-common consumers)"
|
||||
for c in "${LIB_CONSUMERS[@]}"; do
|
||||
|
||||
@@ -252,6 +252,32 @@ docker_build(
|
||||
],
|
||||
)
|
||||
|
||||
docker_build(
|
||||
'futo-backups-bot',
|
||||
context='.',
|
||||
dockerfile='packages/futo-backups-bot/Dockerfile',
|
||||
target='dev',
|
||||
only=[
|
||||
'./pnpm-workspace.yaml',
|
||||
'./pnpm-lock.yaml',
|
||||
'./package.json',
|
||||
'./.npmrc',
|
||||
'./packages',
|
||||
],
|
||||
ignore=[
|
||||
'**/node_modules',
|
||||
'**/dist',
|
||||
'**/.svelte-kit',
|
||||
'packages/michael',
|
||||
'packages/e2e',
|
||||
],
|
||||
live_update=[
|
||||
sync('./packages/futo-backups-bot', '/app/packages/futo-backups-bot'),
|
||||
sync('./packages/common', '/app/packages/common'),
|
||||
run('cd /app && pnpm --filter @common/server build', trigger=['./packages/common/src']),
|
||||
],
|
||||
)
|
||||
|
||||
# mock-oidc-provider has no dev target (config-only via env); a plain build is
|
||||
# enough — it rarely changes and is reconfigured through Helm values.
|
||||
docker_build(
|
||||
@@ -296,11 +322,12 @@ docker_build(
|
||||
# ---------------------------------------------------------------------------
|
||||
local_resource(
|
||||
'helm-deps',
|
||||
cmd='rm -rf charts/apps/yucca-api/charts charts/apps/yucca-admin-api/charts charts/apps/yucca-metrics-worker/charts charts/apps/web/charts charts/apps/meta/charts charts/apps/michael/charts charts/dev/mock-oidc/charts charts/dev/mock-postmark/charts charts/dev/mailpit/charts && for d in charts/apps/yucca-api charts/apps/yucca-admin-api charts/apps/yucca-metrics-worker charts/apps/web charts/apps/meta charts/apps/michael charts/dev/mock-oidc charts/dev/mock-postmark charts/dev/mailpit; do (cd $d && helm dependency build); done',
|
||||
cmd='rm -rf charts/apps/yucca-api/charts charts/apps/yucca-admin-api/charts charts/apps/yucca-metrics-worker/charts charts/apps/futo-backups-bot/charts charts/apps/web/charts charts/apps/meta/charts charts/apps/michael/charts charts/dev/mock-oidc/charts charts/dev/mock-postmark/charts charts/dev/mailpit/charts && for d in charts/apps/yucca-api charts/apps/yucca-admin-api charts/apps/yucca-metrics-worker charts/apps/futo-backups-bot charts/apps/web charts/apps/meta charts/apps/michael charts/dev/mock-oidc charts/dev/mock-postmark charts/dev/mailpit; do (cd $d && helm dependency build); done',
|
||||
deps=[
|
||||
'charts/apps/yucca-api',
|
||||
'charts/apps/yucca-admin-api',
|
||||
'charts/apps/yucca-metrics-worker',
|
||||
'charts/apps/futo-backups-bot',
|
||||
'charts/apps/web',
|
||||
'charts/apps/meta',
|
||||
'charts/apps/michael',
|
||||
@@ -337,6 +364,9 @@ APP_WIRING = {
|
||||
'yucca-api': {'build': 'yucca-api', 'deps': ['yucca-database', 'yucca-mock-oidc', 'yucca-topology'], 'dev_env': True, 'dev_keypair': True},
|
||||
'yucca-admin-api': {'build': 'yucca-admin-api', 'deps': ['yucca-database', 'yucca-mock-oidc', 'yucca-topology'], 'dev_env': True, 'dev_keypair': True},
|
||||
'yucca-metrics-worker': {'build': 'yucca-metrics-worker', 'deps': ['yucca-database', 'yucca-metrics-object-user', 'yucca-topology'], 'dev_env': True},
|
||||
# Idle without a DISCORD_BOT_TOKEN (supplied via .env → yucca-dev-env);
|
||||
# only talks to yucca-api's internal endpoints, never the DB.
|
||||
'futo-backups-bot': {'build': 'futo-backups-bot', 'deps': ['yucca-api'], 'dev_env': True},
|
||||
# Likewise: the dev server reaches yucca-api per request, not at boot.
|
||||
'yucca-web': {'build': 'web', 'deps': []},
|
||||
# Stock upstream nginx serving the .well-known pointer — nothing to build,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
apiVersion: v2
|
||||
name: futo-backups-bot
|
||||
description: FUTO Backups Discord support bot (NestJS)
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "0.37.1" # x-release-please-version
|
||||
dependencies:
|
||||
- name: yucca-common
|
||||
version: 0.2.0
|
||||
repository: "file://../../lib/yucca-common"
|
||||
@@ -0,0 +1,4 @@
|
||||
{{- $_ := set .Values "envFrom" (concat
|
||||
(list (dict "secretRef" (dict "name" (include "yucca-common.fullname" .) "optional" true)))
|
||||
(.Values.extraEnvFrom | default (list))) }}
|
||||
{{- include "yucca-common.deployment" . }}
|
||||
@@ -0,0 +1 @@
|
||||
{{- include "yucca-common.secret" . }}
|
||||
@@ -0,0 +1,53 @@
|
||||
# Single replica ON PURPOSE: one Discord gateway session; two replicas would
|
||||
# double-handle every interaction.
|
||||
replicas: 1
|
||||
|
||||
resources:
|
||||
requests: { cpu: 50m, memory: 256Mi }
|
||||
limits: { memory: 1Gi }
|
||||
|
||||
# Stable in-cluster name, independent of the Helm release name (dev == prod).
|
||||
fullnameOverride: futo-backups-bot
|
||||
|
||||
image:
|
||||
repository: k3d-registry.localhost:5000/futo-backups-bot
|
||||
tag: dev
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3050
|
||||
|
||||
# Empty token on purpose: the bot idles without one, so dev without a Discord
|
||||
# guild stays green. Real dev values arrive via the yucca-dev-env extraEnvFrom
|
||||
# layer (last envFrom wins), prod's via the TF-provisioned Secret of the same
|
||||
# name (secretData nulled in the base HelmRelease).
|
||||
secretData:
|
||||
DISCORD_BOT_TOKEN: ""
|
||||
INTERNAL_SECRET: dev-internal-secret
|
||||
|
||||
extraEnvFrom: []
|
||||
|
||||
env:
|
||||
- name: NODE_ENV
|
||||
value: development
|
||||
- name: FUTO_BACKUPS_BOT_PORT
|
||||
value: "3050"
|
||||
- name: YUCCA_API_URL
|
||||
value: http://yucca-api:3020
|
||||
- name: WEB_URL
|
||||
value: http://localhost:5173
|
||||
- name: LOG_LEVEL
|
||||
value: debug
|
||||
- name: OTEL_METRICS
|
||||
value: http://victoria-metrics:8428/opentelemetry/v1/metrics
|
||||
- name: OTEL_LOGGING
|
||||
value: http://victoria-logs:9428/insert/opentelemetry/v1/logs
|
||||
|
||||
startupProbe:
|
||||
tcpSocket: { port: http }
|
||||
periodSeconds: 5
|
||||
failureThreshold: 60
|
||||
readinessProbe:
|
||||
tcpSocket: { port: http }
|
||||
periodSeconds: 10
|
||||
@@ -52,6 +52,7 @@ oidcLogoutRedirectUri: http://localhost:5173
|
||||
secretData:
|
||||
OIDC_CLIENT_ID: "client ID"
|
||||
OIDC_CLIENT_SECRET: "client secret"
|
||||
INTERNAL_SECRET: dev-internal-secret
|
||||
|
||||
# OPT-IN dev signing key. The project's well-known local-dev ES256 keypair
|
||||
# (the same one committed in .mise/tasks/*/env; michael verifies with the
|
||||
|
||||
+42
-34
@@ -2,9 +2,9 @@
|
||||
|
||||
Support runs through Discord: a pinned message in the public support channel
|
||||
carries a **Get support** button; clicking it links the Discord account to the
|
||||
user's yucca account (once), then opens a **private ticket channel** with the
|
||||
user and the staff role, seeded with the user's issue description and a
|
||||
staff-only context thread.
|
||||
user's yucca account (once), then opens a **private ticket thread** under the
|
||||
support channel with the user, seeded with the user's issue description and
|
||||
paired with a staff-only context thread.
|
||||
|
||||
```
|
||||
click button ──> linked? ──no──> one-time web link ──> login + confirm ──> discordLinks row
|
||||
@@ -12,8 +12,8 @@ click button ──> linked? ──no──> one-time web link ──> login + c
|
||||
└──────────────> description modal <────── bot polls ───────┘
|
||||
│submit
|
||||
v
|
||||
#ticket-<username> (user + staff role)
|
||||
└─ private "staff-notes" thread (Grafana link + account summary)
|
||||
private thread ticket-<user> (member: user; staff via Manage Threads)
|
||||
+ private thread staff-<user> (Grafana link + account summary)
|
||||
```
|
||||
|
||||
## The service
|
||||
@@ -47,52 +47,60 @@ identity by the normal web session. The nonce marries the two:
|
||||
until the link exists (or the nonce expires), then edits the ephemeral
|
||||
reply to an **Open ticket** button (a modal needs a fresh interaction).
|
||||
|
||||
Linking is **required**: every ticket belongs to a known account.
|
||||
Pre-signup questions stay in public channels.
|
||||
Linking is **required** for the self-serve button: every self-opened ticket
|
||||
belongs to a known account. Staff can bypass it with **`/ticket user:<user>`**
|
||||
(staff-only slash command) — the thread is opened for the target user directly,
|
||||
and the staff note records whether a linked account exists. Pre-signup
|
||||
questions stay in public channels or go through that override.
|
||||
|
||||
## Tickets: Discord is the source of truth
|
||||
|
||||
No ticket table. State is which category the channel sits in; metadata
|
||||
(linked `userId`, closedAt) lives in the channel topic. yucca-api's scope
|
||||
stays pure account-linking.
|
||||
No ticket table. A ticket is a **private thread** under the support channel;
|
||||
closed = **locked + archived** (locked distinguishes a real close from
|
||||
Discord's auto-archive on idle), and Discord's own `archiveTimestamp` drives
|
||||
retention. yucca-api's scope stays pure account-linking.
|
||||
|
||||
- **Open**: the button (always, when linked) opens a **modal with a required
|
||||
description field**; the channel is only created on submit. The bot creates
|
||||
`ticket-<username>` under the Support category with permission overwrites
|
||||
(the user + `DISCORD_STAFF_ROLE_ID`), posts the description as the opening
|
||||
message, and creates a **private `staff-notes` thread** containing the
|
||||
description field**; the thread is only created on submit. The bot creates
|
||||
private thread `ticket-<username>-<id suffix>`, adds the user as a member,
|
||||
and posts the description with a mention of the user and
|
||||
`DISCORD_STAFF_ROLE_ID` (mentioning the role adds staff to the thread). A
|
||||
sibling private thread `staff-<same suffix>` with **no members** carries the
|
||||
user's Grafana dashboard link (`GRAFANA_USER_DASHBOARD_URL` template; the
|
||||
dashboard itself is o11y-owned) and an account summary from
|
||||
**`GET /internal/discord/users/:userId/summary`** (email, connections,
|
||||
repository count, last seen). Staff see the thread via a Manage Threads
|
||||
grant on the category; the user cannot. One open ticket per user; a second
|
||||
click jumps to the existing channel.
|
||||
- **Close** (staff-only button/command): strips the user's overwrite and
|
||||
moves the channel to the Archived category, stamping closedAt in the topic.
|
||||
- **Sweep** (daily): archived channels closed **> 14 days** ago
|
||||
repository count, last seen) — staff see it via Manage Threads on the
|
||||
support channel; the user cannot. One open ticket per user (membership scan
|
||||
of active threads); a second submit points at the existing thread.
|
||||
- **Close** (staff-only button): locks + archives the ticket thread and its
|
||||
staff sibling. The user keeps read access to their own closed ticket but
|
||||
cannot post or reopen; staff can unarchive via Manage Threads.
|
||||
- **Sweep** (daily): locked threads archived **> 14 days** ago
|
||||
(`TICKET_RETENTION_DAYS`) are rendered to a plain-text transcript
|
||||
(timestamp / author / content, attachments as URLs), uploaded to S3
|
||||
(`TRANSCRIPT_S3_*`, Ceph RGW in prod), then deleted. History survives as
|
||||
the transcript; the 500-channel guild cap stays far away.
|
||||
the transcript; threads never touch the guild's channel cap.
|
||||
|
||||
## Configuration
|
||||
|
||||
Deployment config (ops-owned): env via cluster-settings; the bot token is a
|
||||
Terraform-provisioned secret (`op://` ref), dev uses the existing dev guild
|
||||
through `.env`.
|
||||
The Discord surface itself is Terraform in **core-infra-tf** (community
|
||||
discord module): the FUTO Backups category with #general + #support, the
|
||||
hidden archive category, and a per-env `YUCCA_DISCORD_SUPPORT_IDS` 1P item
|
||||
carrying the ids (dev Immich server ↔ yucca staging, prod Immich ↔ yucca
|
||||
prod). Yucca's talos stack reads everything secretish via `op://` refs into
|
||||
the `futo-backups-bot` Secret; only the leftovers ride cluster-settings. Dev
|
||||
uses the dev guild through `.env`.
|
||||
|
||||
| Variable | What |
|
||||
| Variable | Source |
|
||||
|---|---|
|
||||
| `DISCORD_BOT_TOKEN` | secret |
|
||||
| `DISCORD_GUILD_ID` | the guild |
|
||||
| `DISCORD_STAFF_ROLE_ID` | role granted on every ticket |
|
||||
| `DISCORD_SUPPORT_CHANNEL_ID` | public channel holding the pinned button |
|
||||
| `DISCORD_TICKET_CATEGORY_ID` / `DISCORD_ARCHIVE_CATEGORY_ID` | open / closed tickets |
|
||||
| `GRAFANA_USER_DASHBOARD_URL` | URL template, `{userId}` substituted |
|
||||
| `YUCCA_API_URL`, `INTERNAL_SECRET` | internal API access |
|
||||
| `WEB_URL` | base for the link-confirmation URL |
|
||||
| `DISCORD_BOT_TOKEN` | Secret ← `YUCCA_DISCORD_BOT_TOKEN` (manual item) |
|
||||
| `DISCORD_GUILD_ID`, `DISCORD_STAFF_ROLE_ID`, `DISCORD_SUPPORT_CHANNEL_ID` | Secret ← `YUCCA_DISCORD_SUPPORT_IDS` (written by core-infra-tf's discord apply) |
|
||||
| `INTERNAL_SECRET` | Secret ← TF-generated (`random_password`, shared with yucca-api) |
|
||||
| `TRANSCRIPT_S3_ACCESS_KEY_ID` / `..._SECRET_ACCESS_KEY` | Secret ← ceph-stack-minted `*_CEPH_S3_SVC_YUCCA_TRANSCRIPTS_*` |
|
||||
| `TRANSCRIPT_S3_ENDPOINT`, `TRANSCRIPT_S3_BUCKET` | cluster-settings |
|
||||
| `GRAFANA_USER_DASHBOARD_URL` | cluster-settings; URL template, `{userId}` substituted |
|
||||
| `YUCCA_API_URL`, `WEB_URL` | HelmRelease env |
|
||||
| `TICKET_RETENTION_DAYS` | archive retention before transcript + delete (14) |
|
||||
| `TRANSCRIPT_S3_*` | endpoint, bucket, credentials, region for transcripts |
|
||||
|
||||
## Where things live
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
# yaml-language-server: $schema=https://k8s-schemas.home-operations.com/helm.toolkit.fluxcd.io/helmrelease_v2.json
|
||||
apiVersion: helm.toolkit.fluxcd.io/v2
|
||||
kind: HelmRelease
|
||||
metadata:
|
||||
name: futo-backups-bot
|
||||
spec:
|
||||
interval: 1h
|
||||
chart:
|
||||
spec:
|
||||
chart: charts/apps/futo-backups-bot
|
||||
# Repackage on every git revision — the in-repo charts keep a static
|
||||
# version, so the default ChartVersion strategy never ships template edits.
|
||||
reconcileStrategy: Revision
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: ${CHART_SOURCE:=flux-system}
|
||||
namespace: flux-system
|
||||
install:
|
||||
remediation:
|
||||
retries: 3
|
||||
upgrade:
|
||||
cleanupOnFail: true
|
||||
remediation:
|
||||
retries: 3
|
||||
values:
|
||||
image:
|
||||
repository: ghcr.io/immich-app/yucca/futo-backups-bot
|
||||
tag: ${YUCCA_IMAGE_TAG:=}
|
||||
# Drop the chart's dev-fixture Secret — the real DISCORD_BOT_TOKEN,
|
||||
# INTERNAL_SECRET, DISCORD_* ids (from the YUCCA_DISCORD_SUPPORT_IDS item
|
||||
# core-infra-tf's discord apply writes), and TRANSCRIPT_S3_* credentials
|
||||
# all arrive via the TF-provisioned futo-backups-bot Secret
|
||||
# (tf .../secrets.tf). Until they land there the bot boots idle instead of
|
||||
# crashing. NB: the ids must NOT appear under env: — explicit env beats
|
||||
# envFrom, so an empty value here would shadow the Secret.
|
||||
secretData: null
|
||||
env:
|
||||
- name: NODE_ENV
|
||||
value: production
|
||||
- name: FUTO_BACKUPS_BOT_PORT
|
||||
value: "3050"
|
||||
- name: LOG_LEVEL
|
||||
value: info
|
||||
- name: OTEL_METRICS
|
||||
value: http://${VMAGENT_OTLP}/opentelemetry/v1/metrics
|
||||
- name: YUCCA_API_URL
|
||||
value: http://yucca-api:3020
|
||||
- name: WEB_URL
|
||||
value: https://${APP_DOMAIN}
|
||||
- name: GRAFANA_USER_DASHBOARD_URL
|
||||
value: ${GRAFANA_USER_DASHBOARD_URL:=}
|
||||
- name: TRANSCRIPT_S3_ENDPOINT
|
||||
value: ${TRANSCRIPT_S3_ENDPOINT:=}
|
||||
- name: TRANSCRIPT_S3_BUCKET
|
||||
value: ${TRANSCRIPT_S3_BUCKET:=}
|
||||
@@ -0,0 +1,4 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- ./helmrelease.yaml
|
||||
@@ -12,6 +12,22 @@ spec:
|
||||
hostnames:
|
||||
- "${APP_DOMAIN}"
|
||||
rules:
|
||||
# Longest-prefix match wins: /api/internal never reaches yucca-api from the
|
||||
# internet — the internal-404 filter answers instead. Pod-to-pod callers
|
||||
# (futo-backups-bot) bypass the gateway entirely.
|
||||
- matches:
|
||||
- path:
|
||||
type: PathPrefix
|
||||
value: /api/internal
|
||||
filters:
|
||||
- type: ExtensionRef
|
||||
extensionRef:
|
||||
group: gateway.envoyproxy.io
|
||||
kind: HTTPRouteFilter
|
||||
name: internal-404
|
||||
backendRefs:
|
||||
- name: yucca-api
|
||||
port: 3020
|
||||
- matches:
|
||||
- path:
|
||||
type: PathPrefix
|
||||
@@ -22,3 +38,11 @@ spec:
|
||||
- backendRefs:
|
||||
- name: yucca-web
|
||||
port: 5173
|
||||
---
|
||||
apiVersion: gateway.envoyproxy.io/v1alpha1
|
||||
kind: HTTPRouteFilter
|
||||
metadata:
|
||||
name: internal-404
|
||||
spec:
|
||||
directResponse:
|
||||
statusCode: 404
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
apiVersion: helm.toolkit.fluxcd.io/v2
|
||||
kind: HelmRelease
|
||||
metadata:
|
||||
name: futo-backups-bot
|
||||
namespace: yucca
|
||||
spec:
|
||||
interval: 1h
|
||||
chart:
|
||||
spec:
|
||||
chart: charts/apps/futo-backups-bot
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: yucca
|
||||
namespace: flux-system
|
||||
install:
|
||||
remediation:
|
||||
retries: 3
|
||||
upgrade:
|
||||
cleanupOnFail: true
|
||||
remediation:
|
||||
retries: 3
|
||||
values:
|
||||
# DEV-ONLY relaxation: Tilt live_update syncs source into /app inside the
|
||||
# running container, which needs a writable rootfs (the chart default is
|
||||
# readOnlyRootFilesystem: true).
|
||||
containerSecurityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: false
|
||||
capabilities:
|
||||
drop: [ALL]
|
||||
image:
|
||||
repository: ghcr.io/immich-app/yucca/futo-backups-bot
|
||||
tag: 0.0.1
|
||||
@@ -0,0 +1,4 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- ./helmrelease.yaml
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: futo-backups-bot
|
||||
namespace: flux-system
|
||||
spec:
|
||||
targetNamespace: yucca
|
||||
commonMetadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: futo-backups-bot
|
||||
path: ./kubernetes/apps/dev/local/yucca/futo-backups-bot/app
|
||||
prune: true
|
||||
wait: true
|
||||
interval: 1h
|
||||
retryInterval: 2m
|
||||
timeout: 5m
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: yucca
|
||||
dependsOn:
|
||||
- name: yucca-api
|
||||
@@ -13,6 +13,7 @@ resources:
|
||||
- ./victoria-logs/ks.yaml
|
||||
- ./michael/ks.yaml
|
||||
- ./metrics-worker/ks.yaml
|
||||
- ./futo-backups-bot/ks.yaml
|
||||
- ./yucca-api/ks.yaml
|
||||
- ./yucca-admin-api/ks.yaml
|
||||
- ./web/ks.yaml
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# yaml-language-server: $schema=https://k8s-schemas.home-operations.com/kustomize.toolkit.fluxcd.io/kustomization_v1.json
|
||||
---
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: futo-backups-bot
|
||||
namespace: flux-system
|
||||
spec:
|
||||
# No storage/DB of its own — everything goes through yucca-api's internal
|
||||
# endpoints, so the API should exist before the bot starts polling it.
|
||||
dependsOn:
|
||||
- name: yucca-api
|
||||
healthChecks:
|
||||
- apiVersion: helm.toolkit.fluxcd.io/v2
|
||||
kind: HelmRelease
|
||||
name: futo-backups-bot
|
||||
namespace: yucca
|
||||
interval: 1h
|
||||
retryInterval: 2m
|
||||
timeout: 10m
|
||||
path: ./kubernetes/apps/base/futo-backups-bot
|
||||
prune: true
|
||||
wait: true
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: ${MANIFEST_SOURCE:=flux-system}
|
||||
namespace: flux-system
|
||||
targetNamespace: yucca
|
||||
@@ -51,6 +51,11 @@ spec:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: web
|
||||
# futo-backups-bot → /api/internal/discord/* (shared-secret guarded;
|
||||
# the gateway 404-shadows /api/internal so pod-to-pod is the only way in).
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: futo-backups-bot
|
||||
ports:
|
||||
- { port: 3020, protocol: TCP }
|
||||
---
|
||||
|
||||
@@ -20,3 +20,4 @@ resources:
|
||||
- ../../apps/meta.yaml
|
||||
- ../../apps/michael.yaml
|
||||
- ../../apps/yucca-metrics-worker.yaml
|
||||
- ../../apps/futo-backups-bot.yaml
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# futo-backups-bot Docker image
|
||||
# Built on/for Alpine Linux
|
||||
#
|
||||
# mise is used as a command runner only
|
||||
# node.js & pnpm pinned in image =(
|
||||
# (node.js 26 will remove yarn v1 fixing corepack install)
|
||||
#
|
||||
# References:
|
||||
# ===========
|
||||
# https://docs.nestjs.com/deployment
|
||||
# https://mise.jdx.dev/mise-cookbook/docker.html
|
||||
# https://github.com/nodejs/docker-node/blob/main/docs/BestPractices.md
|
||||
# https://pnpm.io/cli/deploy
|
||||
|
||||
ARG ALPINE_VERSION=3.23
|
||||
# Pinned alpine runtime base. NOTE: this digest is alpine:3.23's, so it must NOT
|
||||
# be appended to the node/golang base tags (which reuse ${ALPINE_VERSION}).
|
||||
ARG ALPINE_IMAGE=alpine:3.23@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40
|
||||
|
||||
# Manifest stage: strips everything except package.jsons + workspace files so
|
||||
# the pnpm-install layer below is cached on lockfile/manifest changes only.
|
||||
FROM ${ALPINE_IMAGE} AS manifests
|
||||
WORKDIR /src
|
||||
COPY . ./
|
||||
RUN find . -type d -name node_modules -prune -exec rm -rf {} + && \
|
||||
find . -type f \
|
||||
! -name 'package.json' \
|
||||
! -name 'pnpm-lock.yaml' \
|
||||
! -name 'pnpm-workspace.yaml' \
|
||||
! -name '.npmrc' \
|
||||
-delete && \
|
||||
find . -type d -empty -delete
|
||||
|
||||
FROM node:25-alpine${ALPINE_VERSION} AS dev
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache bash && npm install -g pnpm@10.28.1
|
||||
ENV NODE_ENV="development"
|
||||
|
||||
# 1. Install deps — cached unless lockfile or any package.json changes
|
||||
COPY --from=manifests /src ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# 2. Copy sources, build workspace libs futo-backups-bot imports at runtime
|
||||
COPY . ./
|
||||
RUN pnpm --filter @common/server build
|
||||
|
||||
EXPOSE 3050
|
||||
CMD ["pnpm", "--filter", "futo-backups-bot", "start:dev"]
|
||||
|
||||
FROM node:25-alpine${ALPINE_VERSION} AS builder
|
||||
WORKDIR /build-stage
|
||||
COPY . ./
|
||||
|
||||
RUN apk add --no-cache curl bash
|
||||
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
ENV MISE_DATA_DIR="/mise"
|
||||
ENV MISE_CONFIG_DIR="/mise"
|
||||
ENV MISE_CACHE_DIR="/mise/cache"
|
||||
ENV MISE_INSTALL_PATH="/usr/local/bin/mise"
|
||||
ENV MISE_TASK_RUN_AUTO_INSTALL=false
|
||||
ENV PATH="/mise/shims:$PATH"
|
||||
ENV NODE_ENV="production"
|
||||
|
||||
RUN npm install -g pnpm@10.28.1
|
||||
RUN curl https://mise.run | sh
|
||||
RUN mise trust
|
||||
RUN pnpm i --frozen-lockfile
|
||||
RUN mise futo-backups-bot:build
|
||||
RUN pnpm --filter futo-backups-bot deploy /deploy --prod --legacy
|
||||
|
||||
FROM ${ALPINE_IMAGE}
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
RUN apk add --no-cache libstdc++ dumb-init \
|
||||
&& addgroup -g 1000 node && adduser -u 1000 -G node -s /bin/sh -D node \
|
||||
&& chown node:node ./
|
||||
COPY --from=builder /usr/local/bin/node /usr/local/bin/
|
||||
COPY --from=builder /usr/local/bin/docker-entrypoint.sh /usr/local/bin/
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
USER node
|
||||
|
||||
COPY --from=builder /deploy ./
|
||||
|
||||
ENV NODE_ENV="production"
|
||||
EXPOSE 3050
|
||||
CMD ["dumb-init", "node", "dist/main"]
|
||||
@@ -17,13 +17,10 @@ const schema = z.object({
|
||||
|
||||
TICKET_RETENTION_DAYS: z.coerce.number().default(14),
|
||||
|
||||
TRANSCRIPT_S3_ENDPOINT: z
|
||||
.url()
|
||||
.transform((url) => new URL(url))
|
||||
.optional(),
|
||||
TRANSCRIPT_S3_BUCKET: z.string().optional(),
|
||||
TRANSCRIPT_S3_ACCESS_KEY_ID: z.string().optional(),
|
||||
TRANSCRIPT_S3_SECRET_ACCESS_KEY: z.string().optional(),
|
||||
TRANSCRIPT_S3_ENDPOINT: z.string().default(''),
|
||||
TRANSCRIPT_S3_BUCKET: z.string().default(''),
|
||||
TRANSCRIPT_S3_ACCESS_KEY_ID: z.string().default(''),
|
||||
TRANSCRIPT_S3_SECRET_ACCESS_KEY: z.string().default(''),
|
||||
TRANSCRIPT_S3_REGION: z.string().default('rgw'),
|
||||
});
|
||||
|
||||
|
||||
@@ -17,24 +17,44 @@ export class TranscriptStorageRepository {
|
||||
|
||||
async put(key: string, body: string): Promise<void> {
|
||||
this.aws ??= new AwsClient({
|
||||
accessKeyId: env.TRANSCRIPT_S3_ACCESS_KEY_ID!,
|
||||
secretAccessKey: env.TRANSCRIPT_S3_SECRET_ACCESS_KEY!,
|
||||
accessKeyId: env.TRANSCRIPT_S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: env.TRANSCRIPT_S3_SECRET_ACCESS_KEY,
|
||||
service: 's3',
|
||||
region: env.TRANSCRIPT_S3_REGION,
|
||||
});
|
||||
|
||||
const url = new URL(env.TRANSCRIPT_S3_ENDPOINT!.href);
|
||||
url.pathname = `/${env.TRANSCRIPT_S3_BUCKET}/${key}`;
|
||||
|
||||
const response = await this.aws.fetch(url.toString(), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||
body,
|
||||
});
|
||||
let response = await this.putObject(key, body);
|
||||
if (response.status === 404) {
|
||||
await this.createBucket();
|
||||
response = await this.putObject(key, body);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`transcript upload of ${key} failed: ${response.status} ${response.statusText} — ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private putObject(key: string, body: string): Promise<Response> {
|
||||
return this.aws!.fetch(this.url(`/${key}`), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
private async createBucket(): Promise<void> {
|
||||
const response = await this.aws!.fetch(this.url(''), { method: 'PUT' });
|
||||
if (!response.ok && response.status !== 409) {
|
||||
throw new Error(
|
||||
`transcript bucket creation failed: ${response.status} ${response.statusText} — ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private url(suffix: string): string {
|
||||
const url = new URL(env.TRANSCRIPT_S3_ENDPOINT);
|
||||
url.pathname = `/${env.TRANSCRIPT_S3_BUCKET}${suffix}`;
|
||||
return url.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,11 @@
|
||||
"path": "packages/yucca-metrics-worker/package.json",
|
||||
"jsonpath": "$.version"
|
||||
},
|
||||
{
|
||||
"type": "json",
|
||||
"path": "packages/futo-backups-bot/package.json",
|
||||
"jsonpath": "$.version"
|
||||
},
|
||||
{
|
||||
"type": "generic",
|
||||
"path": "packages/michael/internal/version/version.go"
|
||||
@@ -86,6 +91,10 @@
|
||||
"type": "generic",
|
||||
"path": "charts/apps/yucca-metrics-worker/Chart.yaml"
|
||||
},
|
||||
{
|
||||
"type": "generic",
|
||||
"path": "charts/apps/futo-backups-bot/Chart.yaml"
|
||||
},
|
||||
{
|
||||
"type": "generic",
|
||||
"path": "kubernetes/clusters/prod/htz-fsn1/flux-release.yaml"
|
||||
|
||||
@@ -82,3 +82,15 @@ export TF_VAR_netbird_talos_setup_key="op://yucca_tf_staging/NETBIRD_YUCCA_STAGI
|
||||
# OP_TF_YUCCA_STAGING_ENV (read) / OP_TF_YUCCA_STAGING_ENV_WRITE (apply); see
|
||||
# .github/workflows/infra.yml. This file is shared by all stacks; run dev/prod
|
||||
# with their own env file (OP_ENV_FILE=tf/.env.<env> tf/op-run.sh ...).
|
||||
|
||||
# futo-backups-bot (Discord support, docs/discord-support.md). The internal-API
|
||||
# secret is TF-generated (secrets.tf); the transcripts keys are TF-minted by the
|
||||
# ceph stack (rgw-users.tf svc-yucca-transcripts) — uncomment after its first
|
||||
# apply. The token item comes from core-infra-tf's yucca-manual-secrets. Until
|
||||
# then the Secret lands with empty values and the bot idles.
|
||||
# export TF_VAR_yucca_discord_bot_token="op://yucca_tf_staging/YUCCA_DISCORD_BOT_TOKEN/password"
|
||||
# export TF_VAR_yucca_discord_guild_id="op://yucca_tf_staging/YUCCA_DISCORD_SUPPORT_IDS/discord/guild_id"
|
||||
# export TF_VAR_yucca_discord_staff_role_id="op://yucca_tf_staging/YUCCA_DISCORD_SUPPORT_IDS/discord/staff_role_id"
|
||||
# export TF_VAR_yucca_discord_support_channel_id="op://yucca_tf_staging/YUCCA_DISCORD_SUPPORT_IDS/discord/support_channel_id"
|
||||
# export TF_VAR_sietch_transcripts_access_key="op://yucca_tf_staging/SIETCH_CEPH_S3_SVC_YUCCA_TRANSCRIPTS_ACCESS_KEY/password"
|
||||
# export TF_VAR_sietch_transcripts_secret_key="op://yucca_tf_staging/SIETCH_CEPH_S3_SVC_YUCCA_TRANSCRIPTS_SECRET_KEY/password"
|
||||
|
||||
@@ -84,3 +84,15 @@ export TF_VAR_spice_rgw_tls_cert="op://yucca_tf_prod/SPICE_CEPH_RGW_TLS_CERT/pas
|
||||
|
||||
# vmagent/logs remote-write bearer for o11y prod vmauth.
|
||||
export TF_VAR_vmauth_remote_write_password="op://shared_tf_prod/O11Y_VICTORIAMETRICS_VMAUTH_PASSWORD/password"
|
||||
|
||||
# futo-backups-bot (Discord support, docs/discord-support.md). The internal-API
|
||||
# secret is TF-generated (secrets.tf); the transcripts keys are TF-minted by the
|
||||
# ceph stack (rgw-users.tf svc-yucca-transcripts) — uncomment after its first
|
||||
# apply. The token item comes from core-infra-tf's yucca-manual-secrets. Until
|
||||
# then the Secret lands with empty values and the bot idles.
|
||||
# export TF_VAR_yucca_discord_bot_token="op://yucca_tf_prod/YUCCA_DISCORD_BOT_TOKEN/password"
|
||||
# export TF_VAR_yucca_discord_guild_id="op://yucca_tf_prod/YUCCA_DISCORD_SUPPORT_IDS/discord/guild_id"
|
||||
# export TF_VAR_yucca_discord_staff_role_id="op://yucca_tf_prod/YUCCA_DISCORD_SUPPORT_IDS/discord/staff_role_id"
|
||||
# export TF_VAR_yucca_discord_support_channel_id="op://yucca_tf_prod/YUCCA_DISCORD_SUPPORT_IDS/discord/support_channel_id"
|
||||
# export TF_VAR_spice_transcripts_access_key="op://yucca_tf_prod/SPICE_CEPH_S3_SVC_YUCCA_TRANSCRIPTS_ACCESS_KEY/password"
|
||||
# export TF_VAR_spice_transcripts_secret_key="op://yucca_tf_prod/SPICE_CEPH_S3_SVC_YUCCA_TRANSCRIPTS_SECRET_KEY/password"
|
||||
|
||||
@@ -47,6 +47,13 @@ locals {
|
||||
access_role = "db_backup_access"
|
||||
secret_role = "db_backup_secret"
|
||||
}
|
||||
transcripts = {
|
||||
user_id = "svc-yucca-transcripts"
|
||||
display_name = "yucca/futo-backups-bot ticket transcripts"
|
||||
max_buckets = 1
|
||||
access_role = "transcripts_access"
|
||||
secret_role = "transcripts_secret"
|
||||
}
|
||||
}
|
||||
|
||||
rgw_cluster_users = merge([
|
||||
@@ -59,10 +66,11 @@ locals {
|
||||
}
|
||||
]...)
|
||||
|
||||
# Every key this file reads from 1P: the provider's own admin credential plus
|
||||
# each service user's key pair. Data lookups (not the onepassword_item
|
||||
# resources in secrets.tf) so out-of-band roles (s3_restic_*) and TF-managed
|
||||
# roles resolve uniformly.
|
||||
# Every key this file needs: the provider's own admin credential plus each
|
||||
# service user's key pair. Stack-minted roles resolve from the
|
||||
# onepassword_item resources in secrets.tf (resource attr, so a brand-new
|
||||
# service user and its keys bootstrap in a single apply); out-of-band roles
|
||||
# (s3_restic_*) resolve via data lookups.
|
||||
rgw_key_roles = concat(
|
||||
["tf_admin_access", "tf_admin_secret"],
|
||||
flatten([for u in local.rgw_users : [u.access_role, u.secret_role]]),
|
||||
@@ -74,6 +82,7 @@ locals {
|
||||
vault = coalesce(c.vault, "Yucca")
|
||||
title = module.cluster[cname].secrets[role]
|
||||
}
|
||||
if !contains(keys(local.ceph_secret_items), "${cname}.${role}")
|
||||
}
|
||||
]...)
|
||||
}
|
||||
@@ -85,13 +94,20 @@ data "onepassword_item" "rgw_key" {
|
||||
title = each.value.title
|
||||
}
|
||||
|
||||
locals {
|
||||
rgw_keys = merge(
|
||||
{ for k, d in data.onepassword_item.rgw_key : k => d.password },
|
||||
{ for k, r in onepassword_item.ceph_password : k => r.password },
|
||||
)
|
||||
}
|
||||
|
||||
provider "radosgw" {
|
||||
alias = "cluster"
|
||||
for_each = local.rgw_managed_clusters
|
||||
|
||||
endpoint = "https://s3.${each.value.domain}"
|
||||
access_key = data.onepassword_item.rgw_key["${each.key}.tf_admin_access"].password
|
||||
secret_key = data.onepassword_item.rgw_key["${each.key}.tf_admin_secret"].password
|
||||
access_key = local.rgw_keys["${each.key}.tf_admin_access"]
|
||||
secret_key = local.rgw_keys["${each.key}.tf_admin_secret"]
|
||||
# The RGW frontend serves the self-signed cert from rgw.yml Step 11.6; there
|
||||
# is no CA to pin (the dashboard's RGW client skips verification the same way).
|
||||
tls_insecure_skip_verify = true
|
||||
@@ -111,8 +127,8 @@ resource "radosgw_iam_access_key" "svc" {
|
||||
provider = radosgw.cluster[each.value.cluster]
|
||||
|
||||
user_id = radosgw_iam_user.svc[each.key].user_id
|
||||
access_key = data.onepassword_item.rgw_key["${each.value.cluster}.${each.value.access_role}"].password
|
||||
secret_key = data.onepassword_item.rgw_key["${each.value.cluster}.${each.value.secret_role}"].password
|
||||
access_key = local.rgw_keys["${each.value.cluster}.${each.value.access_role}"]
|
||||
secret_key = local.rgw_keys["${each.value.cluster}.${each.value.secret_role}"]
|
||||
}
|
||||
|
||||
# Read-only admin caps for the usage/bucket/user stats scrape.
|
||||
|
||||
@@ -32,15 +32,17 @@ locals {
|
||||
|
||||
# Per-role generated-password length. ops is the break-glass account typed by
|
||||
# hand at the KVM/console, so keep it short; dashboard/grafana are web logins
|
||||
# (paste-friendly) and stay long. The metrics-worker, db-backup and tf-admin
|
||||
# RGW keys follow the AWS/RGW key shape (20-char access id, 40-char secret).
|
||||
# Roles not listed use the default.
|
||||
# (paste-friendly) and stay long. The metrics-worker, db-backup, transcripts
|
||||
# and tf-admin RGW keys follow the AWS/RGW key shape (20-char access id,
|
||||
# 40-char secret). Roles not listed use the default.
|
||||
ceph_password_length = {
|
||||
ops = 16
|
||||
metrics_worker_access = 20
|
||||
metrics_worker_secret = 40
|
||||
db_backup_access = 20
|
||||
db_backup_secret = 40
|
||||
transcripts_access = 20
|
||||
transcripts_secret = 40
|
||||
tf_admin_access = 20
|
||||
tf_admin_secret = 40
|
||||
}
|
||||
|
||||
@@ -64,6 +64,29 @@ provider "registry.opentofu.org/hashicorp/kubernetes" {
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.opentofu.org/hashicorp/random" {
|
||||
version = "3.9.0"
|
||||
constraints = "~> 3.6"
|
||||
hashes = [
|
||||
"h1:U8KXqGCoNI9/guYbTvzgdtVk3fRthoG0UXwm1JoEpIs=",
|
||||
"zh:03f1114cc20b8913523735ab76e0f0a2b16ce13c92923a53304bf85f07fc0dbc",
|
||||
"zh:105b678ee72322a3067f105d7e05e940f6143238f377f6e87ff4ec909246ac2a",
|
||||
"zh:55f3bbf13ea18cbace61a706566a80f25f33fe2b1780b6f3d7b582af2a05b6d2",
|
||||
"zh:63adf996db48f082f7a6351eb485e219cd88795fc71e6ec60a837263ab0d2cb1",
|
||||
"zh:7e99550738a4e3cc68b8a467714b0d69371025fe95e3326d5323d026d55653e9",
|
||||
"zh:8342b54af3a18a37e075eeae61be57f4de2ba71b35d95c5075d402dd2c1f289d",
|
||||
"zh:83ee18e32ac9dd5fc91298554b7c4cfa4c3a1db50f4c797945637cc93c0844ae",
|
||||
"zh:993ecc0adbf6bd535a59fbc9b735d8c33950e6f6eb5e621d750da9b71d65d80a",
|
||||
"zh:ad722bc59d4edbf1415e827fc007c0efe6e0e9462d5568bae20b34be1058a261",
|
||||
"zh:ae9448e1f87b2f9a6c5197a0e9862162ec6b137cb3a3835e11522995d8939e7c",
|
||||
"zh:bc9cdd3aac784f759125c6627f6f6416e8726a1c184eb9cf3e55b9edbc94c627",
|
||||
"zh:c8e35b89572ba1c40a9b20022e033a3395fb8d42e7604d50c900f193ba10382e",
|
||||
"zh:e2deaa8a9975ef81d9f62baed12c41286918b0a10908e0e031f13f69a3b730a1",
|
||||
"zh:ee39707557210a0ab1098aa357d2cdfe502e5a312d0dbdffb09d08facc4d3fc5",
|
||||
"zh:f81afe4eb63e8aa9e0ea71be6c990f0dc69cb360e7191c0742a991f4a5081b64",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.opentofu.org/hashicorp/tls" {
|
||||
version = "4.3.0"
|
||||
constraints = "~> 4.0"
|
||||
|
||||
@@ -140,6 +140,7 @@ resource "kubernetes_secret_v1" "yucca_api" {
|
||||
OIDC_CLIENT_ID = var.yucca_oidc_client_id
|
||||
OIDC_CLIENT_SECRET = var.yucca_oidc_client_secret
|
||||
OIDC_DEVICE_CLIENT_ID = var.yucca_oidc_device_client_id
|
||||
INTERNAL_SECRET = random_password.yucca_internal_secret.result
|
||||
}
|
||||
|
||||
lifecycle {
|
||||
@@ -220,6 +221,43 @@ resource "kubernetes_secret_v1" "yucca_metrics_rgw" {
|
||||
}
|
||||
}
|
||||
|
||||
# Shared secret for yucca-api's /api/internal/* endpoints; yucca-api verifies,
|
||||
# futo-backups-bot presents. TF-generated (no 1P item to mint), mirrored into
|
||||
# 1P like the JWT keypairs so it survives state loss.
|
||||
resource "random_password" "yucca_internal_secret" {
|
||||
length = 48
|
||||
special = false
|
||||
}
|
||||
|
||||
resource "onepassword_item" "yucca_internal_secret" {
|
||||
vault = data.onepassword_vault.prod.uuid
|
||||
title = "YUCCA_INTERNAL_API_SECRET"
|
||||
category = "password"
|
||||
|
||||
password = random_password.yucca_internal_secret.result
|
||||
}
|
||||
|
||||
# futo-backups-bot: Discord gateway token + the shared internal-API secret
|
||||
# + spice RGW keys for ticket transcripts. Deliberately no precondition: the
|
||||
# token and S3 keys default empty so this Secret can land before their 1P
|
||||
# items exist — the bot idles without a token and skips the archive sweep
|
||||
# without S3 keys.
|
||||
resource "kubernetes_secret_v1" "futo_backups_bot" {
|
||||
metadata {
|
||||
name = "futo-backups-bot"
|
||||
namespace = kubernetes_namespace_v1.yucca.metadata[0].name
|
||||
}
|
||||
data = {
|
||||
DISCORD_BOT_TOKEN = var.yucca_discord_bot_token
|
||||
INTERNAL_SECRET = random_password.yucca_internal_secret.result
|
||||
DISCORD_GUILD_ID = var.yucca_discord_guild_id
|
||||
DISCORD_STAFF_ROLE_ID = var.yucca_discord_staff_role_id
|
||||
DISCORD_SUPPORT_CHANNEL_ID = var.yucca_discord_support_channel_id
|
||||
TRANSCRIPT_S3_ACCESS_KEY_ID = var.spice_transcripts_access_key
|
||||
TRANSCRIPT_S3_SECRET_ACCESS_KEY = var.spice_transcripts_secret_key
|
||||
}
|
||||
}
|
||||
|
||||
# yucca-database backups: the spice RGW svc-yucca-db-backup S3 keys for the
|
||||
# CNPG Barman Cloud plugin, plus the RGW's self-signed cert as the CA bundle
|
||||
# (barman cannot skip TLS verification). The cert comes from the DR item that
|
||||
|
||||
@@ -246,3 +246,44 @@ variable "vmauth_remote_write_password" {
|
||||
sensitive = true
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "yucca_discord_bot_token" {
|
||||
description = "Discord bot token for futo-backups-bot (FUTOBackupsBot). Empty = the bot boots idle; ref stays commented in tf/.env.prod until minted."
|
||||
type = string
|
||||
sensitive = true
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "spice_transcripts_access_key" {
|
||||
description = "Spice RGW (S3) access key for futo-backups-bot ticket transcripts (svc-yucca-transcripts, TF-minted SPICE_CEPH_S3_SVC_YUCCA_TRANSCRIPTS_ACCESS_KEY). Empty = the archive sweep skips."
|
||||
type = string
|
||||
sensitive = true
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "spice_transcripts_secret_key" {
|
||||
description = "Spice RGW (S3) secret key for futo-backups-bot ticket transcripts (svc-yucca-transcripts)."
|
||||
type = string
|
||||
sensitive = true
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "yucca_discord_guild_id" {
|
||||
description = "Discord server id for futo-backups-bot (YUCCA_DISCORD_SUPPORT_IDS, written by core-infra-tf's discord apply). Empty = the bot idles."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "yucca_discord_staff_role_id" {
|
||||
description = "Staff (Yucca) role id for futo-backups-bot (YUCCA_DISCORD_SUPPORT_IDS)."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "yucca_discord_support_channel_id" {
|
||||
description = "#support channel id for futo-backups-bot's pinned button (YUCCA_DISCORD_SUPPORT_IDS)."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -27,5 +27,10 @@ terraform {
|
||||
source = "hashicorp/tls"
|
||||
version = "~> 4.0"
|
||||
}
|
||||
# Internal-API shared secret generation (secrets.tf).
|
||||
random = {
|
||||
source = "hashicorp/random"
|
||||
version = "~> 3.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,13 @@ locals {
|
||||
access_role = "db_backup_access"
|
||||
secret_role = "db_backup_secret"
|
||||
}
|
||||
transcripts = {
|
||||
user_id = "svc-yucca-transcripts"
|
||||
display_name = "yucca/futo-backups-bot ticket transcripts"
|
||||
max_buckets = 1
|
||||
access_role = "transcripts_access"
|
||||
secret_role = "transcripts_secret"
|
||||
}
|
||||
}
|
||||
|
||||
rgw_cluster_users = merge([
|
||||
@@ -59,10 +66,11 @@ locals {
|
||||
}
|
||||
]...)
|
||||
|
||||
# Every key this file reads from 1P: the provider's own admin credential plus
|
||||
# each service user's key pair. Data lookups (not the onepassword_item
|
||||
# resources in secrets.tf) so out-of-band roles (s3_restic_*) and TF-managed
|
||||
# roles resolve uniformly.
|
||||
# Every key this file needs: the provider's own admin credential plus each
|
||||
# service user's key pair. Stack-minted roles resolve from the
|
||||
# onepassword_item resources in secrets.tf (resource attr, so a brand-new
|
||||
# service user and its keys bootstrap in a single apply); out-of-band roles
|
||||
# (s3_restic_*) resolve via data lookups.
|
||||
rgw_key_roles = concat(
|
||||
["tf_admin_access", "tf_admin_secret"],
|
||||
flatten([for u in local.rgw_users : [u.access_role, u.secret_role]]),
|
||||
@@ -74,6 +82,7 @@ locals {
|
||||
vault = coalesce(c.vault, "Yucca")
|
||||
title = module.cluster[cname].secrets[role]
|
||||
}
|
||||
if !contains(keys(local.ceph_secret_items), "${cname}.${role}")
|
||||
}
|
||||
]...)
|
||||
}
|
||||
@@ -85,13 +94,20 @@ data "onepassword_item" "rgw_key" {
|
||||
title = each.value.title
|
||||
}
|
||||
|
||||
locals {
|
||||
rgw_keys = merge(
|
||||
{ for k, d in data.onepassword_item.rgw_key : k => d.password },
|
||||
{ for k, r in onepassword_item.ceph_password : k => r.password },
|
||||
)
|
||||
}
|
||||
|
||||
provider "radosgw" {
|
||||
alias = "cluster"
|
||||
for_each = local.rgw_managed_clusters
|
||||
|
||||
endpoint = "https://s3.${each.value.domain}"
|
||||
access_key = data.onepassword_item.rgw_key["${each.key}.tf_admin_access"].password
|
||||
secret_key = data.onepassword_item.rgw_key["${each.key}.tf_admin_secret"].password
|
||||
access_key = local.rgw_keys["${each.key}.tf_admin_access"]
|
||||
secret_key = local.rgw_keys["${each.key}.tf_admin_secret"]
|
||||
# The RGW frontend serves the self-signed cert from rgw.yml Step 11.6; there
|
||||
# is no CA to pin (the dashboard's RGW client skips verification the same way).
|
||||
tls_insecure_skip_verify = true
|
||||
@@ -111,8 +127,8 @@ resource "radosgw_iam_access_key" "svc" {
|
||||
provider = radosgw.cluster[each.value.cluster]
|
||||
|
||||
user_id = radosgw_iam_user.svc[each.key].user_id
|
||||
access_key = data.onepassword_item.rgw_key["${each.value.cluster}.${each.value.access_role}"].password
|
||||
secret_key = data.onepassword_item.rgw_key["${each.value.cluster}.${each.value.secret_role}"].password
|
||||
access_key = local.rgw_keys["${each.value.cluster}.${each.value.access_role}"]
|
||||
secret_key = local.rgw_keys["${each.value.cluster}.${each.value.secret_role}"]
|
||||
}
|
||||
|
||||
# Read-only admin caps for the usage/bucket/user stats scrape.
|
||||
|
||||
@@ -28,15 +28,17 @@ locals {
|
||||
|
||||
# Per-role generated-password length. ops is the break-glass account typed by
|
||||
# hand at the KVM/console, so keep it short; dashboard/grafana are web logins
|
||||
# (paste-friendly) and stay long. The metrics-worker, db-backup and tf-admin
|
||||
# RGW keys follow the AWS/RGW key shape (20-char access id, 40-char secret).
|
||||
# Roles not listed use the default.
|
||||
# (paste-friendly) and stay long. The metrics-worker, db-backup, transcripts
|
||||
# and tf-admin RGW keys follow the AWS/RGW key shape (20-char access id,
|
||||
# 40-char secret). Roles not listed use the default.
|
||||
ceph_password_length = {
|
||||
ops = 16
|
||||
metrics_worker_access = 20
|
||||
metrics_worker_secret = 40
|
||||
db_backup_access = 20
|
||||
db_backup_secret = 40
|
||||
transcripts_access = 20
|
||||
transcripts_secret = 40
|
||||
tf_admin_access = 20
|
||||
tf_admin_secret = 40
|
||||
}
|
||||
|
||||
@@ -153,6 +153,7 @@ resource "kubernetes_secret_v1" "yucca_api" {
|
||||
OIDC_CLIENT_ID = var.yucca_oidc_client_id
|
||||
OIDC_CLIENT_SECRET = var.yucca_oidc_client_secret
|
||||
OIDC_DEVICE_CLIENT_ID = var.yucca_oidc_device_client_id
|
||||
INTERNAL_SECRET = random_password.yucca_internal_secret[0].result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +216,46 @@ resource "kubernetes_secret_v1" "yucca_metrics_rgw" {
|
||||
}
|
||||
}
|
||||
|
||||
# Shared secret for yucca-api's /api/internal/* endpoints; yucca-api verifies,
|
||||
# futo-backups-bot presents. TF-generated (no 1P item to mint), mirrored into
|
||||
# 1P like the JWT keypairs so it survives state loss.
|
||||
resource "random_password" "yucca_internal_secret" {
|
||||
count = local.provision_secrets ? 1 : 0
|
||||
length = 48
|
||||
special = false
|
||||
}
|
||||
|
||||
resource "onepassword_item" "yucca_internal_secret" {
|
||||
count = local.provision_secrets ? 1 : 0
|
||||
vault = data.onepassword_vault.staging[0].uuid
|
||||
title = "YUCCA_INTERNAL_API_SECRET"
|
||||
category = "password"
|
||||
|
||||
password = random_password.yucca_internal_secret[0].result
|
||||
}
|
||||
|
||||
# futo-backups-bot: Discord gateway token + the shared internal-API secret
|
||||
# + sietch RGW keys for ticket transcripts. Deliberately no precondition: the
|
||||
# token and S3 keys default empty so this Secret can land before their 1P
|
||||
# items exist — the bot idles without a token and skips the archive sweep
|
||||
# without S3 keys.
|
||||
resource "kubernetes_secret_v1" "futo_backups_bot" {
|
||||
count = local.provision_secrets ? 1 : 0
|
||||
metadata {
|
||||
name = "futo-backups-bot"
|
||||
namespace = kubernetes_namespace_v1.yucca[0].metadata[0].name
|
||||
}
|
||||
data = {
|
||||
DISCORD_BOT_TOKEN = var.yucca_discord_bot_token
|
||||
INTERNAL_SECRET = random_password.yucca_internal_secret[0].result
|
||||
DISCORD_GUILD_ID = var.yucca_discord_guild_id
|
||||
DISCORD_STAFF_ROLE_ID = var.yucca_discord_staff_role_id
|
||||
DISCORD_SUPPORT_CHANNEL_ID = var.yucca_discord_support_channel_id
|
||||
TRANSCRIPT_S3_ACCESS_KEY_ID = var.sietch_transcripts_access_key
|
||||
TRANSCRIPT_S3_SECRET_ACCESS_KEY = var.sietch_transcripts_secret_key
|
||||
}
|
||||
}
|
||||
|
||||
# yucca-database backups: the sietch RGW svc-yucca-db-backup S3 keys for the
|
||||
# CNPG Barman Cloud plugin, plus the RGW's self-signed cert as the CA bundle
|
||||
# (barman cannot skip TLS verification). The cert comes from the DR item that
|
||||
|
||||
@@ -218,3 +218,44 @@ variable "clusters" {
|
||||
config_patches = optional(list(string), [])
|
||||
}))
|
||||
}
|
||||
|
||||
variable "yucca_discord_bot_token" {
|
||||
description = "Discord bot token for futo-backups-bot (FUTOBackupsBot). Empty = the bot boots idle; ref stays commented in tf/.env until minted."
|
||||
type = string
|
||||
sensitive = true
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "sietch_transcripts_access_key" {
|
||||
description = "Sietch RGW (S3) access key for futo-backups-bot ticket transcripts (svc-yucca-transcripts, TF-minted SIETCH_CEPH_S3_SVC_YUCCA_TRANSCRIPTS_ACCESS_KEY). Empty = the archive sweep skips."
|
||||
type = string
|
||||
sensitive = true
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "sietch_transcripts_secret_key" {
|
||||
description = "Sietch RGW (S3) secret key for futo-backups-bot ticket transcripts (svc-yucca-transcripts)."
|
||||
type = string
|
||||
sensitive = true
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "yucca_discord_guild_id" {
|
||||
description = "Discord server id for futo-backups-bot (YUCCA_DISCORD_SUPPORT_IDS, written by core-infra-tf's discord apply). Empty = the bot idles."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "yucca_discord_staff_role_id" {
|
||||
description = "Staff (Yucca) role id for futo-backups-bot (YUCCA_DISCORD_SUPPORT_IDS)."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "yucca_discord_support_channel_id" {
|
||||
description = "#support channel id for futo-backups-bot's pinned button (YUCCA_DISCORD_SUPPORT_IDS)."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -70,13 +70,15 @@ locals {
|
||||
secret_prefix = "${upper(var.cluster_name)}_CEPH"
|
||||
|
||||
secrets = merge({
|
||||
ops = "${local.secret_prefix}_OPS_PASSWORD"
|
||||
dashboard = "${local.secret_prefix}_DASHBOARD_PASSWORD"
|
||||
grafana = "${local.secret_prefix}_GRAFANA_PASSWORD"
|
||||
s3_restic_access = "${local.secret_prefix}_S3_SVC_YUCCA_RESTIC_ACCESS_KEY"
|
||||
s3_restic_secret = "${local.secret_prefix}_S3_SVC_YUCCA_RESTIC_SECRET_KEY"
|
||||
db_backup_access = "${local.secret_prefix}_S3_SVC_YUCCA_DB_BACKUP_ACCESS_KEY"
|
||||
db_backup_secret = "${local.secret_prefix}_S3_SVC_YUCCA_DB_BACKUP_SECRET_KEY"
|
||||
ops = "${local.secret_prefix}_OPS_PASSWORD"
|
||||
dashboard = "${local.secret_prefix}_DASHBOARD_PASSWORD"
|
||||
grafana = "${local.secret_prefix}_GRAFANA_PASSWORD"
|
||||
s3_restic_access = "${local.secret_prefix}_S3_SVC_YUCCA_RESTIC_ACCESS_KEY"
|
||||
s3_restic_secret = "${local.secret_prefix}_S3_SVC_YUCCA_RESTIC_SECRET_KEY"
|
||||
db_backup_access = "${local.secret_prefix}_S3_SVC_YUCCA_DB_BACKUP_ACCESS_KEY"
|
||||
db_backup_secret = "${local.secret_prefix}_S3_SVC_YUCCA_DB_BACKUP_SECRET_KEY"
|
||||
transcripts_access = "${local.secret_prefix}_S3_SVC_YUCCA_TRANSCRIPTS_ACCESS_KEY"
|
||||
transcripts_secret = "${local.secret_prefix}_S3_SVC_YUCCA_TRANSCRIPTS_SECRET_KEY"
|
||||
# RGW admin (read-only) keys for the metrics worker. Titled <CLUSTER>_
|
||||
# METRICS_WORKER_* (no _CEPH infix) to match the metrics-worker consumer's
|
||||
# 1P contract, which is named by cluster, not by the ceph subsystem.
|
||||
|
||||
Reference in New Issue
Block a user