feat(infra): deploy futo-backups-bot (#545)

This commit is contained in:
Antoine Lecompte
2026-08-25 14:24:51 -04:00
committed by GitHub
parent 474879f399
commit 5ad08e27d7
37 changed files with 744 additions and 83 deletions
+9
View File
@@ -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=
+1
View File
@@ -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:
+2 -2
View File
@@ -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
+31 -1
View File
@@ -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,
+10
View File
@@ -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" . }}
+53
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
+24
View File
@@ -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
+88
View File
@@ -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"]
+4 -7
View File
@@ -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();
}
}
+9
View File
@@ -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"
+12
View File
@@ -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"
+12
View File
@@ -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"
+24 -8
View File
@@ -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.
+5 -3
View File
@@ -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
}
+23
View File
@@ -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"
}
}
}
+24 -8
View File
@@ -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.
+5 -3
View File
@@ -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 = ""
}
+9 -7
View File
@@ -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.