All checks were successful
CD / Build and push images (push) Successful in 3m51s
CI / Lint, typecheck, test (push) Successful in 4m5s
CD / Deploy to Test (push) Successful in 11s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 12s
CI / Auth e2e pack (push) Successful in 5m52s
CI / Import/export fidelity gate (push) Successful in 47s
The operator-level extra beside the admin-configured Nextcloud target (#103), unblocked now that the ONE→BASEL tunnel is stable again. - sidecar: optional mirror step (mirror.ts) driven purely by env — BACKUP_MIRROR_TARGET (rsync-over-ssh), BACKUP_MIRROR_SSH_KEY (private key on the secrets volume, never in image or repo), BACKUP_MIRROR_SSH_PORT. Runs after the prune of every successful run, so --delete aligns the remote retention with the local one (the newest-complete-set guarantee carries over). Only set files travel (db-*.dump, files-*.tar.gz); status files and bundles stay local. Host key pinned via accept-new into .mirror_known_hosts on the backups volume; fixed remote modes (dirs 750, files 640, symbolic --chmod — octal needs rsync ≥ 3, macOS dev machines ship 2.6.9). rsync + openssh-client added to the sidecar image. - status: additive `mirror` block in status.json (outcome, transferred count, lastSuccessAt carried across failures) — shown on the admin backup card; failures alert via a new backupMirrorFailed mail (de+en) while the local run still counts as succeeded. - deploy/backup-basel.md: complete BASEL-side walkthrough — dedicated user dorfteich-backup with a /home/ home and a bash login shell, explicitly avoiding the Debian backup-user (UID 34) pitfalls (nologin shell rejects rsync sessions, /var/backups home), key placement through the api container onto the secrets volume, .env values, on-demand verification. - tests: rsync-arg/stats-parsing units plus an integration suite against the real rsync binary (local target; skips where rsync is absent) — transfer, idempotent re-run (0 files), retention alignment, failure path carrying lastSuccessAt. Verified live against the real BASEL host from a native sidecar run: initial transfer, host-key pinning, retention alignment after a local prune, idempotency, and the failure path (surfaced in status.json while the local run stayed green). BASEL side provisioned per the doc. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
141 lines
4.3 KiB
TypeScript
141 lines
4.3 KiB
TypeScript
import { createTransport } from 'nodemailer';
|
|
|
|
import type { BackupEnv } from '@dorfteich/shared';
|
|
|
|
import deMails from '@dorfteich/shared/i18n/de/mails.json' with { type: 'json' };
|
|
import enMails from '@dorfteich/shared/i18n/en/mails.json' with { type: 'json' };
|
|
|
|
/**
|
|
* The failure alert goes out through nodemailer directly instead of the
|
|
* api's mail outbox — when backups fail, the api may be the broken part
|
|
* (issue #83). Texts live in the shared `mails` i18n namespace (ADR 0012,
|
|
* de + en); the operator picks the language via BACKUP_MAIL_LOCALE. The
|
|
* catalog uses i18next's `{{var}}` syntax, interpolated here without
|
|
* pulling i18next into the sidecar.
|
|
*/
|
|
|
|
export interface FailureMailInput {
|
|
backupId: string;
|
|
error: string;
|
|
lastSuccessAt: string | null;
|
|
}
|
|
|
|
export interface RenderedFailureMail {
|
|
subject: string;
|
|
text: string;
|
|
}
|
|
|
|
const CATALOGS = { de: deMails, en: enMails } as const;
|
|
|
|
/** Both alert mails share one key shape; the namespace picks the texts. */
|
|
type AlertNamespace = 'backupFailed' | 'backupUploadFailed' | 'backupMirrorFailed';
|
|
|
|
function t(
|
|
locale: 'de' | 'en',
|
|
namespace: AlertNamespace,
|
|
key: keyof (typeof CATALOGS)['en']['backupFailed'],
|
|
params: Record<string, string> = {},
|
|
): string {
|
|
let text: string = CATALOGS[locale][namespace][key];
|
|
for (const [name, value] of Object.entries(params)) {
|
|
text = text.replaceAll(`{{${name}}}`, value);
|
|
}
|
|
return text;
|
|
}
|
|
|
|
function renderAlertMail(
|
|
namespace: AlertNamespace,
|
|
input: FailureMailInput,
|
|
locale: 'de' | 'en',
|
|
instanceLabel: string,
|
|
): RenderedFailureMail {
|
|
const lastSuccess = input.lastSuccessAt
|
|
? t(locale, namespace, 'lastSuccess', { finishedAt: input.lastSuccessAt })
|
|
: t(locale, namespace, 'lastSuccessNever');
|
|
return {
|
|
subject: t(locale, namespace, 'subject', {
|
|
instance: instanceLabel,
|
|
backupId: input.backupId,
|
|
}),
|
|
text: [
|
|
t(locale, namespace, 'intro', { instance: instanceLabel }),
|
|
'',
|
|
t(locale, namespace, 'backupId', { backupId: input.backupId }),
|
|
t(locale, namespace, 'error', { error: input.error }),
|
|
lastSuccess,
|
|
'',
|
|
t(locale, namespace, 'hint'),
|
|
].join('\n'),
|
|
};
|
|
}
|
|
|
|
export function renderFailureMail(
|
|
input: FailureMailInput,
|
|
locale: 'de' | 'en',
|
|
instanceLabel: string,
|
|
): RenderedFailureMail {
|
|
return renderAlertMail('backupFailed', input, locale, instanceLabel);
|
|
}
|
|
|
|
/** Alert for a failed Nextcloud upload after a successful local run (#103). */
|
|
export function renderUploadFailureMail(
|
|
input: FailureMailInput,
|
|
locale: 'de' | 'en',
|
|
instanceLabel: string,
|
|
): RenderedFailureMail {
|
|
return renderAlertMail('backupUploadFailed', input, locale, instanceLabel);
|
|
}
|
|
|
|
/** Sends the alert; returns false (after logging upstream) when no relay or
|
|
* recipient is configured — a missing mail must never fail the run. */
|
|
export async function sendFailureMail(env: BackupEnv, input: FailureMailInput): Promise<boolean> {
|
|
return sendAlertMail(env, renderFailureMail(input, env.BACKUP_MAIL_LOCALE, label(env)));
|
|
}
|
|
|
|
/** Same delivery path for the upload alert (issue #103). */
|
|
export async function sendUploadFailureMail(
|
|
env: BackupEnv,
|
|
input: FailureMailInput,
|
|
): Promise<boolean> {
|
|
return sendAlertMail(env, renderUploadFailureMail(input, env.BACKUP_MAIL_LOCALE, label(env)));
|
|
}
|
|
|
|
/** Same delivery path for the rsync-mirror alert (issue #84). */
|
|
export async function sendMirrorFailureMail(
|
|
env: BackupEnv,
|
|
input: FailureMailInput,
|
|
): Promise<boolean> {
|
|
return sendAlertMail(
|
|
env,
|
|
renderAlertMail('backupMirrorFailed', input, env.BACKUP_MAIL_LOCALE, label(env)),
|
|
);
|
|
}
|
|
|
|
function label(env: BackupEnv): string {
|
|
return env.BACKUP_INSTANCE_LABEL || 'Dorfteich';
|
|
}
|
|
|
|
async function sendAlertMail(env: BackupEnv, mail: RenderedFailureMail): Promise<boolean> {
|
|
if (!env.BACKUP_MAIL_TO) return false;
|
|
const transport = createTransport({
|
|
host: env.SMTP_HOST,
|
|
port: env.SMTP_PORT,
|
|
secure: env.SMTP_SECURE,
|
|
auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASS } : undefined,
|
|
connectionTimeout: 10_000,
|
|
greetingTimeout: 10_000,
|
|
socketTimeout: 20_000,
|
|
});
|
|
try {
|
|
await transport.sendMail({
|
|
from: env.SMTP_FROM,
|
|
to: env.BACKUP_MAIL_TO,
|
|
subject: mail.subject,
|
|
text: mail.text,
|
|
});
|
|
return true;
|
|
} finally {
|
|
transport.close();
|
|
}
|
|
}
|