Skip to content

Commit 37c355f

Browse files
androndoclaude
andcommitted
feat(backups): add RabbitMQ definitions backup via a dedicated strategy driver
RabbitMQ's cluster operator ships no backup CRD and RabbitMQ has no consistent online export of message payloads, so back up broker DEFINITIONS (vhosts, users, permissions, queues, exchanges, bindings, policies, parameters) over the management HTTP API and store them in the system bucket. Message payloads and PITR are out of scope - a full-data backup is a Velero volume snapshot, a separate strategy. Add a first-class strategy.backups.cozystack.io/Rabbitmq driver, following the Altinity pattern (a dedicated driver that renders a one-shot Job for an HTTP-API engine): - reconcileRabbitmq / reconcileRabbitmqRestore render the strategy's PodTemplateSpec (curl GET/POST /api/definitions, push/pull to S3 with --aws-sigv4) as a batch/v1.Job, for both in-place and to-copy restore. - A Go-read precondition gates each run on the RabbitmqCluster reporting AllReplicasReady (via the rabbitmqtypes partial type), surfacing a precise Ready=False wait instead of a Job that starts and fails. - The produced Backup records the object location on status.artifact.uri (rendered from spec.artifactURITemplate; the Backup CRD has no status subresource, so it persists on Create, as in the Etcd driver). Wire dispatch in both controllers + supportedBackupStrategyKinds, register rabbitmqtypes in the scheme, and grant read on rabbitmqclusters. Ship cozy-default-rabbitmq (a $bucketName-gated Rabbitmq strategy) and route apps.cozystack.io/RabbitMQ to it in the cozy-default BackupClass. The application chart is untouched. Add unit tests for the driver's pure helpers and the precondition gate, a runnable example, and an honest-roundtrip Chainsaw e2e that seeds a sentinel vhost, backs it up, drops it, and asserts it round-trips through object storage on both restore flows. The Job pod reuses the platform-migrations image (curl + ca-certificates) under a new rabbitmqBackupClientImage key, stamped in lockstep with chBackupClientImage so the repository stays pinned at one digest; a dedicated slim image is a follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Signed-off-by: Andrey Kolkov <[email protected]>
1 parent eef95f4 commit 37c355f

29 files changed

Lines changed: 10843 additions & 2 deletions
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Package v1alpha1 defines strategy.backups.cozystack.io API types.
3+
//
4+
// Group: strategy.backups.cozystack.io
5+
// Version: v1alpha1
6+
package v1alpha1
7+
8+
import (
9+
corev1 "k8s.io/api/core/v1"
10+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
11+
"k8s.io/apimachinery/pkg/runtime"
12+
)
13+
14+
func init() {
15+
SchemeBuilder.Register(func(s *runtime.Scheme) error {
16+
s.AddKnownTypes(GroupVersion,
17+
&Rabbitmq{},
18+
&RabbitmqList{},
19+
)
20+
return nil
21+
})
22+
}
23+
24+
const (
25+
RabbitmqStrategyKind = "Rabbitmq"
26+
)
27+
28+
// +kubebuilder:object:root=true
29+
// +kubebuilder:subresource:status
30+
// +kubebuilder:resource:scope=Cluster
31+
32+
// Rabbitmq defines a backup strategy that exports and imports RabbitMQ broker
33+
// DEFINITIONS (vhosts, users, permissions, queues, exchanges, bindings,
34+
// policies, parameters) via the management HTTP API, run as a one-shot
35+
// batch/v1.Job per BackupJob / RestoreJob. Message payloads are out of scope -
36+
// RabbitMQ has no consistent online export of them, so a full-data backup is a
37+
// Velero volume snapshot, not this strategy. The driver gates on the target
38+
// RabbitmqCluster being ready, wraps the templated PodTemplateSpec in a Job,
39+
// and records the stored object on the Cozystack Backup artifact.
40+
type Rabbitmq struct {
41+
metav1.TypeMeta `json:",inline"`
42+
metav1.ObjectMeta `json:"metadata,omitempty"`
43+
44+
Spec RabbitmqSpec `json:"spec,omitempty"`
45+
Status RabbitmqStatus `json:"status,omitempty"`
46+
}
47+
48+
// +kubebuilder:object:root=true
49+
50+
// RabbitmqList contains a list of Rabbitmq backup strategies.
51+
type RabbitmqList struct {
52+
metav1.TypeMeta `json:",inline"`
53+
metav1.ListMeta `json:"metadata,omitempty"`
54+
Items []Rabbitmq `json:"items"`
55+
}
56+
57+
// RabbitmqSpec specifies the desired Rabbitmq-driven backup strategy.
58+
type RabbitmqSpec struct {
59+
// Template is the PodTemplateSpec the driver wraps in a batch/v1.Job for
60+
// both backup and restore runs. Helm-style Go templates are supported in
61+
// every string field. The available context is:
62+
// .Application - the application object (apps.cozystack.io/RabbitMQ)
63+
// .Release.Name, .Release.Namespace - shorthand for the application
64+
// name/namespace
65+
// .Mode - "backup" or "restore"
66+
// .Parameters - map[string]string from the matched BackupClassStrategy
67+
// .Backup - the source Backup metadata (only set on restore);
68+
// exposes .Name, .Namespace, and
69+
// .ApplicationRef.{APIGroup,Kind,Name} so a to-copy
70+
// restore can address the source release's object key.
71+
Template corev1.PodTemplateSpec `json:"template"`
72+
73+
// ArtifactURITemplate, when set, is rendered against the same context as
74+
// Template on a successful backup and recorded on the produced Backup's
75+
// status.artifact.uri, so the stored object's location is introspectable
76+
// from the Backup rather than living only inside the pod script. Ignored on
77+
// restore; omitted leaves status.artifact unset.
78+
// +optional
79+
ArtifactURITemplate string `json:"artifactURITemplate,omitempty"`
80+
}
81+
82+
// RabbitmqStatus reports observed state for the strategy CR.
83+
type RabbitmqStatus struct {
84+
// Conditions holds the latest available observations.
85+
// +optional
86+
// +listType=map
87+
// +listMapKey=type
88+
Conditions []metav1.Condition `json:"conditions,omitempty"`
89+
}

