Implement autobackup-operator controllers, tests, CI and packaging

PVCs and CloudNativePG Clusters need S3 buckets and backup schedules
provisioned consistently. This operator watches the
backups.unkin.net/{schedule,destination} annotations on those objects and
provisions everything needed to back them up, with no new CRDs.

- Add a PVC controller that provisions cephrgw ObjectStoreUser/Bucket/BucketAccess,
  auto-generates a restic repo-password Secret and creates a k8up Schedule scoped
  to the PVC via spec.backup.volumes[].persistentVolumeClaim.claimName.
- Add a CNPG Cluster controller that provisions the same bucket stack, idempotently
  patches spec.backup.barmanObjectStore (leaving a user-set destinationPath alone
  with a Warning event) and creates a ScheduledBackup.
- Resolve destinations through a ConfigMap lookup table; requeue until the
  BucketAccess is Ready before creating schedule resources; own-reference created
  resources and retain bucket data by default.
- Add schedule-mapping helpers (k8up 5-field/shortcut pass-through, CNPG 6-field
  seconds-first) and deterministic, length-bounded name derivation.
- Add unit tests (schedule mapping, name derivation, destination resolution) and
  envtest controller tests for both paths, wiring the external CRDs into envtest.
- Add kubebuilder-generated RBAC, a Dockerfile (distroless/nonroot), Woodpecker
  lint/test/build pipelines and a tag-triggered image push to the artifactapi
  docker-internal registry, plus a version-bump Makefile and deploy manifests.
This commit is contained in:
2026-08-14 00:08:37 +10:00
parent 3d63975b24
commit 9da206dc7c
4631 changed files with 1334852 additions and 1 deletions
+99
View File
@@ -0,0 +1,99 @@
package controller
import (
"fmt"
"strings"
)
// cnpgShortcuts expands cron nickname shortcuts to the 6-field (seconds-first)
// cron expressions CloudNativePG's robfig/cron parser expects.
var cnpgShortcuts = map[string]string{
"@hourly": "0 0 * * * *",
"@daily": "0 0 0 * * *",
"@midnight": "0 0 0 * * *",
"@weekly": "0 0 0 * * 0",
"@monthly": "0 0 0 1 * *",
"@yearly": "0 0 0 1 1 *",
"@annually": "0 0 0 1 1 *",
}
// k8upShortcuts is the set of cron nicknames k8up accepts natively (passed
// through unchanged).
var k8upShortcuts = map[string]bool{
"@hourly": true, "@daily": true, "@midnight": true, "@weekly": true,
"@monthly": true, "@yearly": true, "@annually": true,
}
// ScheduleForK8up validates a schedule annotation for k8up, which accepts cron
// nickname shortcuts natively and otherwise expects a 5-field cron expression.
// A valid value is returned unchanged.
func ScheduleForK8up(in string) (string, error) {
s := strings.TrimSpace(in)
if s == "" {
return "", fmt.Errorf("empty schedule")
}
if strings.HasPrefix(s, "@") {
if k8upShortcuts[s] {
return s, nil
}
return "", fmt.Errorf("unsupported schedule shortcut %q", s)
}
if err := validateCronFields(s, 5); err != nil {
return "", err
}
return s, nil
}
// ScheduleForCNPG normalises a schedule annotation into the 6-field
// (seconds-first) cron format a CloudNativePG ScheduledBackup requires:
// shortcuts expand to explicit expressions, a 5-field cron gains a leading "0"
// seconds field, and a 6-field cron is accepted as-is.
func ScheduleForCNPG(in string) (string, error) {
s := strings.TrimSpace(in)
if s == "" {
return "", fmt.Errorf("empty schedule")
}
if strings.HasPrefix(s, "@") {
if v, ok := cnpgShortcuts[s]; ok {
return v, nil
}
return "", fmt.Errorf("unsupported schedule shortcut %q", s)
}
fields := strings.Fields(s)
switch len(fields) {
case 5:
if err := validateCronFields(s, 5); err != nil {
return "", err
}
return "0 " + strings.Join(fields, " "), nil
case 6:
if err := validateCronFields(s, 6); err != nil {
return "", err
}
return strings.Join(fields, " "), nil
default:
return "", fmt.Errorf("cron %q must have 5 or 6 fields, got %d", s, len(fields))
}
}
func validateCronFields(s string, want int) error {
fields := strings.Fields(s)
if len(fields) != want {
return fmt.Errorf("cron %q must have %d fields, got %d", s, want, len(fields))
}
for _, f := range fields {
if f == "" {
return fmt.Errorf("cron %q has an empty field", s)
}
for _, r := range f {
if !isCronRune(r) {
return fmt.Errorf("cron field %q contains invalid character %q", f, string(r))
}
}
}
return nil
}
func isCronRune(r rune) bool {
return (r >= '0' && r <= '9') || r == '*' || r == '/' || r == ',' || r == '-' || r == '?'
}