feat: T4 wave 10 – bottling quality, phenology and honey chain UI, Telegram link/receive and ops
- Bottling quality: water content above the legal limit (strict >) answers 409 CONFIRMATION_REQUIRED bound to the measured value; prefill for jar size, best-before (shelfLifeMonths, month-end clamping) and water content; impossible dates give 400; water-content checks in src/common/quality. - Frontend phenology: "Entwicklung melden" sheet, IndexedDB v4 queue, season header on the apiary page, confirmed revoke for all apiaries. - Frontend honey chain: "Vorschlag aus …" hints with explicit take-over, prefill status note, movement ledger with withdraw/correct dialogs. - Telegram: link tokens (hashed, single use), private-chat receiver with lease and offset CAS, bounded poison-update skip, Retry-After, pending deliveries skipped on disconnect; system API for deliveries and retry; worker-only secret wiring and manifest policy tests. - ARCHITECTURE.md §7, §9, §10, §11.11–11.13, Annahmen 51–55. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
16cc557462
commit
fab7f7e12f
91 files changed
+8463
-104
No files matched your search
@@ -45,6 +45,9 @@ data:
|
||||
# WEATHER_USER_AGENT_CONTACT: ""
|
||||
# Telegram-Erinnerungen (optional). Name des Bots ohne @ (5-32 Zeichen:
|
||||
# Buchstaben, Ziffern, Unterstrich) fuer den Deep Link; API und Worker lesen
|
||||
# ihn aus dieser ConfigMap. Das Token steht NICHT hier, siehe Kommentar in
|
||||
# secret.example.yaml (Secret apicore-telegram, nur Worker).
|
||||
# TELEGRAM_BOT_USERNAME: "<bot-username>"
|
||||
# ihn aus dieser ConfigMap. Leer = Telegram ist in der App nicht
|
||||
# eingerichtet (Platzhalter; Runbook: Bot bei BotFather anlegen, ohne
|
||||
# Gruppen (/setjoingroups) und ohne Webhook, je Umgebung ein eigener Bot,
|
||||
# dann hier den Namen eintragen). Das Token steht NICHT hier, siehe Kommentar
|
||||
# in secret.example.yaml (Secret apicore-telegram, nur Worker).
|
||||
TELEGRAM_BOT_USERNAME: ""
|
||||
@@ -43,7 +43,8 @@ stringData:
|
||||
|
||||
# Telegram-Bot-Token (optional) - NICHT in diesem Secret, sondern im eigenen
|
||||
# Secret apicore-telegram, das nur der Worker einbindet (Secret mit
|
||||
# TELEGRAM_BOT_TOKEN, envFrom/secretKeyRef mit optional: true). Der API-Pod
|
||||
# TELEGRAM_BOT_TOKEN; einzelne secretKeyRef-Variable (optional: true), kein
|
||||
# envFrom, kein Volume). Der API-Pod
|
||||
# darf es nicht einbinden. Der Inhaber legt den Bot bei BotFather und das
|
||||
# Secret selbst an; ohne Token laeuft Telegram nicht, alles andere schon.
|
||||
#
|
||||
|
||||
@@ -79,6 +79,12 @@ spec:
|
||||
value: "3002"
|
||||
- name: HOME
|
||||
value: /tmp
|
||||
- name: TELEGRAM_BOT_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: apicore-telegram
|
||||
key: TELEGRAM_BOT_TOKEN
|
||||
optional: true
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: apicore-backend-config
|
||||
|
||||
@@ -22,6 +22,7 @@ data:
|
||||
WEATHER_HISTORY_SOURCE: "open-meteo"
|
||||
OPEN_METEO_FORECAST_URL: "https://api.open-meteo.com/v1/forecast"
|
||||
OPEN_METEO_ARCHIVE_URL: "https://archive-api.open-meteo.com/v1/archive"
|
||||
# Telegram: Name des Bots (ohne @) fuer den Deep Link, sobald der Bot bei
|
||||
# BotFather angelegt ist. Das Token liegt im Secret apicore-telegram (nur Worker).
|
||||
# TELEGRAM_BOT_USERNAME: "<bot-username>"
|
||||
# Telegram: Name des Bots (ohne @) fuer den Deep Link. Leer, solange der Bot
|
||||
# bei BotFather noch nicht angelegt ist (Runbook); dann den Namen eintragen.
|
||||
# Das Token liegt im Secret apicore-telegram (nur Worker).
|
||||
TELEGRAM_BOT_USERNAME: ""
|
||||
@@ -315,6 +315,90 @@ test('resources match the spec table; HPA api 1–2 at 70 % CPU', () => {
|
||||
assert.equal(api.doc.spec.replicas, undefined, 'the HPA owns the api replica count');
|
||||
});
|
||||
|
||||
// The Telegram bot token belongs to the worker alone (Spec T4c): one optional
|
||||
// secretKeyRef on its main container, no envFrom, no volume, no other pod.
|
||||
const TELEGRAM_SECRET = 'apicore-telegram';
|
||||
const TELEGRAM_TOKEN_KEY = 'TELEGRAM_BOT_TOKEN';
|
||||
|
||||
/** Every place a pod template can pull `secretName` in: env, envFrom, volumes. */
|
||||
function secretReferences(pod, secretName) {
|
||||
const refs = [];
|
||||
const containers = [
|
||||
...pod.containers.map((c) => ({ c, init: false })),
|
||||
...pod.initContainers.map((c) => ({ c, init: true })),
|
||||
];
|
||||
for (const { c, init } of containers) {
|
||||
for (const e of c.env ?? []) {
|
||||
const ref = e.valueFrom?.secretKeyRef;
|
||||
if (ref?.name === secretName) refs.push({ kind: 'env', container: c.name, init, name: e.name, ref });
|
||||
}
|
||||
for (const from of c.envFrom ?? []) {
|
||||
if (from.secretRef?.name === secretName) refs.push({ kind: 'envFrom', container: c.name, init });
|
||||
}
|
||||
}
|
||||
for (const v of pod.spec.volumes ?? []) {
|
||||
const projected = (v.projected?.sources ?? []).some((src) => src.secret?.name === secretName);
|
||||
if (v.secret?.secretName === secretName || projected) refs.push({ kind: 'volume', volume: v.name });
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
for (const [label, model] of [['target', target], ...stages]) {
|
||||
test(`${label}: only the worker references the apicore-telegram secret, and only optionally`, () => {
|
||||
const workers = model.byApp('apicore-worker');
|
||||
if (label === 'target' || workers.length > 0) {
|
||||
assert.equal(workers.length, 1, `${label}: expected exactly one apicore-worker pod`);
|
||||
}
|
||||
for (const pod of model.pods) {
|
||||
const refs = secretReferences(pod, TELEGRAM_SECRET);
|
||||
if (pod.labels.app !== 'apicore-worker') {
|
||||
assert.deepEqual(refs, [], `${pod.owner} must not use secret ${TELEGRAM_SECRET}`);
|
||||
continue;
|
||||
}
|
||||
assert.equal(refs.length, 1, `${pod.owner}: expected exactly one reference to ${TELEGRAM_SECRET}`);
|
||||
const [only] = refs;
|
||||
assert.equal(only.kind, 'env', `${pod.owner}: the secret must come in as a single env variable`);
|
||||
assert.equal(only.init, false, `${pod.owner}: init containers must not see the bot token`);
|
||||
assert.equal(only.container, mainContainer(pod).name, `${pod.owner}: only the main container takes the token`);
|
||||
assert.equal(only.name, TELEGRAM_TOKEN_KEY);
|
||||
assert.deepEqual(only.ref, { name: TELEGRAM_SECRET, key: TELEGRAM_TOKEN_KEY, optional: true });
|
||||
const tokenEnvs = [...pod.containers, ...pod.initContainers].flatMap((c) =>
|
||||
(c.env ?? []).filter((e) => e.name === TELEGRAM_TOKEN_KEY),
|
||||
);
|
||||
assert.equal(tokenEnvs.length, 1, `${pod.owner}: expected exactly one ${TELEGRAM_TOKEN_KEY} entry`);
|
||||
assert.equal(tokenEnvs[0].value, undefined, `${pod.owner}: ${TELEGRAM_TOKEN_KEY} must not be a literal`);
|
||||
}
|
||||
// No other pod may carry the token by value or under that variable name.
|
||||
for (const pod of model.pods.filter((p) => p.labels.app !== 'apicore-worker')) {
|
||||
for (const c of [...pod.containers, ...pod.initContainers]) {
|
||||
assert.ok(
|
||||
!(c.env ?? []).some((e) => e.name === TELEGRAM_TOKEN_KEY),
|
||||
`${pod.owner}/${c.name} defines ${TELEGRAM_TOKEN_KEY}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test(`${label}: the bot name sits in the shared ConfigMap, the token never does`, () => {
|
||||
const configMaps = model.docs.filter((d) => d.kind === 'ConfigMap' && d.metadata.name === 'apicore-backend-config');
|
||||
assert.equal(configMaps.length, 1, 'expected one apicore-backend-config');
|
||||
const data = configMaps[0].data ?? {};
|
||||
assert.ok('TELEGRAM_BOT_USERNAME' in data, 'TELEGRAM_BOT_USERNAME missing in apicore-backend-config');
|
||||
assert.ok(!(TELEGRAM_TOKEN_KEY in data), `${TELEGRAM_TOKEN_KEY} must not be in a ConfigMap`);
|
||||
for (const doc of model.docs.filter((d) => d.kind === 'ConfigMap')) {
|
||||
assert.ok(!(TELEGRAM_TOKEN_KEY in (doc.data ?? {})), `${doc.metadata.name} holds ${TELEGRAM_TOKEN_KEY}`);
|
||||
}
|
||||
// Both roles read the name from the one ConfigMap through envFrom.
|
||||
// (The worker only exists from the stage that rolls out backend-roles.)
|
||||
for (const pod of [...model.byApp('apicore-backend'), ...model.byApp('apicore-worker')]) {
|
||||
assert.ok(
|
||||
(mainContainer(pod).envFrom ?? []).some((f) => f.configMapRef?.name === 'apicore-backend-config'),
|
||||
`${pod.owner} does not read apicore-backend-config`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('api and worker run prisma migrate deploy as initContainer; only api seeds', () => {
|
||||
const api = onePod(target, 'apicore-backend');
|
||||
const worker = onePod(target, 'apicore-worker');
|
||||
|
||||
Reference in new issue
Block a user