Skip to content

Commit dcaffd4

Browse files
authored
feat(backup): add EtcdBackup CRD for declarative etcd snapshots (#307)
1 parent b7219f2 commit dcaffd4

62 files changed

Lines changed: 7170 additions & 30 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Dockerfile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ COPY internal/ ./internal/
2020
# was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO
2121
# the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore,
2222
# by leaving it empty we can ensure that the container and binary shipped on it will have the same platform.
23-
RUN CGO_ENABLED=0 GOOS="${TARGETOS:-linux}" GOARCH="${TARGETARCH}" go build -a -o manager cmd/manager/main.go
23+
RUN CGO_ENABLED=0 GOOS="${TARGETOS:-linux}" GOARCH="${TARGETARCH}" go build -a -o manager cmd/manager/main.go && \
24+
CGO_ENABLED=0 GOOS="${TARGETOS:-linux}" GOARCH="${TARGETARCH}" go build -a -o backup-agent cmd/backup-agent/main.go && \
25+
CGO_ENABLED=0 GOOS="${TARGETOS:-linux}" GOARCH="${TARGETARCH}" go build -a -o restore-agent cmd/restore-agent/main.go
2426

2527
# Use distroless as minimal base image to package the manager binary
2628
# Refer to https://github.com/GoogleContainerTools/distroless for more details
@@ -29,3 +31,5 @@ ENTRYPOINT ["/manager"]
2931
USER 65532:65532
3032
WORKDIR /
3133
COPY --chown=root:root --from=builder /workspace/manager .
34+
COPY --chown=root:root --from=builder /workspace/backup-agent .
35+
COPY --chown=root:root --from=builder /workspace/restore-agent .

Makefile

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,13 +112,25 @@ helm-crd-copy: yq kustomize ## Copy CRDs from kustomize to helm-chart
112112
@$(eval TMP := $(shell mktemp -d))
113113
@$(KUSTOMIZE) build config/default > $(TMP)/manifest.yaml && cd $(TMP) && $(YQ) -s '.kind + "-" + .metadata.name' --no-doc manifest.yaml && cd $(OLDPWD)
114114
@mv $(TMP)/CustomResourceDefinition-etcdclusters.etcd.aenix.io charts/etcd-operator/crds/etcd-cluster.yaml
115+
@mv $(TMP)/CustomResourceDefinition-etcdbackups.etcd.aenix.io charts/etcd-operator/crds/etcd-backup.yaml
116+
@mv $(TMP)/CustomResourceDefinition-etcdbackupschedules.etcd.aenix.io charts/etcd-operator/crds/etcd-backup-schedule.yaml
115117
@rm -rf $(TMP)
116118

117119
##@ Build
118120

119121
.PHONY: build
120122
build: manifests generate fmt vet ## Build manager binary.
121123
go build -o bin/manager cmd/manager/main.go
124+
go build -o bin/backup-agent cmd/backup-agent/main.go
125+
go build -o bin/restore-agent cmd/restore-agent/main.go
126+
127+
.PHONY: build-backup-agent
128+
build-backup-agent: ## Build backup-agent binary.
129+
go build -o bin/backup-agent cmd/backup-agent/main.go
130+
131+
.PHONY: build-restore-agent
132+
build-restore-agent: ## Build restore-agent binary.
133+
go build -o bin/restore-agent cmd/restore-agent/main.go
122134

123135
build-plugin:
124136
go build -o bin/kubectl-etcd cmd/kubectl-etcd/main.go

PROJECT

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,28 @@ resources:
2121
defaulting: true
2222
validation: true
2323
webhookVersion: v1
24+
- api:
25+
crdVersion: v1
26+
namespaced: true
27+
controller: true
28+
domain: etcd.aenix.io
29+
group: etcd.aenix.io
30+
kind: EtcdBackup
31+
path: github.com/aenix-io/etcd-operator/api/v1alpha1
32+
version: v1alpha1
33+
webhooks:
34+
validation: true
35+
webhookVersion: v1
36+
- api:
37+
crdVersion: v1
38+
namespaced: true
39+
controller: true
40+
domain: etcd.aenix.io
41+
group: etcd.aenix.io
42+
kind: EtcdBackupSchedule
43+
path: github.com/aenix-io/etcd-operator/api/v1alpha1
44+
version: v1alpha1
45+
webhooks:
46+
validation: true
47+
webhookVersion: v1
2448
version: "3"

api/v1alpha1/etcdbackup_types.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/*
2+
Copyright 2024 The etcd-operator Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package v1alpha1
18+
19+
import (
20+
corev1 "k8s.io/api/core/v1"
21+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
22+
)
23+
24+
type EtcdBackupStatusPhase string
25+
26+
const (
27+
EtcdBackupStatusPhasePending EtcdBackupStatusPhase = "Pending"
28+
EtcdBackupStatusPhaseStarted EtcdBackupStatusPhase = "Started"
29+
EtcdBackupStatusPhaseComplete EtcdBackupStatusPhase = "Complete"
30+
EtcdBackupStatusPhaseFailed EtcdBackupStatusPhase = "Failed"
31+
)
32+
33+
const (
34+
EtcdBackupConditionStarted = "Started"
35+
EtcdBackupConditionComplete = "Complete"
36+
EtcdBackupConditionFailed = "Failed"
37+
)
38+
39+
// EtcdBackupSpec defines the desired state of EtcdBackup
40+
type EtcdBackupSpec struct {
41+
// ClusterRef references the EtcdCluster to back up.
42+
ClusterRef corev1.LocalObjectReference `json:"clusterRef"`
43+
// Destination defines where the backup will be stored.
44+
Destination BackupDestination `json:"destination"`
45+
}
46+
47+
// BackupDestination defines the target location for the backup. Exactly one must be specified.
48+
type BackupDestination struct {
49+
// S3 defines S3-compatible storage as the backup destination.
50+
// +optional
51+
S3 *S3BackupDestination `json:"s3,omitempty"`
52+
// PVC defines a PersistentVolumeClaim as the backup destination.
53+
// +optional
54+
PVC *PVCBackupDestination `json:"pvc,omitempty"`
55+
}
56+
57+
// S3BackupDestination defines S3-compatible storage parameters.
58+
type S3BackupDestination struct {
59+
// Endpoint is the S3-compatible endpoint URL (e.g., "https://s3.amazonaws.com").
60+
Endpoint string `json:"endpoint"`
61+
// Bucket is the name of the S3 bucket.
62+
Bucket string `json:"bucket"`
63+
// Key is the key prefix (directory path) within the bucket.
64+
// The operator appends the backup filename automatically.
65+
// +optional
66+
Key string `json:"key,omitempty"`
67+
// CredentialsSecretRef references a Secret containing AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY keys.
68+
CredentialsSecretRef corev1.LocalObjectReference `json:"credentialsSecretRef"`
69+
// Region is the AWS region for the S3 bucket.
70+
// +optional
71+
Region string `json:"region,omitempty"`
72+
// ForcePathStyle forces path-style S3 URLs (e.g., endpoint/bucket/key)
73+
// instead of virtual-hosted-style (e.g., bucket.endpoint/key).
74+
// Most S3-compatible providers (MinIO, Ceph) require path style.
75+
// +optional
76+
ForcePathStyle bool `json:"forcePathStyle,omitempty"`
77+
}
78+
79+
// PVCBackupDestination defines a PersistentVolumeClaim as the backup target.
80+
type PVCBackupDestination struct {
81+
// ClaimName is the name of the PersistentVolumeClaim to use.
82+
ClaimName string `json:"claimName"`
83+
// SubPath is an optional sub-directory within the PVC volume.
84+
// The operator appends the backup filename automatically.
85+
// +optional
86+
SubPath string `json:"subPath,omitempty"`
87+
}
88+
89+
// EtcdBackupStatus defines the observed state of EtcdBackup
90+
type EtcdBackupStatus struct {
91+
Conditions []metav1.Condition `json:"conditions,omitempty"`
92+
Phase EtcdBackupStatusPhase `json:"phase,omitempty"`
93+
}
94+
95+
// +kubebuilder:object:root=true
96+
// +kubebuilder:subresource:status
97+
// +kubebuilder:printcolumn:name="Cluster",type=string,JSONPath=`.spec.clusterRef.name`
98+
// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
99+
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
100+
101+
// EtcdBackup is the Schema for the etcdbackups API
102+
type EtcdBackup struct {
103+
metav1.TypeMeta `json:",inline"`
104+
metav1.ObjectMeta `json:"metadata,omitempty"`
105+
106+
Spec EtcdBackupSpec `json:"spec,omitempty"`
107+
Status EtcdBackupStatus `json:"status,omitempty"`
108+
}
109+
110+
// +kubebuilder:object:root=true
111+
112+
// EtcdBackupList contains a list of EtcdBackup
113+
type EtcdBackupList struct {
114+
metav1.TypeMeta `json:",inline"`
115+
metav1.ListMeta `json:"metadata,omitempty"`
116+
Items []EtcdBackup `json:"items"`
117+
}
118+
119+
func init() {
120+
SchemeBuilder.Register(&EtcdBackup{}, &EtcdBackupList{})
121+
}

api/v1alpha1/etcdbackup_webhook.go

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/*
2+
Copyright 2024 The etcd-operator Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package v1alpha1
18+
19+
import (
20+
"fmt"
21+
"path/filepath"
22+
"strings"
23+
24+
"k8s.io/apimachinery/pkg/api/equality"
25+
"k8s.io/apimachinery/pkg/api/errors"
26+
"k8s.io/apimachinery/pkg/runtime"
27+
"k8s.io/apimachinery/pkg/runtime/schema"
28+
"k8s.io/apimachinery/pkg/util/validation/field"
29+
ctrl "sigs.k8s.io/controller-runtime"
30+
logf "sigs.k8s.io/controller-runtime/pkg/log"
31+
"sigs.k8s.io/controller-runtime/pkg/webhook"
32+
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
33+
)
34+
35+
var etcdbackuplog = logf.Log.WithName("etcdbackup-resource")
36+
37+
// SetupWebhookWithManager will setup the manager to manage the webhooks
38+
func (r *EtcdBackup) SetupWebhookWithManager(mgr ctrl.Manager) error {
39+
return ctrl.NewWebhookManagedBy(mgr).
40+
For(r).
41+
Complete()
42+
}
43+
44+
// +kubebuilder:webhook:path=/validate-etcd-aenix-io-v1alpha1-etcdbackup,mutating=false,failurePolicy=fail,sideEffects=None,groups=etcd.aenix.io,resources=etcdbackups,verbs=create;update,versions=v1alpha1,name=vetcdbackup.kb.io,admissionReviewVersions=v1
45+
46+
var _ webhook.Validator = &EtcdBackup{}
47+
48+
// ValidateCreate implements webhook.Validator so a webhook will be registered for the type
49+
func (r *EtcdBackup) ValidateCreate() (admission.Warnings, error) {
50+
etcdbackuplog.Info("validate create", "name", r.Name)
51+
52+
var allErrors field.ErrorList
53+
54+
if r.Spec.ClusterRef.Name == "" {
55+
allErrors = append(allErrors, field.Required(
56+
field.NewPath("spec", "clusterRef", "name"),
57+
"clusterRef.name is required",
58+
))
59+
}
60+
61+
destErrors := r.validateDestination()
62+
allErrors = append(allErrors, destErrors...)
63+
64+
if len(allErrors) > 0 {
65+
return nil, errors.NewInvalid(
66+
schema.GroupKind{Group: GroupVersion.Group, Kind: "EtcdBackup"},
67+
r.Name, allErrors)
68+
}
69+
70+
return nil, nil
71+
}
72+
73+
// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type
74+
func (r *EtcdBackup) ValidateUpdate(old runtime.Object) (admission.Warnings, error) {
75+
etcdbackuplog.Info("validate update", "name", r.Name)
76+
77+
oldBackup, ok := old.(*EtcdBackup)
78+
if !ok {
79+
return nil, fmt.Errorf("expected EtcdBackup but got %T", old)
80+
}
81+
82+
if !equality.Semantic.DeepEqual(r.Spec, oldBackup.Spec) {
83+
var allErrors field.ErrorList
84+
allErrors = append(allErrors, field.Forbidden(
85+
field.NewPath("spec"),
86+
"EtcdBackup spec is immutable",
87+
))
88+
return nil, errors.NewInvalid(
89+
schema.GroupKind{Group: GroupVersion.Group, Kind: "EtcdBackup"},
90+
r.Name, allErrors)
91+
}
92+
93+
return nil, nil
94+
}
95+
96+
// ValidateDelete implements webhook.Validator so a webhook will be registered for the type
97+
func (r *EtcdBackup) ValidateDelete() (admission.Warnings, error) {
98+
etcdbackuplog.Info("validate delete", "name", r.Name)
99+
return nil, nil
100+
}
101+
102+
func (r *EtcdBackup) validateDestination() field.ErrorList {
103+
return validateBackupDestination(r.Spec.Destination, field.NewPath("spec", "destination"))
104+
}
105+
106+
// validateBackupDestination validates a BackupDestination at the given field path.
107+
// This is shared between EtcdBackup and EtcdCluster (bootstrap restore source) webhooks.
108+
func validateBackupDestination(dest BackupDestination, destPath *field.Path) field.ErrorList {
109+
var allErrors field.ErrorList
110+
111+
if dest.S3 == nil && dest.PVC == nil {
112+
allErrors = append(allErrors, field.Required(
113+
destPath,
114+
"exactly one of s3 or pvc must be specified",
115+
))
116+
return allErrors
117+
}
118+
119+
if dest.S3 != nil && dest.PVC != nil {
120+
allErrors = append(allErrors, field.Invalid(
121+
destPath,
122+
"both s3 and pvc",
123+
"exactly one of s3 or pvc must be specified, not both",
124+
))
125+
return allErrors
126+
}
127+
128+
if s3 := dest.S3; s3 != nil {
129+
s3Path := destPath.Child("s3")
130+
if s3.Endpoint == "" {
131+
allErrors = append(allErrors, field.Required(s3Path.Child("endpoint"), "endpoint is required"))
132+
} else if !strings.HasPrefix(s3.Endpoint, "http://") && !strings.HasPrefix(s3.Endpoint, "https://") {
133+
allErrors = append(allErrors, field.Invalid(s3Path.Child("endpoint"), s3.Endpoint,
134+
"endpoint must start with http:// or https://"))
135+
}
136+
if s3.Bucket == "" {
137+
allErrors = append(allErrors, field.Required(s3Path.Child("bucket"), "bucket is required"))
138+
}
139+
if s3.CredentialsSecretRef.Name == "" {
140+
allErrors = append(allErrors, field.Required(s3Path.Child("credentialsSecretRef", "name"), "credentialsSecretRef.name is required"))
141+
}
142+
}
143+
144+
if pvc := dest.PVC; pvc != nil {
145+
pvcPath := destPath.Child("pvc")
146+
if pvc.ClaimName == "" {
147+
allErrors = append(allErrors, field.Required(pvcPath.Child("claimName"), "claimName is required"))
148+
}
149+
if pvc.SubPath != "" {
150+
cleaned := filepath.Clean(pvc.SubPath)
151+
if strings.HasPrefix(cleaned, "..") || filepath.IsAbs(cleaned) {
152+
allErrors = append(allErrors, field.Invalid(
153+
pvcPath.Child("subPath"), pvc.SubPath,
154+
"subPath must be a relative path and must not contain '..' components",
155+
))
156+
}
157+
}
158+
}
159+
160+
return allErrors
161+
}

0 commit comments

Comments
 (0)