Skip to content

Commit 9906b3b

Browse files
committed
feat: Add support for environment variable secrets in cleanup jobs
Signed-off-by: Magnus Ullberg <magnus@ullberg.us>
1 parent 21d993b commit 9906b3b

8 files changed

Lines changed: 220 additions & 12 deletions

File tree

Makefile

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,12 @@ run: build ## Run the application locally
5454
./$(BUILD_DIR)/$(BINARY_NAME) \
5555
-group startpunkt.ullberg.us \
5656
-kind Application \
57-
-version v1alpha2 \
57+
-version v1alpha4 \
5858
-leader-elect \
5959
-leader-elect-namespace default \
60-
-opt-in-label-key "object-lease-controller.ullberg.io/enabled" \
61-
-opt-in-label-value true
60+
# -opt-in-label-key "object-lease-controller.ullberg.io/enabled" \
61+
# -opt-in-label-value true \
62+
-zap-log-level debug
6263

6364
# =============================================================================
6465
# Docker Targets - Main Controller

cmd/main.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const (
4242
AnnJobTimeout = "object-lease-controller.ullberg.io/job-timeout"
4343
AnnJobTTL = "object-lease-controller.ullberg.io/job-ttl"
4444
AnnJobBackoffLimit = "object-lease-controller.ullberg.io/job-backoff-limit"
45+
AnnJobEnvSecrets = "object-lease-controller.ullberg.io/job-env-secrets"
4546
)
4647

4748
// ParseParams holds runtime configuration parsed from flags and environment.
@@ -67,10 +68,19 @@ var statFn = os.Stat
6768
var readFileFn = os.ReadFile
6869

