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
@@ -0,0 +1,365 @@
/*
Copyright 2019 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/*
Package conversion provides implementation for CRD conversion webhook that implements handler for version conversion requests for types that are convertible.
See pkg/conversion for interface definitions required to ensure an API Type is convertible.
*/
package conversion
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
apix "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
"sigs.k8s.io/controller-runtime/pkg/conversion"
logf "sigs.k8s.io/controller-runtime/pkg/log"
conversionmetrics "sigs.k8s.io/controller-runtime/pkg/webhook/conversion/metrics"
)
var (
log = logf.Log.WithName("conversion-webhook")
)
func NewWebhookHandler(scheme *runtime.Scheme, registry Registry) http.Handler {
return &webhook{scheme: scheme, decoder: NewDecoder(scheme), registry: registry}
}
// webhook implements a CRD conversion webhook HTTP handler.
type webhook struct {
scheme *runtime.Scheme
decoder *Decoder
registry Registry
}
// ensure Webhook implements http.Handler
var _ http.Handler = &webhook{}
func (wh *webhook) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
convertReview := &apix.ConversionReview{}
err := json.NewDecoder(r.Body).Decode(convertReview)
if err != nil {
log.Error(err, "failed to read conversion request")
w.WriteHeader(http.StatusBadRequest)
return
}
if convertReview.Request == nil {
log.Error(nil, "conversion request is nil")
w.WriteHeader(http.StatusBadRequest)
return
}
// TODO(droot): may be move the conversion logic to a separate module to
// decouple it from the http layer ?
resp, err := wh.handleConvertRequest(ctx, convertReview.Request)
if err != nil {
log.Error(err, "failed to convert", "request", convertReview.Request.UID)
convertReview.Response = errored(err)
} else {
convertReview.Response = resp
}
convertReview.Response.UID = convertReview.Request.UID
convertReview.Request = nil
err = json.NewEncoder(w).Encode(convertReview)
if err != nil {
log.Error(err, "failed to write response")
return
}
}
// handles a version conversion request.
func (wh *webhook) handleConvertRequest(ctx context.Context, req *apix.ConversionRequest) (_ *apix.ConversionResponse, retErr error) {
defer func() {
if r := recover(); r != nil {
conversionmetrics.WebhookPanics.WithLabelValues().Inc()
for _, fn := range utilruntime.PanicHandlers {
fn(ctx, r)
}
retErr = errors.New("internal error occurred during conversion")
return
}
}()
if req == nil {
return nil, fmt.Errorf("conversion request is nil")
}
var objects []runtime.RawExtension
for _, obj := range req.Objects {
src, gvk, err := wh.decoder.Decode(obj.Raw)
if err != nil {
return nil, err
}
dst, err := wh.allocateDstObject(req.DesiredAPIVersion, gvk.Kind)
if err != nil {
return nil, err
}
err = wh.convertObject(ctx, src, dst)
if err != nil {
return nil, err
}
objects = append(objects, runtime.RawExtension{Object: dst})
}
return &apix.ConversionResponse{
UID: req.UID,
ConvertedObjects: objects,
Result: metav1.Status{
Status: metav1.StatusSuccess,
},
}, nil
}
// convertObject will convert given a src object to dst object.
// Note(droot): couldn't find a way to reduce the cyclomatic complexity under 10
// without compromising readability, so disabling gocyclo linter
func (wh *webhook) convertObject(ctx context.Context, src, dst runtime.Object) error {
srcGVK := src.GetObjectKind().GroupVersionKind()
dstGVK := dst.GetObjectKind().GroupVersionKind()
if srcGVK.GroupKind() != dstGVK.GroupKind() {
return fmt.Errorf("src %T and dst %T does not belong to same API Group", src, dst)
}
if srcGVK == dstGVK {
return fmt.Errorf("conversion is not allowed between same type %T", src)
}
if converter, ok := wh.registry.GetConverter(srcGVK.GroupKind()); ok {
return converter.ConvertObject(ctx, src, dst)
}
srcIsHub, dstIsHub := isHub(src), isHub(dst)
srcIsConvertible, dstIsConvertible := isConvertible(src), isConvertible(dst)
switch {
case srcIsHub && dstIsConvertible:
return dst.(conversion.Convertible).ConvertFrom(src.(conversion.Hub))
case dstIsHub && srcIsConvertible:
return src.(conversion.Convertible).ConvertTo(dst.(conversion.Hub))
case srcIsConvertible && dstIsConvertible:
return wh.convertViaHub(src.(conversion.Convertible), dst.(conversion.Convertible))
default:
return fmt.Errorf("%T is not convertible to %T", src, dst)
}
}
func (wh *webhook) convertViaHub(src, dst conversion.Convertible) error {
hub, err := wh.getHub(src)
if err != nil {
return err
}
if hub == nil {
return fmt.Errorf("%s does not have any Hub defined", src)
}
err = src.ConvertTo(hub)
if err != nil {
return fmt.Errorf("%T failed to convert to hub version %T : %w", src, hub, err)
}
err = dst.ConvertFrom(hub)
if err != nil {
return fmt.Errorf("%T failed to convert from hub version %T : %w", dst, hub, err)
}
return nil
}
// getHub returns an instance of the Hub for passed-in object's group/kind.
func (wh *webhook) getHub(obj runtime.Object) (conversion.Hub, error) {
gvks, err := objectGVKs(wh.scheme, obj)
if err != nil {
return nil, err
}
if len(gvks) == 0 {
return nil, fmt.Errorf("error retrieving gvks for object : %v", obj)
}
var hub conversion.Hub
var hubFoundAlready bool
for _, gvk := range gvks {
instance, err := wh.scheme.New(gvk)
if err != nil {
return nil, fmt.Errorf("failed to allocate an instance for gvk %v: %w", gvk, err)
}
if val, isHub := instance.(conversion.Hub); isHub {
if hubFoundAlready {
return nil, fmt.Errorf("multiple hub version defined for %T", obj)
}
hubFoundAlready = true
hub = val
}
}
return hub, nil
}
// allocateDstObject returns an instance for a given GVK.
func (wh *webhook) allocateDstObject(apiVersion, kind string) (runtime.Object, error) {
gvk := schema.FromAPIVersionAndKind(apiVersion, kind)
obj, err := wh.scheme.New(gvk)
if err != nil {
return obj, err
}
t, err := meta.TypeAccessor(obj)
if err != nil {
return obj, err
}
t.SetAPIVersion(apiVersion)
t.SetKind(kind)
return obj, nil
}
// IsConvertible determines if given type is convertible or not. For a type
// to be convertible, the group-kind needs to have a Hub type defined and all
// non-hub types must be able to convert to/from Hub.
func IsConvertible(scheme *runtime.Scheme, obj runtime.Object) (bool, error) {
var hubs, spokes, nonSpokes []runtime.Object
gvks, err := objectGVKs(scheme, obj)
if err != nil {
return false, err
}
if len(gvks) == 0 {
return false, fmt.Errorf("error retrieving gvks for object : %v", obj)
}
for _, gvk := range gvks {
instance, err := scheme.New(gvk)
if err != nil {
return false, fmt.Errorf("failed to allocate an instance for gvk %v: %w", gvk, err)
}
if isHub(instance) {
hubs = append(hubs, instance)
continue
}
if !isConvertible(instance) {
nonSpokes = append(nonSpokes, instance)
continue
}
spokes = append(spokes, instance)
}
if len(gvks) == 1 {
return false, nil // single version
}
if len(hubs) == 0 && len(spokes) == 0 {
// multiple version detected with no conversion implementation. This is
// true for multi-version built-in types.
return false, nil
}
if len(hubs) == 1 && len(nonSpokes) == 0 { // convertible
return true, nil
}
return false, PartialImplementationError{
hubs: hubs,
nonSpokes: nonSpokes,
spokes: spokes,
}
}
// objectGVKs returns all (Group,Version,Kind) for the Group/Kind of given object.
func objectGVKs(scheme *runtime.Scheme, obj runtime.Object) ([]schema.GroupVersionKind, error) {
// NB: we should not use `obj.GetObjectKind().GroupVersionKind()` to get the
// GVK here, since it is parsed from apiVersion and kind fields and it may
// return empty GVK if obj is an uninitialized object.
objGVKs, _, err := scheme.ObjectKinds(obj)
if err != nil {
return nil, err
}
if len(objGVKs) != 1 {
return nil, fmt.Errorf("expect to get only one GVK for %v", obj)
}
objGVK := objGVKs[0]
knownTypes := scheme.AllKnownTypes()
var gvks []schema.GroupVersionKind
for gvk := range knownTypes {
if objGVK.GroupKind() == gvk.GroupKind() {
gvks = append(gvks, gvk)
}
}
return gvks, nil
}
// PartialImplementationError represents an error due to partial conversion
// implementation such as hub without spokes, multiple hubs or spokes without hub.
type PartialImplementationError struct {
gvk schema.GroupVersionKind
hubs []runtime.Object
nonSpokes []runtime.Object
spokes []runtime.Object
}
func (e PartialImplementationError) Error() string {
if len(e.hubs) == 0 {
return fmt.Sprintf("no hub defined for gvk %s", e.gvk)
}
if len(e.hubs) > 1 {
return fmt.Sprintf("multiple(%d) hubs defined for group-kind '%s' ",
len(e.hubs), e.gvk.GroupKind())
}
if len(e.nonSpokes) > 0 {
return fmt.Sprintf("%d inconvertible types detected for group-kind '%s'",
len(e.nonSpokes), e.gvk.GroupKind())
}
return ""
}
// isHub determines if passed-in object is a Hub or not.
func isHub(obj runtime.Object) bool {
_, yes := obj.(conversion.Hub)
return yes
}
// isConvertible determines if passed-in object is a convertible.
func isConvertible(obj runtime.Object) bool {
_, yes := obj.(conversion.Convertible)
return yes
}
// helper to construct error response.
func errored(err error) *apix.ConversionResponse {
return &apix.ConversionResponse{
Result: metav1.Status{
Status: metav1.StatusFailure,
Message: err.Error(),
},
}
}
@@ -0,0 +1,173 @@
/*
Copyright 2025 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package conversion
import (
"context"
"fmt"
"slices"
"strings"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/sets"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/apiutil"
)
func NewHubSpokeConverter[hubObject runtime.Object](hub hubObject, spokeConverter ...SpokeConverter[hubObject]) func(scheme *runtime.Scheme) (Converter, error) {
return func(scheme *runtime.Scheme) (Converter, error) {
hubGVK, err := apiutil.GVKForObject(hub, scheme)
if err != nil {
return nil, fmt.Errorf("failed to create hub spoke converter: failed to get GroupVersionKind for hub: %w", err)
}
allGVKs, err := objectGVKs(scheme, hub)
if err != nil {
return nil, fmt.Errorf("failed to create hub spoke converter for %s: %w", hubGVK.Kind, err)
}
spokeVersions := sets.New[string]()
for _, gvk := range allGVKs {
if gvk != hubGVK {
spokeVersions.Insert(gvk.Version)
}
}
c := &hubSpokeConverter[hubObject]{
scheme: scheme,
hubGVK: hubGVK,
spokeConverterByGVK: map[schema.GroupVersionKind]SpokeConverter[hubObject]{},
}
spokeConverterVersions := sets.New[string]()
for _, sc := range spokeConverter {
spokeGVK, err := apiutil.GVKForObject(sc.GetSpoke(), scheme)
if err != nil {
return nil, fmt.Errorf("failed to create hub spoke converter for %s: "+
"failed to get GroupVersionKind for spoke converter: %w",
hubGVK.Kind, err)
}
if hubGVK.GroupKind() != spokeGVK.GroupKind() {
return nil, fmt.Errorf("failed to create hub spoke converter for %s: "+
"spoke converter GroupKind %s does not match hub GroupKind %s",
hubGVK.Kind, spokeGVK.GroupKind(), hubGVK.GroupKind())
}
if _, ok := c.spokeConverterByGVK[spokeGVK]; ok {
return nil, fmt.Errorf("failed to create hub spoke converter for %s: "+
"duplicate spoke converter for version %s",
hubGVK.Kind, spokeGVK.Version)
}
c.spokeConverterByGVK[spokeGVK] = sc
spokeConverterVersions.Insert(spokeGVK.Version)
}
if !spokeConverterVersions.Equal(spokeVersions) {
return nil, fmt.Errorf("failed to create hub spoke converter for %s: "+
"expected spoke converter for %s got spoke converter for %s",
hubGVK.Kind, sortAndJoin(spokeVersions), sortAndJoin(spokeConverterVersions))
}
return c, nil
}
}
func sortAndJoin(set sets.Set[string]) string {
list := set.UnsortedList()
slices.Sort(list)
return strings.Join(list, ",")
}
type hubSpokeConverter[hubObject runtime.Object] struct {
scheme *runtime.Scheme
hubGVK schema.GroupVersionKind
spokeConverterByGVK map[schema.GroupVersionKind]SpokeConverter[hubObject]
}
func (c hubSpokeConverter[hubObject]) ConvertObject(ctx context.Context, src, dst runtime.Object) error {
srcGVK := src.GetObjectKind().GroupVersionKind()
dstGVK := dst.GetObjectKind().GroupVersionKind()
if srcGVK.GroupKind() != dstGVK.GroupKind() {
return fmt.Errorf("src %T and dst %T does not belong to same API Group", src, dst)
}
if srcGVK == dstGVK {
return fmt.Errorf("conversion is not allowed between same type %T", src)
}
srcIsHub := c.hubGVK == srcGVK
dstIsHub := c.hubGVK == dstGVK
_, srcIsConvertible := c.spokeConverterByGVK[srcGVK]
_, dstIsConvertible := c.spokeConverterByGVK[dstGVK]
switch {
case srcIsHub && dstIsConvertible:
return c.spokeConverterByGVK[dstGVK].ConvertHubToSpoke(ctx, src.(hubObject), dst)
case dstIsHub && srcIsConvertible:
return c.spokeConverterByGVK[srcGVK].ConvertSpokeToHub(ctx, src, dst.(hubObject))
case srcIsConvertible && dstIsConvertible:
hub, err := c.scheme.New(c.hubGVK)
if err != nil {
return fmt.Errorf("failed to allocate an instance for GroupVersionKind %s: %w", c.hubGVK, err)
}
if err := c.spokeConverterByGVK[srcGVK].ConvertSpokeToHub(ctx, src, hub.(hubObject)); err != nil {
return fmt.Errorf("failed to convert spoke %s to hub %s : %w", srcGVK, c.hubGVK, err)
}
if err := c.spokeConverterByGVK[dstGVK].ConvertHubToSpoke(ctx, hub.(hubObject), dst); err != nil {
return fmt.Errorf("failed to convert hub %s to spoke %s : %w", c.hubGVK, dstGVK, err)
}
return nil
default:
return fmt.Errorf("failed to convert %s to %s: not convertible", srcGVK, dstGVK)
}
}
type SpokeConverter[hubObject runtime.Object] interface {
GetSpoke() runtime.Object
ConvertHubToSpoke(ctx context.Context, hub hubObject, spoke runtime.Object) error
ConvertSpokeToHub(ctx context.Context, spoke runtime.Object, hub hubObject) error
}
func NewSpokeConverter[hubObject, spokeObject client.Object](
spoke spokeObject,
convertHubToSpokeFunc func(ctx context.Context, src hubObject, dst spokeObject) error,
convertSpokeToHubFunc func(ctx context.Context, src spokeObject, dst hubObject) error,
) SpokeConverter[hubObject] {
return &spokeConverter[hubObject, spokeObject]{
spoke: spoke,
convertSpokeToHubFunc: convertSpokeToHubFunc,
convertHubToSpokeFunc: convertHubToSpokeFunc,
}
}
type spokeConverter[hubObject, spokeObject runtime.Object] struct {
spoke spokeObject
convertHubToSpokeFunc func(ctx context.Context, src hubObject, dst spokeObject) error
convertSpokeToHubFunc func(ctx context.Context, src spokeObject, dst hubObject) error
}
func (c spokeConverter[hubObject, spokeObject]) GetSpoke() runtime.Object {
return c.spoke
}
func (c spokeConverter[hubObject, spokeObject]) ConvertHubToSpoke(ctx context.Context, hub hubObject, spoke runtime.Object) error {
return c.convertHubToSpokeFunc(ctx, hub, spoke.(spokeObject))
}
func (c spokeConverter[hubObject, spokeObject]) ConvertSpokeToHub(ctx context.Context, spoke runtime.Object, hub hubObject) error {
return c.convertSpokeToHubFunc(ctx, spoke.(spokeObject), hub)
}
@@ -0,0 +1,57 @@
/*
Copyright 2025 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package conversion
import (
"context"
"fmt"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
)
type Converter interface {
ConvertObject(ctx context.Context, src, dst runtime.Object) error
}
type Registry interface {
RegisterConverter(gk schema.GroupKind, converter Converter) error
GetConverter(gk schema.GroupKind) (Converter, bool)
}
type registry struct {
converterByGK map[schema.GroupKind]Converter
}
func NewRegistry() Registry {
return registry{
converterByGK: map[schema.GroupKind]Converter{},
}
}
func (r registry) RegisterConverter(gk schema.GroupKind, converter Converter) error {
if _, ok := r.converterByGK[gk]; ok {
return fmt.Errorf("failed to register Converter for GroupKind %s: converter already registered", gk)
}
r.converterByGK[gk] = converter
return nil
}
func (r registry) GetConverter(gk schema.GroupKind) (Converter, bool) {
c, ok := r.converterByGK[gk]
return c, ok
}
@@ -0,0 +1,50 @@
/*
Copyright 2021 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package conversion
import (
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/runtime/serializer"
)
// Decoder knows how to decode the contents of a CRD version conversion
// request into a concrete object.
// TODO(droot): consider reusing decoder from admission pkg for this.
type Decoder struct {
codecs serializer.CodecFactory
}
// NewDecoder creates a Decoder given the runtime.Scheme
func NewDecoder(scheme *runtime.Scheme) *Decoder {
if scheme == nil {
panic("scheme should never be nil")
}
return &Decoder{codecs: serializer.NewCodecFactory(scheme)}
}
// Decode decodes the inlined object.
func (d *Decoder) Decode(content []byte) (runtime.Object, *schema.GroupVersionKind, error) {
deserializer := d.codecs.UniversalDeserializer()
return deserializer.Decode(content, nil, nil)
}
// DecodeInto decodes the inlined object in the into the passed-in runtime.Object.
func (d *Decoder) DecodeInto(content []byte, into runtime.Object) error {
deserializer := d.codecs.UniversalDeserializer()
return runtime.DecodeInto(deserializer, content, into)
}
@@ -0,0 +1,39 @@
/*
Copyright 2025 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
"sigs.k8s.io/controller-runtime/pkg/metrics"
)
var (
// WebhookPanics is a prometheus counter metrics which holds the total
// number of panics from conversion webhooks.
WebhookPanics = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "controller_runtime_conversion_webhook_panics_total",
Help: "Total number of conversion webhook panics",
}, []string{})
)
func init() {
metrics.Registry.MustRegister(
WebhookPanics,
)
// Init metric.
WebhookPanics.WithLabelValues().Add(0)
}