feat: Abschreiben im Cluster mit Whisper-Image und Voice-CronJob (T7 L3 Task 13)
ApiCore-Whisper: transcribe.py liest das Manifest im RAM-Arbeitsordner, dekodiert mit festem Demuxer je Endung, misst die Länge vor dem Abschreiben (TOO_LONG > 240 s, DECODE_FAILED bei Länge 0), sperrt das Netz vor dem Modell-Laden und schreibt nur Zähler auf stdout. Image: python:3.12-slim per Digest, Nutzer 1000, alle Pakete mit Hash (faster-whisper 1.2.1, av 18.1.0 wegen Inkompatibilität mit av 19), Modell faster-whisper-small auf fester Revision mit SHA-256 je Datei. CI-Workflow nur bei Änderungen unter ApiCore-Whisper/, Actions per SHA. deploy/k8s/components/voice: CronJob alle 2 Minuten mit fetch, whisper, ingest (gleiche UID, getrennte /tmp-Volumes, damit der Decoder dem Container mit den Secrets nichts unterschieben kann; whisper ohne Secrets und Umgebungsvariablen außer WHISPER_CPU_THREADS), Overlay mit Platzhalter-Digest, ConfigMap-Werte für Sprache, Policy-Tests (55). Die Anbindung des Anthropic-Schlüssels bleibt offen (Ruling 9). Refs #27 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
e614ded967
commit
c53810b8a6
16 files changed
+1448
-5
No files matched your search
@@ -0,0 +1,165 @@
|
||||
# Abschreiben im Cluster (Spec T7 6.3 und 11.1): alle 2 Minuten ein Pod mit
|
||||
# drei Containern, die nacheinander laufen:
|
||||
# fetch (initContainer) claimt Aufnahmen, entschluesselt, legt Ton nach /work/in
|
||||
# whisper (initContainer) liest /work/in, schreibt /work/out; ohne Netz,
|
||||
# ohne Schluessel, ohne Datenbank-Zugang
|
||||
# ingest (Hauptcontainer) liest /work/out, verschluesselt, speichert, loescht Ton
|
||||
#
|
||||
# Der gemeinsame CronJob-Patch aus components/backend-roles trifft alle
|
||||
# CronJobs mit app=apicore-job und wuerde diesem Pod einen Container `job`
|
||||
# (Backend-Image, alle Secrets) hinzufuegen. Darum traegt das CronJob-Objekt
|
||||
# app=apicore-voice-job; nur das Pod-Template traegt app=apicore-job (damit die
|
||||
# NetworkPolicy apicore-jobs gilt: Egress nur Postgres und DNS). Haertung,
|
||||
# Zeitzone und Forbid stehen deshalb hier vollstaendig.
|
||||
#
|
||||
# fetch, whisper und ingest laufen mit derselben uid 1000 und fsGroup 1000:
|
||||
# /work/in und /work/out sind 0700, die Dateien 0600.
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: apicore-job-voice-transcribe
|
||||
labels:
|
||||
app: apicore-voice-job
|
||||
apicore.io/job: voice-transcribe
|
||||
spec:
|
||||
schedule: "*/2 * * * *"
|
||||
timeZone: Europe/Berlin
|
||||
concurrencyPolicy: Forbid
|
||||
startingDeadlineSeconds: 120
|
||||
successfulJobsHistoryLimit: 3
|
||||
failedJobsHistoryLimit: 3
|
||||
jobTemplate:
|
||||
spec:
|
||||
backoffLimit: 0
|
||||
activeDeadlineSeconds: 1500
|
||||
ttlSecondsAfterFinished: 86400
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: apicore-job
|
||||
apicore.io/job: voice-transcribe
|
||||
db-client: "true"
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
automountServiceAccountToken: false
|
||||
enableServiceLinks: false
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
fsGroup: 1000
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
volumes:
|
||||
# Entschluesselter Ton und Abschriften liegen nur im RAM, nie auf
|
||||
# einer Platte. Das Backend rechnet mit diesen 32Mi (work-dir.ts).
|
||||
- name: work
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
sizeLimit: 32Mi
|
||||
# /tmp von fetch und ingest (HOME=/tmp). Whisper bekommt ein eigenes
|
||||
# Volume: ein gemeinsames /tmp liesse einen kompromittierten
|
||||
# Decoder Code unter $HOME/.node_modules ablegen, den ingest mit
|
||||
# allen Secrets laedt.
|
||||
- name: tmp
|
||||
emptyDir:
|
||||
sizeLimit: 64Mi
|
||||
- name: whisper-tmp
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
sizeLimit: 16Mi
|
||||
initContainers:
|
||||
- name: fetch
|
||||
image: git.encry.de/nick.rosenloecher/apicore-backend
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: ["node", "dist/cli.js", "voice-transcribe", "--phase=fetch"]
|
||||
env:
|
||||
- name: APICORE_ROLE
|
||||
value: job
|
||||
- name: HOME
|
||||
value: /tmp
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: apicore-backend-config
|
||||
- secretRef:
|
||||
name: apicore-backend-secret
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: work
|
||||
mountPath: /work
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 192Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
# Kein envFrom, kein secretKeyRef, kein Service-Account-Token. Nur
|
||||
# die Thread-Zahl kommt von aussen; alles andere steht im Image.
|
||||
- name: whisper
|
||||
image: git.encry.de/nick.rosenloecher/apicore-whisper
|
||||
imagePullPolicy: IfNotPresent
|
||||
env:
|
||||
- name: WHISPER_CPU_THREADS
|
||||
value: "2"
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
volumeMounts:
|
||||
# Der ganze Mount, Schreibrecht fuer /work/out.
|
||||
- name: work
|
||||
mountPath: /work
|
||||
- name: whisper-tmp
|
||||
mountPath: /tmp
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 1280Mi
|
||||
limits:
|
||||
cpu: 2000m
|
||||
memory: 1280Mi
|
||||
containers:
|
||||
- name: ingest
|
||||
image: git.encry.de/nick.rosenloecher/apicore-backend
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: ["node", "dist/cli.js", "voice-transcribe", "--phase=ingest"]
|
||||
env:
|
||||
- name: APICORE_ROLE
|
||||
value: job
|
||||
- name: HOME
|
||||
value: /tmp
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: apicore-backend-config
|
||||
- secretRef:
|
||||
name: apicore-backend-secret
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: work
|
||||
mountPath: /work
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 192Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
@@ -0,0 +1,11 @@
|
||||
# Sprachnotizen (Spec T7 11.1 und 11.3): der CronJob, der Ton im Cluster
|
||||
# abschreibt. Rollout: nach backend-roles (gleiche Backend-Rollen, gleiches
|
||||
# Secret) und erst, wenn VOICE_ENCRYPTION_KEY in apicore-backend-secret steht.
|
||||
#
|
||||
# Zurueckgestellt (Controller-Entscheid 9): patch-worker-voice.yaml mit
|
||||
# ANTHROPIC_API_KEY (secretKeyRef apicore-anthropic, optional) im Worker. Es
|
||||
# wird erst angelegt, wenn die Claude-Auswertung freigegeben ist.
|
||||
apiVersion: kustomize.config.k8s.io/v1alpha1
|
||||
kind: Component
|
||||
resources:
|
||||
- cronjob-voice-transcribe.yaml
|
||||
@@ -28,3 +28,10 @@ data:
|
||||
# bei BotFather noch nicht angelegt ist (Runbook); dann den Namen eintragen.
|
||||
# Das Token liegt im Secret apicore-telegram (nur Worker).
|
||||
TELEGRAM_BOT_USERNAME: ""
|
||||
# Sprachnotizen (T7). VOICE_RECORDING_ENABLED blendet die Aufnahme in der App
|
||||
# aus ("false") oder ein ("true"); VOICE_MODEL und VOICE_DAILY_EXTRACTIONS
|
||||
# gelten fuer die Claude-Auswertung (zurueckgestellt). Den Schluessel
|
||||
# ANTHROPIC_API_KEY gibt es hier nie: er gehoert ins Secret apicore-anthropic.
|
||||
VOICE_RECORDING_ENABLED: "false"
|
||||
VOICE_MODEL: "claude-sonnet-5-5"
|
||||
VOICE_DAILY_EXTRACTIONS: "60"
|
||||
@@ -43,10 +43,15 @@ components:
|
||||
- ../../components/pod-hardening # Schritt 3
|
||||
- ../../components/backend-roles # Schritt 3
|
||||
- ../../components/api-hpa # Schritt 5
|
||||
- ../../components/voice # Sprachnotizen (T7), erst nach VOICE_ENCRYPTION_KEY im Secret
|
||||
images:
|
||||
- name: git.encry.de/nick.rosenloecher/apicore-backend
|
||||
digest: sha256:0000000000000000000000000000000000000000000000000000000000000000
|
||||
- name: git.encry.de/nick.rosenloecher/apicore-frontend
|
||||
digest: sha256:0000000000000000000000000000000000000000000000000000000000000000
|
||||
# Abschreib-Image (.gitea/workflows/whisper-image.yml, Tag sha-<commit>).
|
||||
# Platzhalter bis zum ersten CI-Build; dann den Digest eintragen.
|
||||
- name: git.encry.de/nick.rosenloecher/apicore-whisper
|
||||
digest: sha256:0000000000000000000000000000000000000000000000000000000000000000
|
||||
- name: postgres
|
||||
digest: sha256:0000000000000000000000000000000000000000000000000000000000000000
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
|
||||
const APP_PODS = ['apicore-backend', 'apicore-worker', 'apicore-job', 'apicore-frontend'];
|
||||
const DB_CLIENT_APPS = ['apicore-backend', 'apicore-worker', 'apicore-job', 'apicore-db-backup'];
|
||||
const KNOWN_JOBS = ['daily-evaluation', 'task-templates', 'cleanup', 'app-backup', 'rekey'];
|
||||
const KNOWN_JOBS = ['daily-evaluation', 'task-templates', 'cleanup', 'app-backup', 'rekey', 'voice-transcribe'];
|
||||
const PRIVATE_V4 = ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '100.64.0.0/10', '169.254.0.0/16', '127.0.0.0/8'];
|
||||
const PRIVATE_V6 = ['fc00::/7', 'fe80::/10', '::1/128'];
|
||||
const MIGRATE = ['node', 'node_modules/prisma/build/index.js', 'migrate', 'deploy'];
|
||||
@@ -255,7 +255,9 @@ test('all app pods: automountServiceAccountToken false, readOnlyRootFilesystem t
|
||||
test('cronjobs: Forbid, Europe/Berlin, command node dist/cli.js <known job>', () => {
|
||||
const cronJobs = target.docs.filter((d) => d.kind === 'CronJob');
|
||||
for (const cj of cronJobs) assert.equal(cj.spec.concurrencyPolicy, 'Forbid', `${cj.metadata.name}`);
|
||||
const appJobs = cronJobs.filter((cj) => podTemplateOf(cj).metadata?.labels?.app === 'apicore-job');
|
||||
// The shared job patch (backend-roles) selects CronJob objects labelled app=apicore-job;
|
||||
// the voice CronJob is deliberately not one of them (see the voice tests below).
|
||||
const appJobs = cronJobs.filter((cj) => cj.metadata.labels?.app === 'apicore-job');
|
||||
assert.deepEqual(
|
||||
appJobs.map((cj) => cj.metadata.name).sort(),
|
||||
Object.keys(CRON_JOBS).map((name) => `apicore-job-${name}`).sort(),
|
||||
@@ -541,3 +543,190 @@ for (const [name, model] of [...stages, ['target', target]]) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- voice transcription CronJob (spec T7 6.3, 9.2, 11.1; plan task 13) ------
|
||||
|
||||
const VOICE_CRONJOB = 'apicore-job-voice-transcribe';
|
||||
const WHISPER_IMAGE = 'git.encry.de/nick.rosenloecher/apicore-whisper';
|
||||
const ANTHROPIC_KEY = 'ANTHROPIC_API_KEY';
|
||||
const ANTHROPIC_SECRET = 'apicore-anthropic';
|
||||
|
||||
function voiceCronJob(model) {
|
||||
const found = model.docs.filter((d) => d.kind === 'CronJob' && d.metadata.name === VOICE_CRONJOB);
|
||||
assert.equal(found.length, 1, `expected exactly one CronJob ${VOICE_CRONJOB}`);
|
||||
return found[0];
|
||||
}
|
||||
const voicePodOf = (model) => {
|
||||
const pod = model.byOwner(`CronJob/${VOICE_CRONJOB}`);
|
||||
assert.ok(pod, 'voice pod template missing');
|
||||
return pod;
|
||||
};
|
||||
const voiceContainer = (pod, name) => {
|
||||
const found = [...pod.initContainers, ...pod.containers].filter((c) => c.name === name);
|
||||
assert.equal(found.length, 1, `expected exactly one container ${name}`);
|
||||
return found[0];
|
||||
};
|
||||
|
||||
test('whisper container has no env from secrets, no secretKeyRef and no service account token', () => {
|
||||
const pod = voicePodOf(target);
|
||||
const whisper = voiceContainer(pod, 'whisper');
|
||||
assert.equal(pod.spec.automountServiceAccountToken, false, 'service account token mounted');
|
||||
assert.equal(whisper.envFrom, undefined, 'whisper takes envFrom');
|
||||
assert.deepEqual(whisper.env, [{ name: 'WHISPER_CPU_THREADS', value: '2' }], 'whisper env is only the thread count');
|
||||
assert.ok(!(whisper.env ?? []).some((e) => e.valueFrom), 'whisper env uses valueFrom');
|
||||
assert.equal(whisper.image.split('@')[0], WHISPER_IMAGE);
|
||||
// Every volume of the pod is an emptyDir: no secret, configMap or projected token.
|
||||
for (const volume of pod.spec.volumes ?? []) {
|
||||
assert.ok(volume.emptyDir, `volume ${volume.name} is not an emptyDir`);
|
||||
}
|
||||
assert.deepEqual(
|
||||
(whisper.volumeMounts ?? []).map((m) => m.mountPath).sort(),
|
||||
['/tmp', '/work'],
|
||||
'whisper mounts more than the work volume and /tmp',
|
||||
);
|
||||
assert.equal(whisper.securityContext.runAsNonRoot, true);
|
||||
assert.equal(whisper.securityContext.runAsUser, 1000);
|
||||
assert.equal(whisper.securityContext.readOnlyRootFilesystem, true);
|
||||
assert.equal(whisper.securityContext.allowPrivilegeEscalation, false);
|
||||
assert.deepEqual(whisper.securityContext.capabilities.drop, ['ALL']);
|
||||
assert.deepEqual(whisper.securityContext.seccompProfile, { type: 'RuntimeDefault' });
|
||||
for (const secret of ['apicore-backend-secret', ANTHROPIC_SECRET, TELEGRAM_SECRET]) {
|
||||
assert.deepEqual(
|
||||
secretReferences(pod, secret).filter((r) => r.container === 'whisper'),
|
||||
[],
|
||||
`whisper references secret ${secret}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the voice pod matches the jobs network policy (egress only postgres and dns)', () => {
|
||||
const pod = voicePodOf(target);
|
||||
const postgres = postgresOf(target);
|
||||
assert.equal(pod.labels.app, 'apicore-job', 'the pod template must carry app=apicore-job for apicore-jobs');
|
||||
assert.equal(pod.labels['apicore.io/job'], 'voice-transcribe');
|
||||
assert.equal(pod.labels['db-client'], 'true');
|
||||
assert.ok(target.canConnect(pod, postgres, 5432), 'voice pod cannot reach postgres');
|
||||
assert.ok(!target.canConnect(pod, postgres, 22), 'voice pod reaches postgres on another port');
|
||||
for (const protocol of ['UDP', 'TCP']) {
|
||||
assert.ok(target.canConnect(pod, KUBE_DNS, 53, protocol), `voice pod cannot resolve names (${protocol})`);
|
||||
}
|
||||
for (const address of ['1.1.1.1', '10.43.0.1', '169.254.169.254']) {
|
||||
for (const port of [80, 443]) {
|
||||
assert.ok(!target.canConnect(pod, ip(address), port), `voice pod reaches ${address}:${port}`);
|
||||
}
|
||||
}
|
||||
for (const other of target.pods.filter((p) => p !== postgres && p !== pod)) {
|
||||
assert.ok(!target.canConnect(pod, other, 3001) && !target.canConnect(pod, other, 3000), `voice pod reaches ${other.owner}`);
|
||||
}
|
||||
for (const policy of target.policiesFor(pod, 'Ingress')) {
|
||||
assert.equal((policy.spec.ingress ?? []).length, 0, `${policy.metadata.name} opens ingress to the voice pod`);
|
||||
}
|
||||
});
|
||||
|
||||
test('work volume is a memory emptyDir of at most 32Mi, mounted in all three containers', () => {
|
||||
const pod = voicePodOf(target);
|
||||
const work = (pod.spec.volumes ?? []).find((v) => v.name === 'work');
|
||||
assert.ok(work?.emptyDir, 'work volume missing or not an emptyDir');
|
||||
assert.equal(work.emptyDir.medium, 'Memory', 'work volume is not memory-backed');
|
||||
assert.equal(work.emptyDir.sizeLimit, '32Mi');
|
||||
for (const name of ['fetch', 'whisper', 'ingest']) {
|
||||
const mount = (voiceContainer(pod, name).volumeMounts ?? []).find((m) => m.name === 'work');
|
||||
assert.equal(mount?.mountPath, '/work', `${name} does not mount work on /work`);
|
||||
assert.ok(!mount.readOnly, `${name} mounts work read-only`);
|
||||
}
|
||||
// fetch, whisper and ingest share the files (0700/0600): one uid, one fsGroup.
|
||||
assert.equal(pod.spec.securityContext.runAsUser, 1000);
|
||||
assert.equal(pod.spec.securityContext.fsGroup, 1000);
|
||||
for (const name of ['fetch', 'whisper', 'ingest']) {
|
||||
const own = voiceContainer(pod, name).securityContext.runAsUser;
|
||||
assert.ok(own === undefined || own === 1000, `${name} runs as another uid`);
|
||||
}
|
||||
});
|
||||
|
||||
test('voice resources match spec 11.1', () => {
|
||||
const pod = voicePodOf(target);
|
||||
assert.deepEqual(voiceContainer(pod, 'fetch').resources, res('50m', '500m', '192Mi', '512Mi'));
|
||||
assert.deepEqual(voiceContainer(pod, 'ingest').resources, res('50m', '500m', '192Mi', '512Mi'));
|
||||
assert.deepEqual(voiceContainer(pod, 'whisper').resources, res('50m', '2000m', '1280Mi', '1280Mi'));
|
||||
});
|
||||
|
||||
test('the voice CronJob is not matched by the shared job patch and has exactly fetch, whisper, ingest', () => {
|
||||
const cronJob = voiceCronJob(target);
|
||||
assert.notEqual(cronJob.metadata.labels?.app, 'apicore-job', 'the shared job patch would add a container `job`');
|
||||
const pod = voicePodOf(target);
|
||||
assert.deepEqual(pod.initContainers.map((c) => c.name), ['fetch', 'whisper']);
|
||||
assert.deepEqual(pod.containers.map((c) => c.name), ['ingest']);
|
||||
assert.ok(![...pod.initContainers, ...pod.containers].some((c) => c.name === 'job'), 'a container `job` was patched in');
|
||||
// Everything the shared patch would have set is spelled out in the manifest.
|
||||
const { spec } = cronJob;
|
||||
assert.equal(spec.schedule, '*/2 * * * *');
|
||||
assert.equal(spec.timeZone, 'Europe/Berlin');
|
||||
assert.equal(spec.concurrencyPolicy, 'Forbid');
|
||||
assert.equal(spec.startingDeadlineSeconds, 120);
|
||||
assert.equal(spec.suspend ?? false, false);
|
||||
assert.equal(spec.jobTemplate.spec.activeDeadlineSeconds, 1500);
|
||||
assert.equal(spec.jobTemplate.spec.backoffLimit, 0);
|
||||
assert.equal(spec.jobTemplate.spec.ttlSecondsAfterFinished, 86400);
|
||||
assert.equal(pod.spec.restartPolicy, 'Never');
|
||||
assert.equal(pod.spec.securityContext.runAsNonRoot, true);
|
||||
assert.deepEqual(pod.spec.securityContext.seccompProfile, { type: 'RuntimeDefault' });
|
||||
// fetch and ingest are the backend image in the job role; both phases are exact commands.
|
||||
const fetch = voiceContainer(pod, 'fetch');
|
||||
const ingest = voiceContainer(pod, 'ingest');
|
||||
assert.deepEqual(fetch.command, ['node', 'dist/cli.js', 'voice-transcribe', '--phase=fetch']);
|
||||
assert.deepEqual(ingest.command, ['node', 'dist/cli.js', 'voice-transcribe', '--phase=ingest']);
|
||||
assert.ok(KNOWN_JOBS.includes(fetch.command[2]));
|
||||
for (const c of [fetch, ingest]) {
|
||||
assert.equal(envOf(c).APICORE_ROLE, 'job', `${c.name}: APICORE_ROLE`);
|
||||
assert.equal(c.image.split('@')[0], 'git.encry.de/nick.rosenloecher/apicore-backend');
|
||||
assert.ok((c.envFrom ?? []).some((f) => f.secretRef?.name === 'apicore-backend-secret'), `${c.name}: backend secret`);
|
||||
}
|
||||
});
|
||||
|
||||
// The Claude extraction is on hold (no worker patch yet). Until it lands, no pod
|
||||
// at all may carry the key; once the worker patch exists the worker is exempt.
|
||||
for (const [label, model] of [['target', target], ...stages]) {
|
||||
test(`${label}: ${ANTHROPIC_KEY} appears only in the worker (no other pod references it)`, () => {
|
||||
for (const pod of model.pods) {
|
||||
if (pod.labels.app === 'apicore-worker') continue;
|
||||
for (const c of [...pod.containers, ...pod.initContainers]) {
|
||||
assert.ok(!(c.env ?? []).some((e) => e.name === ANTHROPIC_KEY), `${pod.owner}/${c.name} defines ${ANTHROPIC_KEY}`);
|
||||
}
|
||||
assert.deepEqual(secretReferences(pod, ANTHROPIC_SECRET), [], `${pod.owner} must not use secret ${ANTHROPIC_SECRET}`);
|
||||
}
|
||||
for (const doc of model.docs.filter((d) => d.kind === 'ConfigMap')) {
|
||||
assert.ok(!(ANTHROPIC_KEY in (doc.data ?? {})), `${doc.metadata.name} holds ${ANTHROPIC_KEY}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('the voice settings sit in the shared ConfigMap with the values the backend accepts', () => {
|
||||
const configMap = target.docs.find((d) => d.kind === 'ConfigMap' && d.metadata.name === 'apicore-backend-config');
|
||||
assert.equal(configMap.data.VOICE_RECORDING_ENABLED, 'false');
|
||||
assert.equal(configMap.data.VOICE_MODEL, 'claude-sonnet-5-5');
|
||||
assert.equal(configMap.data.VOICE_DAILY_EXTRACTIONS, '60');
|
||||
});
|
||||
|
||||
test('apicore-whisper is pinned by digest in the overlay', () => {
|
||||
const overlay = readFileSync(new URL('../overlays/encry/kustomization.yaml', import.meta.url), 'utf8');
|
||||
assert.match(overlay, new RegExp(`name: ${WHISPER_IMAGE}\\s+digest: sha256:[0-9a-f]{64}`), 'no digest entry for apicore-whisper');
|
||||
const whisper = voiceContainer(voicePodOf(target), 'whisper');
|
||||
assert.match(whisper.image, new RegExp(`^${WHISPER_IMAGE}@sha256:[0-9a-f]{64}$`));
|
||||
assert.doesNotMatch(whisper.image, /:latest/);
|
||||
});
|
||||
|
||||
test('no volume except work is mounted by both whisper and fetch/ingest; /tmp volumes are size-limited', () => {
|
||||
const pod = voicePodOf(target);
|
||||
const mounted = (name) => new Set((voiceContainer(pod, name).volumeMounts ?? []).map((m) => m.name));
|
||||
const whisper = mounted('whisper');
|
||||
for (const other of ['fetch', 'ingest']) {
|
||||
const shared = [...mounted(other)].filter((v) => whisper.has(v));
|
||||
assert.deepEqual(shared, ['work'], `whisper and ${other} share volumes other than work: ${shared}`);
|
||||
}
|
||||
const tmpOf = (name) => voiceContainer(pod, name).volumeMounts.find((m) => m.mountPath === '/tmp').name;
|
||||
assert.notEqual(tmpOf('whisper'), tmpOf('ingest'), 'whisper and ingest share /tmp');
|
||||
for (const volume of pod.spec.volumes) {
|
||||
assert.ok(volume.emptyDir?.sizeLimit, `volume ${volume.name} has no sizeLimit`);
|
||||
}
|
||||
assert.equal(pod.spec.volumes.find((v) => v.name === tmpOf('ingest')).emptyDir.sizeLimit, '64Mi');
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
// Renders every rollout stage of overlays/encry (Runbook steps 1, 2, 3 and 5)
|
||||
// Renders every rollout stage of overlays/encry (Runbook steps 1, 2, 3, 5 and the voice component)
|
||||
// so manifest-policy.test.mjs can check that each intermediate state keeps
|
||||
// the application reachable. A stage is the committed overlay with only the
|
||||
// components released up to that step; the overlay in the repo is not
|
||||
@@ -15,12 +15,13 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const K8S_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const OVERLAY_FILE = join('overlays', 'encry', 'kustomization.yaml');
|
||||
const ROLLOUT_ORDER = ['network-policies', 'pod-hardening', 'backend-roles', 'api-hpa'];
|
||||
const ROLLOUT_ORDER = ['network-policies', 'pod-hardening', 'backend-roles', 'api-hpa', 'voice'];
|
||||
const STAGES = {
|
||||
'step1-ist-stand': [],
|
||||
'step2-network-policies': ROLLOUT_ORDER.slice(0, 1),
|
||||
'step3-roles-and-hardening': ROLLOUT_ORDER.slice(0, 3),
|
||||
'step5-hpa': ROLLOUT_ORDER,
|
||||
'step5-hpa': ROLLOUT_ORDER.slice(0, 4),
|
||||
'step6-voice': ROLLOUT_ORDER,
|
||||
};
|
||||
const COMPONENT_LINE = /^\s+-\s+\.\.\/\.\.\/components\/([\w-]+)/;
|
||||
|
||||
|
||||
Reference in new issue
Block a user