provisioning/schemas/lib/backup_policy.ncl

187 lines
7.8 KiB
Text
Raw Normal View History

# Backup policy contracts — declarative description of how a component is backed up.
# Consumed by the backup-manager crate (one-shot, daemon, standalone, coordinator modes).
# Encryption, multi-destination replication and non-empty scopes are enforced as
# Nickel contracts so misconfiguration fails at `nickel export` time, not at runtime.
let vault = import "vault_refs.ncl" in
let _Duration = std.contract.from_validator (fun value =>
if !(std.is_string value)
then 'Error { message = "Duration must be a String" }
else if std.string.length value == 0
then 'Error { message = "Duration must be non-empty" }
else if !(std.string.contains "s" value
|| std.string.contains "m" value
|| std.string.contains "h" value
|| std.string.contains "d" value)
then 'Error { message = "Duration must contain a time unit (s, m, h, d)" }
else 'Ok
) in
let _CronExpr = std.contract.from_validator (fun value =>
if !(std.is_string value)
then 'Error { message = "CronExpr must be a String" }
else
let parts = std.string.split " " value in
if std.array.length parts == 5 || std.array.length parts == 6
then 'Ok
else 'Error { message = "CronExpr must have 5 or 6 space-separated fields" }
) in
let _Tags = { _ | String } in
let _DnsRecordsLike = { .. } in
{
Duration = _Duration,
CronExpr = _CronExpr,
Tags = _Tags,
# Schedule discriminated union: cron, interval or NATS event-driven.
Schedule = {
kind | [| 'cron, 'interval, 'on_event |],
cron_expr | _CronExpr | optional,
jitter_sec | Number | optional,
every | _Duration | optional,
jitter | _Duration | optional,
subject | String | optional | doc "NATS subject when kind = 'on_event",
debounce | _Duration | optional,
},
# Retention preset — keeps last N + N daily/weekly/monthly/yearly snapshots.
RetentionPolicy = {
keep_last | Number | default = 7,
keep_daily | Number | default = 7,
keep_weekly | Number | default = 4,
keep_monthly | Number | default = 6,
keep_yearly | Number | default = 0,
prune_after | _Duration | optional | doc "Delete data older than this regardless of keep_* (safety bound)",
},
# A backup destination (where snapshots end up). At least 2 are required
# when policy is enabled (MultiDestinationRequired contract).
Destination = {
name | String | doc "Stable identifier (used in metrics labels and tags)",
kind | [| 's3, 'b2, 'local, 'sftp, 'rest_server |],
uri | String | doc "restic-style URI: 's3:host/bucket', 'b2:bucket', 'sftp:user@host:/path', etc.",
cred_ref | vault.VaultCredRef,
role | [| 'primary, 'replica, 'archive |] | default = 'replica,
region | String | optional,
},
# Tagging strategy for snapshots. The actual tags emitted are a determinístic
# function of {component, scope, parameters} computed by the manager.
TagStrategy = {
component_label | String | doc "Used as `component=<value>` tag",
extra | Array String | doc "Additional static tags (k=v strings)" | default = [],
},
# Database dump strategy. Three flavours cover the consistency/atomicity matrix.
DumpStrategy = {
kind | [| 'stream_to_stdin, 'dump_to_path, 'pre_dump_then_path,
'csi_volume_snapshot, 'app_quiesce_then_snapshot |],
dump_command | String | optional,
path | String | optional,
cleanup | Bool | default = true,
volume | String | optional,
snapshot_class | String | optional,
quiesce_cmd | String | optional,
unquiesce_cmd | String | optional,
},
DbEngine = [| 'postgresql, 'mariadb, 'mysql, 'redis, 'mongodb, 'surrealdb, 'etcd, 'sqlite |],
# Discriminated scope: what gets backed up and how it's grouped/tagged.
BackupScope = {
kind | [| 'service_full, 'per_domain, 'per_mailbox, 'database,
'volume_snapshot, 'logs_archive, 'kv_export |],
name | String | doc "Identifier within a policy (used in CLI: --scope <name>)",
paths | Array String | default = [],
exclude | Array String | default = [],
domains | Array String | default = [],
base_path | String | default = "",
selector | String | optional,
engine | DbEngine | optional,
dump_strategy | DumpStrategy | optional,
volumes | Array String | default = [],
snapshot_class | String | optional,
sources | Array String | default = [],
format | [| 'jsonl_gz, 'tar_gz, 'restic_native, 'sqlite_dump |] | optional,
rotation | _Duration | optional,
source | [| 'etcd, 'consul, 'loki, 'journald, 'files |] | optional,
tag_prefix | String | default = "",
tags | _Tags | default = {},
},
# Pre/post hooks executed by the manager around the backup run.
Hooks = {
pre | Array String | default = [],
post | Array String | default = [],
timeout | _Duration | default = "5m",
abort_on_failure | Bool | default = true,
},
# Throttle network bandwidth (passed to provider as --limit-upload/--limit-download).
Throttle = {
upload_kbps | Number | optional,
download_kbps | Number | optional,
},
# Verify policy. Drill is a separate spec consumed by verify_policy.ncl.
VerifyPolicyRef = {
schedule | { kind | [| 'cron, 'interval |], cron_expr | String | optional, every | _Duration | optional } | optional,
level | [| 'quick, 'deep, 'restore_drill, 'full_dr |] | default = 'quick,
drill_ref | String | optional | doc "Reference to a DrillSpec by name (looked up from verify-recipes/)",
},
# Provider reference. Manager resolves to extensions/providers/backup/<name>/.
BackupProviderRef = {
name | String | doc "Provider directory name (e.g. 'restic', 'kopia')",
version | String | optional | doc "Pinned version; warn if installed CLI mismatches",
},
# === Contracts ===========================================================
# NonEmptyScopes: an enabled BackupPolicy must have at least one scope.
NonEmptyScopes = std.contract.from_validator (fun value =>
if std.array.length value > 0
then 'Ok
else 'Error { message = "BackupPolicy.scopes must contain at least one BackupScope" }
),
# MultiDestinationRequired: enforces the off-site replication invariant.
# A policy must declare ≥2 destinations and at least one with role = 'primary.
MultiDestinationRequired = std.contract.from_validator (fun value =>
if std.array.length value < 2
then 'Error {
message = "BackupPolicy.destinations must contain at least 2 entries (off-site replication is non-negotiable)",
}
else
let has_primary = std.array.any (fun d => d.role == 'primary) value in
if !has_primary
then 'Error {
message = "BackupPolicy.destinations must contain at least one entry with role = 'primary",
}
else 'Ok
),
# === Top-level policy ====================================================
BackupPolicy = {
provider | BackupProviderRef,
destinations | Array Destination
| doc "≥2 destinations, at least one 'primary",
encryption | vault.VaultKeyRef
| doc "Encryption key reference in vault (E2E encryption is non-negotiable)",
schedule | Schedule,
retention | RetentionPolicy,
scopes | Array BackupScope
| doc "1..N backup units; tagged determinístically",
tag_strategy | TagStrategy,
hooks | Hooks | optional,
verify | VerifyPolicyRef | optional,
throttle | Throttle | optional,
consistency_group | String | optional | doc "If set, this policy participates in a BackupGroup",
},
}