6970
func main() {
70-
ctrl.SetLogger(zap.New())
71+
// Bind zap logging flags (e.g., -zap-log-level) to the global flag set
72+
// so callers (and the Makefile) can adjust verbosity. Don't set the
73+
// logger until after flags are parsed so the selected level is applied.
74+
var zapOpts zap.Options
75+
zapOpts.BindFlags(flag.CommandLine)
7176

7277
params := parseParameters()
7378

79+
// Set logger using the parsed zap options (this reads values parsed by
80+
// parseParameters which calls flag.Parse()). This allows callers to pass
81+
// flags like -zap-log-level=debug to control verbosity.
82+
ctrl.SetLogger(zap.New(zap.UseFlagOptions(&zapOpts)))
83+
7484
enableLeaderElection, leaderElectionNamespace, errE := parseLeaderElectionConfig(params.LeaderElectionEnabled, params.LeaderElectionNamespace)
7585
if errE != nil {
7686
fmt.Printf("%v\n", errE)
@@ -310,6 +320,7 @@ func newLeaseWatcher(mgr ctrl.Manager, gvk schema.GroupVersionKind, leaderElecti
310320
JobTimeout: AnnJobTimeout,
311321
JobTTL: AnnJobTTL,
312322
JobBackoffLimit: AnnJobBackoffLimit,
323+
JobEnvSecrets: AnnJobEnvSecrets,
313324
},
314325
Metrics: ometrics.NewLeaseMetrics(gvk),
315326
}

examples/cleanup/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ This directory contains example configurations for using cleanup jobs with the o
2222
| `object-lease-controller.ullberg.io/on-delete-job` | Yes (only if cleanup is needed) | - | ConfigMap reference in format `configmap-name/script-key` |
2323
| `object-lease-controller.ullberg.io/job-service-account` | No | `default` | ServiceAccount to run the Job as |
2424
| `object-lease-controller.ullberg.io/job-image` | No | `bitnami/kubectl:latest` | Container image for running the script |
25+
| `object-lease-controller.ullberg.io/job-env-secrets` | No | - | Comma-separated list of Secret names to mount as environment variables |
2526
| `object-lease-controller.ullberg.io/job-wait` | No | `false` | Wait for Job completion before deleting object |
2627
| `object-lease-controller.ullberg.io/job-timeout` | No | `5m` | Maximum time to wait for Job completion |
2728
| `object-lease-controller.ullberg.io/job-ttl` | No | `300` | TTL in seconds for Job cleanup |

examples/cleanup/backup-to-s3.yaml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,12 @@ metadata:
4646
namespace: demo
4747

4848
---
49-
# Secret containing AWS credentials (linked to ServiceAccount)
49+
# Secret containing AWS credentials
5050
apiVersion: v1
5151
kind: Secret
5252
metadata:
5353
name: aws-credentials
5454
namespace: demo
55-
annotations:
56-
kubernetes.io/service-account.name: backup-sa
5755
type: Opaque
5856
stringData:
5957
AWS_ACCESS_KEY_ID: "YOUR_AWS_ACCESS_KEY_ID"
@@ -106,6 +104,7 @@ metadata:
106104
object-lease-controller.ullberg.io/on-delete-job: "cleanup-scripts/backup-to-s3.sh"
107105
object-lease-controller.ullberg.io/job-image: "amazon/aws-cli:latest"
108106
object-lease-controller.ullberg.io/job-service-account: "backup-sa"
107+
object-lease-controller.ullberg.io/job-env-secrets: "aws-credentials"
109108
object-lease-controller.ullberg.io/job-wait: "true"
110109
object-lease-controller.ullberg.io/job-timeout: "10m"
111110
object-lease-controller.ullberg.io/job-ttl: "600"

examples/cleanup/notify-webhook.yaml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,12 @@ metadata:
6767
namespace: demo
6868

6969
---
70-
# Secret containing webhook token (linked to ServiceAccount)
70+
# Secret containing webhook token
7171
apiVersion: v1
7272
kind: Secret
7373
metadata:
7474
name: webhook-token
7575
namespace: demo
76-
annotations:
77-
kubernetes.io/service-account.name: webhook-sa
7876
type: Opaque
7977
stringData:
8078
WEBHOOK_TOKEN: "your-webhook-token-here"
@@ -95,6 +93,7 @@ metadata:
9593
object-lease-controller.ullberg.io/on-delete-job: "webhook-scripts/notify-webhook.sh"
9694
object-lease-controller.ullberg.io/job-image: "curlimages/curl:latest"
9795
object-lease-controller.ullberg.io/job-service-account: "webhook-sa"
96+
object-lease-controller.ullberg.io/job-env-secrets: "webhook-token"
9897
object-lease-controller.ullberg.io/job-wait: "false"
9998
object-lease-controller.ullberg.io/job-timeout: "2m"
10099
object-lease-controller.ullberg.io/job-ttl: "300"

pkg/controllers/lease_controller.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ type Annotations struct {
4949
JobTimeout string
5050
JobTTL string
5151
JobBackoffLimit string
52+
JobEnvSecrets string
5253
}
5354

5455
var (
@@ -253,6 +254,7 @@ func (r *LeaseWatcher) handleExpired(ctx context.Context, obj *unstructured.Unst
253254
"JobTimeout": r.Annotations.JobTimeout,
254255
"JobTTL": r.Annotations.JobTTL,
255256
"JobBackoffLimit": r.Annotations.JobBackoffLimit,
257+
"JobEnvSecrets": r.Annotations.JobEnvSecrets,
256258
}
257259

258260
config, err := util.ParseCleanupJobConfig(anns, annotationKeys)

pkg/util/cleanup_job.go

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ var jsonMarshal = json.Marshal
2222
const (
2323
DefaultJobImage = "bitnami/kubectl:latest"
2424
DefaultServiceAccount = "default"
25-
DefaultJobTTL = 300
25+
DefaultJobTTL = 60
2626
DefaultJobBackoffLimit = 3
27-
DefaultJobTimeout = "5m"
27+
DefaultJobTimeout = "30s"
2828
)
2929

3030
// CleanupJobConfig holds the configuration for a cleanup job
@@ -37,6 +37,7 @@ type CleanupJobConfig struct {
3737
Timeout time.Duration
3838
TTLSecondsAfterFinished int32
3939
BackoffLimit int32
40+
EnvFromSecrets []string // List of secret names to mount as environment variables
4041
}
4142

4243
// ParseCleanupJobConfig extracts cleanup job configuration from object annotations
@@ -62,13 +63,23 @@ func ParseCleanupJobConfig(annotations map[string]string, annotationKeys map[str
6263
Timeout: 5 * time.Minute,
6364
TTLSecondsAfterFinished: DefaultJobTTL,
6465
BackoffLimit: DefaultJobBackoffLimit,
66+
EnvFromSecrets: []string{},
6567
}
6668

6769
// Parse optional service account
6870
if sa := annotations[annotationKeys["JobServiceAccount"]]; sa != "" {
6971
config.ServiceAccount = sa
7072
}
7173

74+
// Parse optional secrets for environment variables
75+
if secrets := annotations[annotationKeys["JobEnvSecrets"]]; secrets != "" {
76+
config.EnvFromSecrets = strings.Split(secrets, ",")
77+
// Trim whitespace from secret names
78+
for i := range config.EnvFromSecrets {
79+
config.EnvFromSecrets[i] = strings.TrimSpace(config.EnvFromSecrets[i])
80+
}
81+
}
82+
7283
// Parse optional image
7384
if img := annotations[annotationKeys["JobImage"]]; img != "" {
7485
config.Image = img
@@ -189,6 +200,7 @@ func CreateCleanupJob(
189200
Image: config.Image,
190201
Command: []string{"/scripts/cleanup-script"},
191202
Env: envVars,
203+
EnvFrom: buildEnvFrom(config.EnvFromSecrets),
192204
VolumeMounts: []corev1.VolumeMount{
193205
{
194206
Name: "script",
@@ -246,3 +258,24 @@ func WaitForJobCompletion(ctx context.Context, c client.Client, job *batchv1.Job
246258
func int32Ptr(i int32) *int32 {
247259
return &i
248260
}
261+
262+
// buildEnvFrom creates EnvFromSource entries for each secret name
263+
func buildEnvFrom(secretNames []string) []corev1.EnvFromSource {
264+
if len(secretNames) == 0 {
265+
return nil
266+
}
267+
268+
envFrom := make([]corev1.EnvFromSource, 0, len(secretNames))
269+
for _, secretName := range secretNames {
270+
if secretName != "" {
271+
envFrom = append(envFrom, corev1.EnvFromSource{
272+
SecretRef: &corev1.SecretEnvSource{
273+
LocalObjectReference: corev1.LocalObjectReference{
274+
Name: secretName,
275+
},
276+
},
277+
})
278+
}
279+
}
280+
return envFrom
281+
}

pkg/util/cleanup_job_test.go

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ func TestParseCleanupJobConfig_Defaults(t *testing.T) {
173173
"JobTimeout": "job-timeout",
174174
"JobTTL": "job-ttl",
175175
"JobBackoffLimit": "job-backoff-limit",
176+
"JobEnvSecrets": "job-env-secrets",
176177
}
177178

178179
config, err := ParseCleanupJobConfig(annotations, annotationKeys)
@@ -198,6 +199,65 @@ func TestParseCleanupJobConfig_Defaults(t *testing.T) {
198199
if config.BackoffLimit != DefaultJobBackoffLimit {
199200
t.Errorf("Expected default BackoffLimit %d, got %d", DefaultJobBackoffLimit, config.BackoffLimit)
200201
}
202+
if len(config.EnvFromSecrets) != 0 {
203+
t.Errorf("Expected empty EnvFromSecrets, got %v", config.EnvFromSecrets)
204+
}
205+
}
206+
207+
func TestParseCleanupJobConfig_WithSecrets(t *testing.T) {
208+
annotations := map[string]string{
209+
"on-delete-job": "scripts/cleanup.sh",
210+
"job-env-secrets": "aws-creds,db-creds",
211+
}
212+
annotationKeys := map[string]string{
213+
"OnDeleteJob": "on-delete-job",
214+
"JobEnvSecrets": "job-env-secrets",
215+
"JobServiceAccount": "job-service-account",
216+
}
217+
218+
config, err := ParseCleanupJobConfig(annotations, annotationKeys)
219+
if err != nil {
220+
t.Fatalf("Unexpected error: %v", err)
221+
}
222+
if config == nil {
223+
t.Fatal("Expected config, got nil")
224+
}
225+
226+
if len(config.EnvFromSecrets) != 2 {
227+
t.Fatalf("Expected 2 secrets, got %d", len(config.EnvFromSecrets))
228+
}
229+
if config.EnvFromSecrets[0] != "aws-creds" {
230+
t.Errorf("Expected first secret 'aws-creds', got %s", config.EnvFromSecrets[0])
231+
}
232+
if config.EnvFromSecrets[1] != "db-creds" {
233+
t.Errorf("Expected second secret 'db-creds', got %s", config.EnvFromSecrets[1])
234+
}
235+
}
236+
237+
func TestParseCleanupJobConfig_WithSecretsWhitespace(t *testing.T) {
238+
annotations := map[string]string{
239+
"on-delete-job": "scripts/cleanup.sh",
240+
"job-env-secrets": "aws-creds , db-creds , third-secret",
241+
}
242+
annotationKeys := map[string]string{
243+
"OnDeleteJob": "on-delete-job",
244+
"JobEnvSecrets": "job-env-secrets",
245+
"JobServiceAccount": "job-service-account",
246+
}
247+
248+
config, err := ParseCleanupJobConfig(annotations, annotationKeys)
249+
if err != nil {
250+
t.Fatalf("Unexpected error: %v", err)
251+
}
252+
if len(config.EnvFromSecrets) != 3 {
253+
t.Fatalf("Expected 3 secrets, got %d", len(config.EnvFromSecrets))
254+
}
255+
// Verify whitespace is trimmed
256+
for i, expected := range []string{"aws-creds", "db-creds", "third-secret"} {
257+
if config.EnvFromSecrets[i] != expected {
258+
t.Errorf("Expected secret %d to be '%s', got '%s'", i, expected, config.EnvFromSecrets[i])
259+
}
260+
}
201261
}
202262

203263
func TestCreateCleanupJob(t *testing.T) {
@@ -473,3 +533,105 @@ func TestWaitForJobCompletion_GetError(t *testing.T) {
473533
t.Fatalf("expected error when Get fails, got nil")
474534
}
475535
}
536+
537+
func TestBuildEnvFrom_Empty(t *testing.T) {
538+
result := buildEnvFrom([]string{})
539+
if result != nil {
540+
t.Errorf("Expected nil for empty input, got %v", result)
541+
}
542+
}
543+
544+
func TestBuildEnvFrom_SingleSecret(t *testing.T) {
545+
result := buildEnvFrom([]string{"my-secret"})
546+
if len(result) != 1 {
547+
t.Fatalf("Expected 1 EnvFromSource, got %d", len(result))
548+
}
549+
if result[0].SecretRef == nil {
550+
t.Fatal("Expected SecretRef to be set")
551+
}
552+
if result[0].SecretRef.Name != "my-secret" {
553+
t.Errorf("Expected secret name 'my-secret', got %s", result[0].SecretRef.Name)
554+
}
555+
}
556+
557+
func TestBuildEnvFrom_MultipleSecrets(t *testing.T) {
558+
result := buildEnvFrom([]string{"secret1", "secret2", "secret3"})
559+
if len(result) != 3 {
560+
t.Fatalf("Expected 3 EnvFromSource, got %d", len(result))
561+
}
562+
for i, expected := range []string{"secret1", "secret2", "secret3"} {
563+
if result[i].SecretRef == nil {
564+
t.Fatalf("Expected SecretRef at index %d to be set", i)
565+
}
566+
if result[i].SecretRef.Name != expected {
567+
t.Errorf("Expected secret %d to be '%s', got %s", i, expected, result[i].SecretRef.Name)
568+
}
569+
}
570+
}
571+
572+
func TestBuildEnvFrom_EmptyStringInList(t *testing.T) {
573+
result := buildEnvFrom([]string{"secret1", "", "secret2"})
574+
if len(result) != 2 {
575+
t.Fatalf("Expected 2 EnvFromSource (empty string should be skipped), got %d", len(result))
576+
}
577+
if result[0].SecretRef.Name != "secret1" {
578+
t.Errorf("Expected first secret 'secret1', got %s", result[0].SecretRef.Name)
579+
}
580+
if result[1].SecretRef.Name != "secret2" {
581+
t.Errorf("Expected second secret 'secret2', got %s", result[1].SecretRef.Name)
582+
}
583+
}
584+
585+
func TestCreateCleanupJob_WithSecrets(t *testing.T) {
586+
scheme := runtime.NewScheme()
587+
_ = batchv1.AddToScheme(scheme)
588+
_ = corev1.AddToScheme(scheme)
589+
590+
cl := fake.NewClientBuilder().WithScheme(scheme).Build()
591+
592+
obj := &unstructured.Unstructured{}
593+
obj.SetName("test-obj")
594+
obj.SetNamespace("test-ns")
595+
obj.SetUID("test-uid")
596+
obj.SetResourceVersion("123")
597+
598+
gvk := schema.GroupVersionKind{
599+
Group: "example.com",
600+
Version: "v1",
601+
Kind: "TestKind",
602+
}
603+
604+
config := &CleanupJobConfig{
605+
ConfigMapName: "my-scripts",
606+
ScriptKey: "cleanup.sh",
607+
ServiceAccount: "test-sa",
608+
Image: "test/image:v1",
609+
Wait: false,
610+
Timeout: 5 * time.Minute,
611+
TTLSecondsAfterFinished: 300,
612+
BackoffLimit: 3,
613+
EnvFromSecrets: []string{"aws-creds", "db-password"},
614+
}
615+
616+
job, err := CreateCleanupJob(context.Background(), cl, obj, gvk, config, time.Now(), time.Now())
617+
if err != nil {
618+
t.Fatalf("Failed to create cleanup job: %v", err)
619+
}
620+
621+
if len(job.Spec.Template.Spec.Containers) != 1 {
622+
t.Fatalf("Expected 1 container, got %d", len(job.Spec.Template.Spec.Containers))
623+
}
624+
625+
container := job.Spec.Template.Spec.Containers[0]
626+
if len(container.EnvFrom) != 2 {
627+
t.Fatalf("Expected 2 EnvFromSource, got %d", len(container.EnvFrom))
628+
}
629+
630+
// Check that secrets are correctly mounted
631+
if container.EnvFrom[0].SecretRef.Name != "aws-creds" {
632+
t.Errorf("Expected first secret 'aws-creds', got %s", container.EnvFrom[0].SecretRef.Name)
633+
}
634+
if container.EnvFrom[1].SecretRef.Name != "db-password" {
635+
t.Errorf("Expected second secret 'db-password', got %s", container.EnvFrom[1].SecretRef.Name)
636+
}
637+
}

0 commit comments

Comments
 (0)