‎api/backups/strategy/v1alpha1/zz_generated.deepcopy.go‎

Lines changed: 97 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎cmd/backupstrategy-controller/main.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import (
5252
"github.com/cozystack/cozystack/internal/backupcontroller/mongodbapp"
5353
"github.com/cozystack/cozystack/internal/backupcontroller/postgresapp"
5454
"github.com/cozystack/cozystack/internal/backupcontroller/psmdbtypes"
55+
"github.com/cozystack/cozystack/internal/backupcontroller/rabbitmqtypes"
5556
velerov1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
5657
// +kubebuilder:scaffold:imports
5758
)
@@ -77,6 +78,7 @@ func init() {
7778
utilruntime.Must(foundationdbapp.AddToScheme(scheme))
7879
utilruntime.Must(etcdtypes.AddToScheme(scheme))
7980
utilruntime.Must(etcdapp.AddToScheme(scheme))
81+
utilruntime.Must(rabbitmqtypes.AddToScheme(scheme))
8082
// +kubebuilder:scaffold:scheme
8183
}
8284

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
#!/bin/bash
2+
# Shared helpers for the RabbitMQ definitions backup/restore demo.
3+
# Source this file in other scripts: source "$(dirname "$0")/00-helpers.sh"
4+
5+
export RED='\033[0;31m'
6+
export GREEN='\033[0;32m'
7+
export YELLOW='\033[1;33m'
8+
export BLUE='\033[0;34m'
9+
export MAGENTA='\033[0;35m'
10+
export CYAN='\033[0;36m'
11+
export WHITE='\033[1;37m'
12+
export NC='\033[0m'
13+
export BOLD='\033[1m'
14+
15+
# Default settings (override via environment).
16+
export NAMESPACE="${NAMESPACE:-tenant-root}"
17+
export RABBITMQ_SRC_NAME="${RABBITMQ_SRC_NAME:-rabbitmq-src}"
18+
export RABBITMQ_TARGET_NAME="${RABBITMQ_TARGET_NAME:-rabbitmq-target}"
19+
# The rabbitmq-rd ApplicationDefinition renders the HelmRelease with
20+
# releaseName = "rabbitmq-" + appName (release.prefix in
21+
# packages/system/rabbitmq-rd/cozyrds/rabbitmq.yaml), and the chart sets the
22+
# rabbitmq.com/RabbitmqCluster metadata.name to .Release.Name, so the
23+
# operator-side CR (its Service, default-user Secret and server pods) is
24+
# rabbitmq-<app>.
25+
export RABBITMQ_SRC_CR="rabbitmq-${RABBITMQ_SRC_NAME}"
26+
export RABBITMQ_TARGET_CR="rabbitmq-${RABBITMQ_TARGET_NAME}"
27+
# cozy-default routes RabbitMQ to the shipped cozy-default-rabbitmq Job
28+
# strategy, which exports broker DEFINITIONS to the platform cozy-backups
29+
# bucket. No demo Bucket and no app backup block are needed: the strategy
30+
# carries the S3 coordinates and the controller projects cozy-backups-creds
31+
# into this namespace before each run.
32+
export BACKUPCLASS_NAME="${BACKUPCLASS_NAME:-cozy-default}"
33+
export BACKUPJOB_NAME="${BACKUPJOB_NAME:-rabbitmq-src-adhoc}"
34+
export RESTOREJOB_INPLACE_NAME="${RESTOREJOB_INPLACE_NAME:-rabbitmq-src-in-place}"
35+
export RESTOREJOB_TOCOPY_NAME="${RESTOREJOB_TOCOPY_NAME:-rabbitmq-src-to-rabbitmq-target}"
36+
export PLAN_NAME="${PLAN_NAME:-rabbitmq-src-daily}"
37+
38+
log_info() { echo -e "${BLUE}i${NC} $*" >&2; }
39+
log_success() { echo -e "${GREEN}OK${NC} $*" >&2; }
40+
log_warning() { echo -e "${YELLOW}!${NC} $*" >&2; }
41+
log_error() { echo -e "${RED}x${NC} $*" >&2; }
42+
log_step() { echo -e "\n${MAGENTA}${BOLD}> $*${NC}" >&2; }
43+
log_substep() { echo -e "${CYAN} -> $*${NC}" >&2; }
44+
45+
print_header() {
46+
echo -e "\n${MAGENTA}${BOLD}== $1 ==${NC}\n" >&2
47+
}
48+
49+
# Wait until a JSONPath value on a resource matches the desired string.
50+
# Optional 7th arg is a TERMINAL failure value: once the field reaches it the
51+
# wait returns 1 immediately instead of polling to the timeout. BackupJob and
52+
# RestoreJob settle on a terminal phase=Failed that never becomes Succeeded, so
53+
# failing fast on it keeps wall-clock in reach.
54+
wait_for_field() {
55+
local resource_type="$1"
56+
local resource_name="$2"
57+
local jsonpath="$3"
58+
local desired="$4"
59+
local namespace="${5:-}"
60+
local timeout="${6:-300}"
61+
local fail_value="${7:-}"
62+
63+
log_substep "Waiting for $resource_type/$resource_name $jsonpath to become '$desired'..."
64+
local elapsed=0
65+
local ns_flag=()
66+
[[ -n "$namespace" ]] && ns_flag=(-n "$namespace")
67+
68+
while true; do
69+
local current
70+
current=$(kubectl get "$resource_type" "$resource_name" "${ns_flag[@]}" -o jsonpath="$jsonpath" 2>/dev/null || true)
71+
if [[ "$current" == "$desired" ]]; then
72+
log_success "$resource_type/$resource_name reached '$desired'"
73+
return 0
74+
fi
75+
if [[ -n "$fail_value" && "$current" == "$fail_value" ]]; then
76+
log_error "$resource_type/$resource_name reached terminal '$current' (expected '$desired')"
77+
return 1
78+
fi
79+
if [[ $elapsed -ge $timeout ]]; then
80+
log_error "Timeout waiting for $resource_type/$resource_name (current: '$current', expected: '$desired')"
81+
return 1
82+
fi
83+
sleep 5
84+
elapsed=$((elapsed + 5))
85+
done
86+
}
87+
88+
# Wait for a HelmRelease to become Ready, with an existence backstop (the apps
89+
# controller creates the HR asynchronously) and a fail-fast on Stalled=True.
90+
wait_hr_ready() {
91+
local name="$1"
92+
local timeout="${2:-300}"
93+
local elapsed=0
94+
95+
log_substep "Waiting for HelmRelease/$name to become Ready..."
96+
while true; do
97+
if kubectl -n "$NAMESPACE" get hr "$name" >/dev/null 2>&1; then
98+
local ready stalled
99+
ready=$(kubectl -n "$NAMESPACE" get hr "$name" \
100+
-o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || true)
101+
if [[ "$ready" == "True" ]]; then
102+
log_success "HelmRelease/$name is Ready"
103+
return 0
104+
fi
105+
stalled=$(kubectl -n "$NAMESPACE" get hr "$name" \
106+
-o jsonpath='{.status.conditions[?(@.type=="Stalled")].status}' 2>/dev/null || true)
107+
if [[ "$stalled" == "True" ]]; then
108+
log_error "HelmRelease/$name is Stalled (terminal): $(kubectl -n "$NAMESPACE" get hr "$name" \
109+
-o jsonpath='{.status.conditions[?(@.type=="Ready")].message}' 2>/dev/null)"
110+
return 1
111+
fi
112+
fi
113+
if [[ $elapsed -ge $timeout ]]; then
114+
log_error "Timeout waiting for HelmRelease/$name to become Ready:"
115+
kubectl -n "$NAMESPACE" get hr "$name" \
116+
-o jsonpath='{.status.conditions[?(@.type=="Ready")].message}' >&2 2>/dev/null || true
117+
return 1
118+
fi
119+
sleep 5
120+
elapsed=$((elapsed + 5))
121+
done
122+
}
123+
124+
# Wait until the rabbitmq.com/RabbitmqCluster CR reports AllReplicasReady=True
125+
# (the cluster-operator's own readiness signal — every server pod is up and the
126+
# management API is serving). No fail-fast value: the condition can be briefly
127+
# absent or False during rolling startup before it converges.
128+
wait_rabbitmq_ready() {
129+
local cr="$1" timeout="${2:-600}"
130+
wait_for_field rabbitmqclusters.rabbitmq.com "$cr" \
131+
'{.status.conditions[?(@.type=="AllReplicasReady")].status}' True "$NAMESPACE" "$timeout"
132+
}
133+
134+
# Run rabbitmqctl inside a RabbitmqCluster's first server pod. rabbitmqctl talks
135+
# to the local node over the Erlang dist port and needs no HTTP credentials, so
136+
# it is the simplest way to mutate and read the DEFINITIONS (vhosts, policies)
137+
# this demo uses as its round-trip sentinel. Args: <cr-name> <rabbitmqctl args...>
138+
rabbitmqctl_exec() {
139+
local cr="$1"; shift
140+
kubectl -n "$NAMESPACE" exec "${cr}-server-0" -c rabbitmq -- rabbitmqctl "$@"
141+
}
142+
143+
# Seed the sentinel: a vhost plus a policy on it. Both are broker DEFINITIONS
144+
# captured by /api/definitions, so their survival through S3 proves the backup
145+
# round-tripped the definitions (not merely that a Job ran). Args: <cr> <token>
146+
rabbitmq_seed_sentinel() {
147+
local cr="$1" token="$2"
148+
rabbitmqctl_exec "$cr" add_vhost "$token" >/dev/null
149+
rabbitmqctl_exec "$cr" set_policy -p "$token" sentinel-policy '.*' \
150+
'{"max-length":1000}' --apply-to queues >/dev/null
151+
}
152+
153+
# True when the sentinel vhost exists on the broker. Args: <cr> <token>
154+
rabbitmq_has_sentinel() {
155+
local cr="$1" token="$2"
156+
rabbitmqctl_exec "$cr" list_vhosts name 2>/dev/null | grep -qxF "$token"
157+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Source RabbitMQ application. Note there is no `backup:` block: this demo uses
2+
# the platform cozy-default flow, where the cozy-default-rabbitmq strategy
3+
# carries the S3 coordinates and the controller projects cozy-backups-creds into
4+
# this namespace. The application chart is unmodified.
5+
apiVersion: apps.cozystack.io/v1alpha1
6+
kind: RabbitMQ
7+
metadata:
8+
name: rabbitmq-src
9+
spec:
10+
replicas: 1
11+
size: 1Gi
12+
resourcesPreset: c1.small
13+
version: v4.2
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Ad-hoc backup of the source RabbitMQ, routed through the platform cozy-default
2+
# BackupClass. The driver renders the cozy-default-rabbitmq strategy, which
3+
# exports broker definitions to the system bucket. On success the BackupJob
4+
# reports status.backupRef.name — the Backup this demo restores from.
5+
apiVersion: backups.cozystack.io/v1alpha1
6+
kind: BackupJob
7+
metadata:
8+
name: rabbitmq-src-adhoc
9+
spec:
10+
applicationRef:
11+
apiGroup: apps.cozystack.io
12+
kind: RabbitMQ
13+
name: rabbitmq-src
14+
backupClassName: cozy-default

0 commit comments

Comments
 (0)