# 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=` 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 )", 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//. 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", }, }