diff --git a/Makefile b/Makefile index 2038ff9f..182db8af 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,7 @@ build: build-deps make -C packages/extra/monitoring image make -C packages/system/cozystack-api image make -C packages/system/cozystack-controller image + make -C packages/system/backup-controller image make -C packages/system/lineage-controller-webhook image make -C packages/system/cilium image make -C packages/system/kubeovn image diff --git a/cmd/backup-controller/main.go b/cmd/backup-controller/main.go new file mode 100644 index 00000000..d8436659 --- /dev/null +++ b/cmd/backup-controller/main.go @@ -0,0 +1,174 @@ +/* +Copyright 2025. + +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 main + +import ( + "crypto/tls" + "flag" + "os" + + // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) + // to ensure that exec-entrypoint and run can make use of them. + _ "k8s.io/client-go/plugin/pkg/client/auth" + + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/metrics/filters" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + "sigs.k8s.io/controller-runtime/pkg/webhook" + + backupsv1alpha1 "github.com/cozystack/cozystack/api/backups/v1alpha1" + "github.com/cozystack/cozystack/internal/backupcontroller" + // +kubebuilder:scaffold:imports +) + +var ( + scheme = runtime.NewScheme() + setupLog = ctrl.Log.WithName("setup") +) + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + + utilruntime.Must(backupsv1alpha1.AddToScheme(scheme)) + // +kubebuilder:scaffold:scheme +} + +func main() { + var metricsAddr string + var enableLeaderElection bool + var probeAddr string + var secureMetrics bool + var enableHTTP2 bool + var tlsOpts []func(*tls.Config) + flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+ + "Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.") + flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") + flag.BoolVar(&enableLeaderElection, "leader-elect", false, + "Enable leader election for controller manager. "+ + "Enabling this will ensure there is only one active controller manager.") + flag.BoolVar(&secureMetrics, "metrics-secure", true, + "If set, the metrics endpoint is served securely via HTTPS. Use --metrics-secure=false to use HTTP instead.") + flag.BoolVar(&enableHTTP2, "enable-http2", false, + "If set, HTTP/2 will be enabled for the metrics and webhook servers") + opts := zap.Options{ + Development: false, + } + opts.BindFlags(flag.CommandLine) + flag.Parse() + + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + + // if the enable-http2 flag is false (the default), http/2 should be disabled + // due to its vulnerabilities. More specifically, disabling http/2 will + // prevent from being vulnerable to the HTTP/2 Stream Cancellation and + // Rapid Reset CVEs. For more information see: + // - https://github.com/advisories/GHSA-qppj-fm5r-hxr3 + // - https://github.com/advisories/GHSA-4374-p667-p6c8 + disableHTTP2 := func(c *tls.Config) { + setupLog.Info("disabling http/2") + c.NextProtos = []string{"http/1.1"} + } + + if !enableHTTP2 { + tlsOpts = append(tlsOpts, disableHTTP2) + } + + webhookServer := webhook.NewServer(webhook.Options{ + TLSOpts: tlsOpts, + }) + + // Metrics endpoint is enabled in 'config/default/kustomization.yaml'. The Metrics options configure the server. + // More info: + // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.1/pkg/metrics/server + // - https://book.kubebuilder.io/reference/metrics.html + metricsServerOptions := metricsserver.Options{ + BindAddress: metricsAddr, + SecureServing: secureMetrics, + TLSOpts: tlsOpts, + } + + if secureMetrics { + // FilterProvider is used to protect the metrics endpoint with authn/authz. + // These configurations ensure that only authorized users and service accounts + // can access the metrics endpoint. The RBAC are configured in 'config/rbac/kustomization.yaml'. More info: + // https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.1/pkg/metrics/filters#WithAuthenticationAndAuthorization + metricsServerOptions.FilterProvider = filters.WithAuthenticationAndAuthorization + + // TODO(user): If CertDir, CertName, and KeyName are not specified, controller-runtime will automatically + // generate self-signed certificates for the metrics server. While convenient for development and testing, + // this setup is not recommended for production. + } + + // Configure rate limiting for the Kubernetes client + config := ctrl.GetConfigOrDie() + config.QPS = 50.0 // Increased from default 5.0 + config.Burst = 100 // Increased from default 10 + + mgr, err := ctrl.NewManager(config, ctrl.Options{ + Scheme: scheme, + Metrics: metricsServerOptions, + WebhookServer: webhookServer, + HealthProbeBindAddress: probeAddr, + LeaderElection: enableLeaderElection, + LeaderElectionID: "core.backups.cozystack.io", + // LeaderElectionReleaseOnCancel defines if the leader should step down voluntarily + // when the Manager ends. This requires the binary to immediately end when the + // Manager is stopped, otherwise, this setting is unsafe. Setting this significantly + // speeds up voluntary leader transitions as the new leader don't have to wait + // LeaseDuration time first. + // + // In the default scaffold provided, the program ends immediately after + // the manager stops, so would be fine to enable this option. However, + // if you are doing or is intended to do any operation such as perform cleanups + // after the manager stops then its usage might be unsafe. + // LeaderElectionReleaseOnCancel: true, + }) + if err != nil { + setupLog.Error(err, "unable to start manager") + os.Exit(1) + } + + if err = (&backupcontroller.PlanReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Plan") + os.Exit(1) + } + + // +kubebuilder:scaffold:builder + + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + setupLog.Error(err, "unable to set up health check") + os.Exit(1) + } + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + setupLog.Error(err, "unable to set up ready check") + os.Exit(1) + } + + setupLog.Info("starting manager") + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + setupLog.Error(err, "problem running manager") + os.Exit(1) + } +} diff --git a/internal/backupcontroller/plan_controller.go b/internal/backupcontroller/plan_controller.go index fca5703b..046fdce4 100644 --- a/internal/backupcontroller/plan_controller.go +++ b/internal/backupcontroller/plan_controller.go @@ -61,6 +61,20 @@ func (r *PlanReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl. } return ctrl.Result{}, nil } + + // Clear error condition if cron parsing succeeds + if condition := meta.FindStatusCondition(p.Status.Conditions, backupsv1alpha1.PlanConditionError); condition != nil && condition.Status == metav1.ConditionTrue { + meta.SetStatusCondition(&p.Status.Conditions, metav1.Condition{ + Type: backupsv1alpha1.PlanConditionError, + Status: metav1.ConditionFalse, + Reason: "Cron spec is valid", + Message: "The cron schedule has been successfully parsed", + }) + if err := r.Status().Update(ctx, p); err != nil { + return ctrl.Result{}, err + } + } + tNext := sch.Next(tCheck) if time.Now().Before(tNext) { diff --git a/packages/core/platform/bundles/paas-full.yaml b/packages/core/platform/bundles/paas-full.yaml index 4382bd62..1c83c49c 100644 --- a/packages/core/platform/bundles/paas-full.yaml +++ b/packages/core/platform/bundles/paas-full.yaml @@ -112,6 +112,12 @@ releases: disableTelemetry: true {{- end }} +- name: backup-controller + releaseName: backup-controller + chart: cozy-backup-controller + namespace: cozy-backup-controller + dependsOn: [cilium,kubeovn] + - name: lineage-controller-webhook releaseName: lineage-controller-webhook chart: cozy-lineage-controller-webhook diff --git a/packages/core/platform/bundles/paas-hosted.yaml b/packages/core/platform/bundles/paas-hosted.yaml index 560578c7..c85bcb7b 100644 --- a/packages/core/platform/bundles/paas-hosted.yaml +++ b/packages/core/platform/bundles/paas-hosted.yaml @@ -56,6 +56,11 @@ releases: disableTelemetry: true {{- end }} +- name: backup-controller + releaseName: backup-controller + chart: cozy-backup-controller + namespace: cozy-backup-controller + - name: lineage-controller-webhook releaseName: lineage-controller-webhook chart: cozy-lineage-controller-webhook diff --git a/packages/system/backup-controller/Chart.yaml b/packages/system/backup-controller/Chart.yaml new file mode 100644 index 00000000..fd135712 --- /dev/null +++ b/packages/system/backup-controller/Chart.yaml @@ -0,0 +1,3 @@ +apiVersion: v2 +name: cozy-backup-controller +version: 0.0.0 # Placeholder, the actual version will be automatically set during the build process diff --git a/packages/system/backup-controller/Makefile b/packages/system/backup-controller/Makefile new file mode 100644 index 00000000..56d58f20 --- /dev/null +++ b/packages/system/backup-controller/Makefile @@ -0,0 +1,18 @@ +NAME=backup-controller +NAMESPACE=cozy-backup-controller + +include ../../../scripts/common-envs.mk +include ../../../scripts/package.mk + +image: image-backup-controller + +image-backup-controller: + docker buildx build -f images/backup-controller/Dockerfile ../../.. \ + --tag $(REGISTRY)/backup-controller:$(call settag,$(TAG)) \ + --cache-from type=registry,ref=$(REGISTRY)/backup-controller:latest \ + --cache-to type=inline \ + --metadata-file images/backup-controller.json \ + $(BUILDX_ARGS) + IMAGE="$(REGISTRY)/backup-controller:$(call settag,$(TAG))@$$(yq e '."containerimage.digest"' images/backup-controller.json -o json -r)" \ + yq -i '.backupController.image = strenv(IMAGE)' values.yaml + rm -f images/backup-controller.json diff --git a/packages/system/backup-controller/images/backup-controller/Dockerfile b/packages/system/backup-controller/images/backup-controller/Dockerfile new file mode 100644 index 00000000..cdf53c7b --- /dev/null +++ b/packages/system/backup-controller/images/backup-controller/Dockerfile @@ -0,0 +1,23 @@ +FROM golang:1.24-alpine AS builder + +ARG TARGETOS +ARG TARGETARCH + +WORKDIR /workspace + +COPY go.mod go.sum ./ +RUN GOOS=$TARGETOS GOARCH=$TARGETARCH go mod download + +COPY api api/ +COPY pkg pkg/ +COPY cmd cmd/ +COPY internal internal/ + +RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -ldflags="-extldflags=-static" -o /backup-controller cmd/backup-controller/main.go + +FROM scratch + +COPY --from=builder /backup-controller /backup-controller +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt + +ENTRYPOINT ["/backup-controller"] diff --git a/packages/system/backup-controller/templates/crds.yaml b/packages/system/backup-controller/templates/crds.yaml new file mode 100644 index 00000000..2cf3183b --- /dev/null +++ b/packages/system/backup-controller/templates/crds.yaml @@ -0,0 +1,4 @@ +{{- range $path, $_ := .Files.Glob "definitions/*" }} +--- +{{ $.Files.Get $path }} +{{- end }} diff --git a/packages/system/backup-controller/templates/deployment.yaml b/packages/system/backup-controller/templates/deployment.yaml new file mode 100644 index 00000000..a8c3d07e --- /dev/null +++ b/packages/system/backup-controller/templates/deployment.yaml @@ -0,0 +1,57 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: backup-controller + labels: + app: backup-controller +spec: + replicas: {{ .Values.backupController.replicas }} + selector: + matchLabels: + app: backup-controller + template: + metadata: + labels: + app: backup-controller + spec: + tolerations: + - key: "node-role.kubernetes.io/control-plane" + operator: "Exists" + effect: "NoSchedule" + - key: "node-role.kubernetes.io/master" + operator: "Exists" + effect: "NoSchedule" + serviceAccountName: backup-controller + containers: + - name: backup-controller + image: "{{ .Values.backupController.image }}" + args: + - --leader-elect + {{- if .Values.backupController.metrics.enable }} + - --metrics-bind-address={{ .Values.backupController.metrics.bindAddress }} + {{- end }} + {{- if .Values.backupController.debug }} + - --zap-log-level=debug + {{- else }} + - --zap-log-level=info + {{- end }} + ports: + - name: metrics + containerPort: {{ split ":" .Values.backupController.metrics.bindAddress | mustLast }} + - name: health + containerPort: 8081 + readinessProbe: + httpGet: + path: /readyz + port: health + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /healthz + port: health + initialDelaySeconds: 15 + periodSeconds: 20 + {{- with .Values.backupController.resources }} + resources: {{- . | toYaml | nindent 10 }} + {{- end }} diff --git a/packages/system/backup-controller/templates/rbac-bind.yaml b/packages/system/backup-controller/templates/rbac-bind.yaml new file mode 100644 index 00000000..cbe9b5b8 --- /dev/null +++ b/packages/system/backup-controller/templates/rbac-bind.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: backups.cozystack.io:core-controller +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: backups.cozystack.io:core-controller +subjects: +- kind: ServiceAccount + name: backup-controller + namespace: {{ .Release.Namespace }} diff --git a/packages/system/backup-controller/templates/rbac.yaml b/packages/system/backup-controller/templates/rbac.yaml new file mode 100644 index 00000000..71c4af69 --- /dev/null +++ b/packages/system/backup-controller/templates/rbac.yaml @@ -0,0 +1,11 @@ +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: backups.cozystack.io:core-controller +rules: +- apiGroups: ["backups.cozystack.io"] + resources: ["plans"] + verbs: ["get", "list", "watch"] +- apiGroups: ["backups.cozystack.io"] + resources: ["backupjobs"] + verbs: ["create", "get", "list", "watch"] diff --git a/packages/system/backup-controller/templates/sa.yaml b/packages/system/backup-controller/templates/sa.yaml new file mode 100644 index 00000000..4a180dd2 --- /dev/null +++ b/packages/system/backup-controller/templates/sa.yaml @@ -0,0 +1,4 @@ +kind: ServiceAccount +apiVersion: v1 +metadata: + name: backup-controller diff --git a/packages/system/backup-controller/values.yaml b/packages/system/backup-controller/values.yaml new file mode 100644 index 00000000..557c201a --- /dev/null +++ b/packages/system/backup-controller/values.yaml @@ -0,0 +1,14 @@ +backupController: + image: "" + replicas: 2 + debug: false + metrics: + enabled: true + bindAddress: ":8443" + resources: + requests: + cpu: 10m + memory: 64Mi + limits: + cpu: 500m + memory: 128Mi