Skip to content

Commit 25af46d

Browse files
authored
feat: add S3 endpoint override for S3-compatible storage (#117)
1 parent 0e76c87 commit 25af46d

16 files changed

Lines changed: 707 additions & 191 deletions

File tree

.golangci.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ linters:
292292
rules:
293293
- linters:
294294
- lll
295-
source: "^//\\+kubebuilder.*"
295+
source: "^\\s*//\\+kubebuilder.*"
296296
- linters:
297297
- lll
298298
source: ".*https.*" # URLs are long 🤷

api/v1alpha1/hostedcontrolplane.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,30 @@ func (a *Audit) ModeOrDefault() string {
5252
return ptr.Deref(a.Mode, defaultMode)
5353
}
5454

55+
func (ebs *ETCDBackupSecret) HostKeyOrDefault() string {
56+
defaultKey := "host"
57+
if ebs == nil {
58+
return defaultKey
59+
}
60+
return ptr.Deref(ebs.HostKey, defaultKey)
61+
}
62+
63+
func (ebs *ETCDBackupSecret) RegionKeyOrDefault() string {
64+
defaultKey := "region"
65+
if ebs == nil {
66+
return defaultKey
67+
}
68+
return ptr.Deref(ebs.RegionKey, defaultKey)
69+
}
70+
71+
func (ebs *ETCDBackupSecret) BucketKeyOrDefault() string {
72+
defaultKey := "bucket"
73+
if ebs == nil {
74+
return defaultKey
75+
}
76+
return ptr.Deref(ebs.BucketKey, defaultKey)
77+
}
78+
5579
func (ebs *ETCDBackupSecret) AccessKeyIDKeyOrDefault() string {
5680
defaultKey := "accessKeyID"
5781
if ebs == nil {

api/v1alpha1/hostedcontrolplane_types.go

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -145,15 +145,20 @@ type ETCDComponent struct {
145145
Backup *ETCDBackup `json:"backup,omitempty"`
146146
}
147147

148+
// ETCDBackup configures periodic etcd snapshots uploaded to S3-compatible storage.
149+
// Backup retention is not managed by the operator; configure lifecycle rules on the
150+
// bucket itself (e.g. S3 Lifecycle Rules) to expire old snapshots automatically.
148151
type ETCDBackup struct {
152+
// Schedule is a standard 5-field cron expression (minute hour day-of-month month day-of-week).
153+
// See https://en.wikipedia.org/wiki/Cron#Overview, no `*/5` syntax, no lists and no ranges,
154+
// only single values
155+
// Additionally, @daily is supported; it is spread across clusters around midnight
156+
// to avoid simultaneous backups.
149157
//+kubebuilder:validation:Required
158+
//+kubebuilder:validation:Pattern=`^(@daily|(\*|[0-9]|[1-5][0-9]) (\*|[0-9]|1[0-9]|2[0-3]) (\*|[1-9]|[1-2][0-9]|3[0-1]) (\*|[1-9]|1[0-2]) (\*|[0-6]))$`
150159
Schedule string `json:"schedule"`
151160
//+kubebuilder:validation:Required
152-
Bucket string `json:"bucket"`
153-
//+kubebuilder:validation:Required
154161
Secret ETCDBackupSecret `json:"secret"`
155-
//+kubebuilder:validation:Optional
156-
Region string `json:"region,omitempty"`
157162
}
158163

159164
type ETCDBackupSecret struct {
@@ -165,6 +170,18 @@ type ETCDBackupSecret struct {
165170
AccessKeyIDKey *string `json:"accessKeyIDKey,omitempty"`
166171
//+kubebuilder:validation:Optional
167172
SecretAccessKeyKey *string `json:"secretAccessKeyKey,omitempty"`
173+
// HostKey is the key in the referenced Secret whose value contains the S3 endpoint host.
174+
// The Secret value must be a bare host name or host:port, for example
175+
// `s3.example.com` or `s3.example.com:9000`. Do not include a URL scheme
176+
// such as `http://` or `https://`, and do not include any path component.
177+
//+kubebuilder:validation:Optional
178+
HostKey *string `json:"hostKey,omitempty"`
179+
//+kubebuilder:validation:Optional
180+
RegionKey *string `json:"regionKey,omitempty"`
181+
// The bucket will be split by `/` and only the first part is the bucket,
182+
// the rest is a prefix for the file
183+
//+kubebuilder:validation:Optional
184+
BucketKey *string `json:"bucketKey,omitempty"`
168185
}
169186

170187
type APIServerPod struct {

api/v1alpha1/zz_generated.deepcopy.go

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

go.mod

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ go 1.25.7
44

55
require (
66
github.com/aws/aws-sdk-go-v2 v1.41.5
7-
github.com/aws/aws-sdk-go-v2/config v1.32.7
8-
github.com/aws/aws-sdk-go-v2/credentials v1.19.7
9-
github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.1.2
7+
github.com/aws/aws-sdk-go-v2/config v1.32.14
8+
github.com/aws/aws-sdk-go-v2/credentials v1.19.14
9+
github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.1.15
1010
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0
11+
github.com/aws/smithy-go/tracing/smithyoteltracing v1.0.13
1112
github.com/blang/semver/v4 v4.0.0
1213
github.com/caarlos0/env/v6 v6.10.1
1314
github.com/cert-manager/cert-manager v1.19.3
@@ -55,20 +56,20 @@ require (
5556
github.com/NYTimes/gziphandler v1.1.1 // indirect
5657
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
5758
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
58-
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
59+
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
5960
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
6061
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
61-
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
62+
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
6263
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
6364
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
6465
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
6566
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
6667
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
67-
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
68-
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect
69-
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect
70-
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
71-
github.com/aws/smithy-go v1.24.2 // indirect
68+
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
69+
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect
70+
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
71+
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
72+
github.com/aws/smithy-go v1.24.3 // indirect
7273
github.com/beorn7/perks v1.0.1 // indirect
7374
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
7475
github.com/cespare/xxhash/v2 v2.3.0 // indirect

go.sum

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -30,20 +30,20 @@ github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV
3030
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
3131
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
3232
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
33-
github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY=
34-
github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY=
35-
github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8=
36-
github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw=
37-
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=
38-
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA=
39-
github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.1.2 h1:1q8/WwEqZnM/vO4q1gx2g7lHYmyN+o4P7G6EW4zKbRQ=
40-
github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.1.2/go.mod h1:owKRexW+Ir5ACD2UTesmjkQ+w7mcmknLNfwOiKfVLTg=
33+
github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI=
34+
github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo=
35+
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI=
36+
github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w=
37+
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g=
38+
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI=
39+
github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.1.15 h1:92MfpwB6KjsPIEq9g3DniRPxOe92ew5hUz1h8W8cX7E=
40+
github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.1.15/go.mod h1:7O129SmOn4acM++3oVfTLAeHmNOsj0y7AA7zmbgnGOk=
4141
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
4242
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
4343
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
4444
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
45-
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
46-
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
45+
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
46+
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
4747
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
4848
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
4949
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
@@ -56,16 +56,18 @@ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWUR
5656
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
5757
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 h1:hlSuz394kV0vhv9drL5lhuEFbEOEP1VyQpy15qWh1Pk=
5858
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
59-
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=
60-
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M=
61-
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk=
62-
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw=
63-
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds=
64-
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo=
65-
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=
66-
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
67-
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
68-
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
59+
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
60+
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI=
61+
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM=
62+
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM=
63+
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys=
64+
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w=
65+
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U=
66+
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
67+
github.com/aws/smithy-go v1.24.3 h1:XgOAaUgx+HhVBoP4v8n6HCQoTRDhoMghKqw4LNHsDNg=
68+
github.com/aws/smithy-go v1.24.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
69+
github.com/aws/smithy-go/tracing/smithyoteltracing v1.0.13 h1:Okz+/GoqkQlPEEzHTa69fOshx6lYjZIcMxR9RpJPTfs=
70+
github.com/aws/smithy-go/tracing/smithyoteltracing v1.0.13/go.mod h1:zTATuy5kyRLhWPtp33dLfOIRF8eqq091eEJtksR8QKk=
6971
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
7072
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
7173
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=

pkg/operator/operator.go

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -204,13 +204,14 @@ func setupControllers(
204204
) (*alias.WorkloadClusterClient, ciliumclient.Interface, error) {
205205
return workload.GetWorkloadClusterClient(
206206
ctx,
207+
tracingWrapper,
207208
managementClusterClient,
208209
cluster,
209-
tracingWrapper,
210210
controllerUsername,
211211
)
212212
},
213-
func(ctx context.Context,
213+
func(
214+
ctx context.Context,
214215
managementClusterClient *alias.ManagementClusterClient,
215216
hostedControlPlane *v1alpha1.HostedControlPlane,
216217
cluster *capiv2.Cluster,
@@ -225,7 +226,20 @@ func setupControllers(
225226
serverPort,
226227
)
227228
},
228-
s3_client.NewS3Client,
229+
func(
230+
ctx context.Context,
231+
managementClusterClient *alias.ManagementClusterClient,
232+
hostedControlPlane *v1alpha1.HostedControlPlane,
233+
cluster *capiv2.Cluster,
234+
) (s3_client.S3Client, error) {
235+
return s3_client.NewS3Client(
236+
ctx,
237+
tracerProvider,
238+
managementClusterClient,
239+
hostedControlPlane,
240+
cluster,
241+
)
242+
},
229243
mgr.GetEventRecorder(hostedControlPlaneControllerName),
230244
controllerNamespace,
231245
reconcileFilter,

pkg/reconcilers/apiserverresources/apiserverresources.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -566,11 +566,10 @@ func (arr *apiServerResourcesReconciler) extractAdditionalVolumesAndMounts(
566566
volume := corev1ac.Volume().
567567
WithName(name)
568568
if mount.Secret != nil {
569-
items := mount.Secret.Items
570569
volume = volume.WithSecret(corev1ac.SecretVolumeSource().
571570
WithSecretName(mount.Secret.SecretName).
572571
WithOptional(false).
573-
WithItems(convertItems(items)...),
572+
WithItems(convertItems(mount.Secret.Items)...),
574573
)
575574
}
576575
if mount.ConfigMap != nil {
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package etcd_cluster
2+
3+
import (
4+
"fmt"
5+
"hash/fnv"
6+
7+
"github.com/robfig/cron/v3"
8+
)
9+
10+
// resolveBackupSchedule converts a schedule string to a concrete 5-field cron expression.
11+
// Vague terms like "@daily" are expanded to a deterministic time within an 8-hour window
12+
// around midnight (20:00–03:59), spread by hashing the cluster's namespace and name so that
13+
// multiple clusters do not all back up at the same moment.
14+
func resolveBackupSchedule(schedule string, namespace string, name string) (cron.Schedule, error) {
15+
resolvedSchedule := schedule
16+
if resolvedSchedule == "@daily" {
17+
resolvedSchedule = dailyScheduleFor(namespace, name)
18+
}
19+
20+
if cronSchedule, err := cron.ParseStandard(resolvedSchedule); err != nil {
21+
return nil, fmt.Errorf("failed to parse schedule: %w", err)
22+
} else {
23+
return cronSchedule, nil
24+
}
25+
}
26+
27+
// dailyScheduleFor returns a deterministic cron expression within the 8-hour window
28+
// 20:00–03:59 (480 minutes centered on midnight) for the given cluster identity.
29+
func dailyScheduleFor(namespace string, name string) string {
30+
h := fnv.New32a()
31+
_, _ = fmt.Fprintf(h, "%s/%s", namespace, name)
32+
offset := int(h.Sum32() % 480) // 0–479 minutes into the window
33+
totalMinutes := 20*60 + offset // 1200 = 20:00; wraps through midnight up to 03:59
34+
hour := (totalMinutes / 60) % 24
35+
minute := totalMinutes % 60
36+
return fmt.Sprintf("%d %d * * *", minute, hour)
37+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package etcd_cluster
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
"time"
7+
8+
. "github.com/onsi/gomega"
9+
)
10+
11+
func TestDailyScheduleFor(t *testing.T) {
12+
t.Run("result is within the 20:00–03:59 window", func(t *testing.T) {
13+
g := NewWithT(t)
14+
identities := [][2]string{
15+
{"ns-a", "cluster-1"},
16+
{"ns-b", "cluster-2"},
17+
{"production", "control-plane"},
18+
{"kube-system", "etcd-backup"},
19+
{"", "no-namespace"},
20+
}
21+
for _, id := range identities {
22+
expr := dailyScheduleFor(id[0], id[1])
23+
24+
var minute, hour int
25+
_, parseErr := fmt.Sscanf(expr, "%d %d * * *", &minute, &hour)
26+
g.Expect(parseErr).NotTo(HaveOccurred(), "expression %q could not be parsed", expr)
27+
g.Expect(minute).To(BeNumerically(">=", 0))
28+
g.Expect(minute).To(BeNumerically("<=", 59))
29+
// Hour must be within the 8-hour window: 20, 21, 22, 23, 0, 1, 2, 3
30+
validHours := []int{20, 21, 22, 23, 0, 1, 2, 3}
31+
g.Expect(validHours).
32+
To(ContainElement(hour), "hour %d is outside the 8-hour window for %s/%s", hour, id[0], id[1])
33+
}
34+
})
35+
36+
t.Run("is deterministic for the same cluster", func(t *testing.T) {
37+
g := NewWithT(t)
38+
g.Expect(dailyScheduleFor("default", "my-cluster")).To(Equal(dailyScheduleFor("default", "my-cluster")))
39+
})
40+
41+
t.Run("produces different schedules for different clusters", func(t *testing.T) {
42+
g := NewWithT(t)
43+
g.Expect(dailyScheduleFor("ns", "cluster-a")).NotTo(Equal(dailyScheduleFor("ns", "cluster-b")))
44+
})
45+
}
46+
47+
func TestResolveBackupSchedule(t *testing.T) {
48+
t.Run("@daily returns a usable schedule within the midnight window", func(t *testing.T) {
49+
g := NewWithT(t)
50+
schedule, err := resolveBackupSchedule("@daily", "default", "my-cluster")
51+
g.Expect(err).NotTo(HaveOccurred())
52+
53+
// The resolved schedule must fire exactly once per day; its next-run from a known
54+
// base should be within 24h and land in the 20:00–03:59 window.
55+
base := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
56+
next := schedule.Next(base)
57+
g.Expect(next).NotTo(BeZero())
58+
hour := next.Hour()
59+
validHours := []int{20, 21, 22, 23, 0, 1, 2, 3}
60+
g.Expect(validHours).To(ContainElement(hour))
61+
})
62+
63+
t.Run("@daily is deterministic for the same cluster", func(t *testing.T) {
64+
g := NewWithT(t)
65+
base := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
66+
s1, _ := resolveBackupSchedule("@daily", "default", "my-cluster")
67+
s2, _ := resolveBackupSchedule("@daily", "default", "my-cluster")
68+
g.Expect(s1.Next(base)).To(Equal(s2.Next(base)))
69+
})
70+
71+
t.Run("@daily produces different next-run times for different clusters", func(t *testing.T) {
72+
g := NewWithT(t)
73+
base := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
74+
a, _ := resolveBackupSchedule("@daily", "ns", "cluster-a")
75+
b, _ := resolveBackupSchedule("@daily", "ns", "cluster-b")
76+
g.Expect(a.Next(base)).NotTo(Equal(b.Next(base)))
77+
})
78+
79+
t.Run("standard cron parses successfully", func(t *testing.T) {
80+
g := NewWithT(t)
81+
schedule, err := resolveBackupSchedule("0 2 * * *", "ns", "cluster")
82+
g.Expect(err).NotTo(HaveOccurred())
83+
base := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
84+
g.Expect(schedule.Next(base).Hour()).To(Equal(2))
85+
})
86+
87+
t.Run("invalid schedule returns an error", func(t *testing.T) {
88+
g := NewWithT(t)
89+
_, err := resolveBackupSchedule("invalid cron", "ns", "cluster")
90+
g.Expect(err).To(HaveOccurred())
91+
})
92+
}

0 commit comments

Comments
 (0)