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:
@@ -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 == '?'
|
||||
}
|
||||
Reference in New Issue
Block a user