feat: Initial e2e tests and migrate old ones into e2e

This commit is contained in:
TheiLLeniumStudios
2026-01-08 11:06:45 +01:00
parent fdd2474b3f
commit fafd5460a2
63 changed files with 14035 additions and 7116 deletions
+207
View File
@@ -0,0 +1,207 @@
package utils
// Annotation key constants used by Reloader.
// These follow the pattern: {scope}.reloader.stakater.com/{action}
// where scope can be empty (all resources), "configmap", "secret", "deployment", etc.
const (
// ============================================================
// Core reload annotations
// ============================================================
// AnnotationLastReloadedFrom is set by Reloader on workloads to track the last resource
// that triggered a reload. Format: "{namespace}/{resource-type}/{resource-name}"
AnnotationLastReloadedFrom = "reloader.stakater.com/last-reloaded-from"
// AnnotationConfigMapReload triggers reload when specified ConfigMap(s) change.
// Value: comma-separated list of ConfigMap names, e.g., "config1,config2"
AnnotationConfigMapReload = "configmap.reloader.stakater.com/reload"
// AnnotationSecretReload triggers reload when specified Secret(s) change.
// Value: comma-separated list of Secret names, e.g., "secret1,secret2"
AnnotationSecretReload = "secret.reloader.stakater.com/reload"
// ============================================================
// Auto-reload annotations
// ============================================================
// AnnotationAuto enables auto-reload for all referenced ConfigMaps and Secrets.
// Value: "true" or "false"
AnnotationAuto = "reloader.stakater.com/auto"
// AnnotationConfigMapAuto enables auto-reload for all referenced ConfigMaps only.
// Value: "true" or "false"
AnnotationConfigMapAuto = "configmap.reloader.stakater.com/auto"
// AnnotationSecretAuto enables auto-reload for all referenced Secrets only.
// Value: "true" or "false"
AnnotationSecretAuto = "secret.reloader.stakater.com/auto"
// ============================================================
// Exclude annotations (used with auto=true to exclude specific resources)
// ============================================================
// AnnotationConfigMapExclude excludes specified ConfigMaps from auto-reload.
// Value: comma-separated list of ConfigMap names
AnnotationConfigMapExclude = "configmaps.exclude.reloader.stakater.com/reload"
// AnnotationSecretExclude excludes specified Secrets from auto-reload.
// Value: comma-separated list of Secret names
AnnotationSecretExclude = "secrets.exclude.reloader.stakater.com/reload"
// ============================================================
// Search annotations (for regex matching)
// ============================================================
// AnnotationSearch enables regex search mode for ConfigMap/Secret names.
// Value: "true"
// Used with reload annotation where value is a regex pattern.
AnnotationSearch = "reloader.stakater.com/search"
// AnnotationMatch is an alias for AnnotationSearch.
// Value: "true"
AnnotationMatch = "reloader.stakater.com/match"
// ============================================================
// Resource-level annotations (placed on ConfigMap/Secret)
// ============================================================
// AnnotationIgnore prevents Reloader from triggering reloads for this resource.
// Place this on a ConfigMap or Secret to exclude it from reload triggers.
// Value: "true"
AnnotationIgnore = "reloader.stakater.com/ignore"
// ============================================================
// Pause/period annotations
// ============================================================
// AnnotationDeploymentPausePeriod sets a pause period before triggering reload.
// Value: duration string, e.g., "10s", "1m"
AnnotationDeploymentPausePeriod = "deployment.reloader.stakater.com/pause-period"
// AnnotationDeploymentPausedAt is set by Reloader when a workload is paused.
// Value: RFC3339 timestamp
AnnotationDeploymentPausedAt = "deployment.reloader.stakater.com/paused-at"
// ============================================================
// Argo Rollouts specific annotations
// ============================================================
// AnnotationRolloutStrategy specifies the strategy for Argo Rollouts.
// Value: "restart" (sets spec.restartAt)
AnnotationRolloutStrategy = "reloader.stakater.com/rollout-strategy"
)
// Annotation values.
const (
// AnnotationValueTrue is the string "true" for annotation values.
AnnotationValueTrue = "true"
// AnnotationValueFalse is the string "false" for annotation values.
AnnotationValueFalse = "false"
// AnnotationValueRestart is the "restart" strategy value for Argo Rollouts.
AnnotationValueRestart = "restart"
)
// BuildConfigMapReloadAnnotation creates an annotation map for ConfigMap reload.
func BuildConfigMapReloadAnnotation(configMapNames ...string) map[string]string {
return map[string]string{
AnnotationConfigMapReload: joinNames(configMapNames),
}
}
// BuildSecretReloadAnnotation creates an annotation map for Secret reload.
func BuildSecretReloadAnnotation(secretNames ...string) map[string]string {
return map[string]string{
AnnotationSecretReload: joinNames(secretNames),
}
}
// BuildAutoTrueAnnotation creates an annotation map with auto=true.
func BuildAutoTrueAnnotation() map[string]string {
return map[string]string{
AnnotationAuto: AnnotationValueTrue,
}
}
// BuildAutoFalseAnnotation creates an annotation map with auto=false.
func BuildAutoFalseAnnotation() map[string]string {
return map[string]string{
AnnotationAuto: AnnotationValueFalse,
}
}
// BuildConfigMapAutoAnnotation creates an annotation map with configmap auto=true.
func BuildConfigMapAutoAnnotation() map[string]string {
return map[string]string{
AnnotationConfigMapAuto: AnnotationValueTrue,
}
}
// BuildSecretAutoAnnotation creates an annotation map with secret auto=true.
func BuildSecretAutoAnnotation() map[string]string {
return map[string]string{
AnnotationSecretAuto: AnnotationValueTrue,
}
}
// BuildSearchAnnotation creates an annotation map to enable search mode.
func BuildSearchAnnotation() map[string]string {
return map[string]string{
AnnotationSearch: AnnotationValueTrue,
}
}
// BuildMatchAnnotation creates an annotation map to enable match mode.
func BuildMatchAnnotation() map[string]string {
return map[string]string{
AnnotationMatch: AnnotationValueTrue,
}
}
// BuildIgnoreAnnotation creates an annotation map to ignore a resource.
func BuildIgnoreAnnotation() map[string]string {
return map[string]string{
AnnotationIgnore: AnnotationValueTrue,
}
}
// BuildRolloutRestartStrategyAnnotation creates an annotation for Argo Rollout restart strategy.
func BuildRolloutRestartStrategyAnnotation() map[string]string {
return map[string]string{
AnnotationRolloutStrategy: AnnotationValueRestart,
}
}
// BuildConfigMapExcludeAnnotation creates an annotation to exclude ConfigMaps from auto-reload.
func BuildConfigMapExcludeAnnotation(configMapNames ...string) map[string]string {
return map[string]string{
AnnotationConfigMapExclude: joinNames(configMapNames),
}
}
// BuildSecretExcludeAnnotation creates an annotation to exclude Secrets from auto-reload.
func BuildSecretExcludeAnnotation(secretNames ...string) map[string]string {
return map[string]string{
AnnotationSecretExclude: joinNames(secretNames),
}
}
// BuildPausePeriodAnnotation creates an annotation for deployment pause period.
func BuildPausePeriodAnnotation(duration string) map[string]string {
return map[string]string{
AnnotationDeploymentPausePeriod: duration,
}
}
// joinNames joins names with comma separator.
func joinNames(names []string) string {
if len(names) == 0 {
return ""
}
result := names[0]
for i := 1; i < len(names); i++ {
result += "," + names[i]
}
return result
}
+306
View File
@@ -0,0 +1,306 @@
package utils
import (
"testing"
)
func TestBuildConfigMapReloadAnnotation(t *testing.T) {
tests := []struct {
name string
configMaps []string
expected map[string]string
}{
{
name: "single ConfigMap",
configMaps: []string{"my-config"},
expected: map[string]string{
AnnotationConfigMapReload: "my-config",
},
},
{
name: "multiple ConfigMaps",
configMaps: []string{"config1", "config2", "config3"},
expected: map[string]string{
AnnotationConfigMapReload: "config1,config2,config3",
},
},
{
name: "empty list",
configMaps: []string{},
expected: map[string]string{
AnnotationConfigMapReload: "",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := BuildConfigMapReloadAnnotation(tt.configMaps...)
if len(result) != len(tt.expected) {
t.Errorf("BuildConfigMapReloadAnnotation() returned %d entries, want %d", len(result), len(tt.expected))
}
for k, v := range tt.expected {
if result[k] != v {
t.Errorf("BuildConfigMapReloadAnnotation()[%q] = %q, want %q", k, result[k], v)
}
}
})
}
}
func TestBuildSecretReloadAnnotation(t *testing.T) {
tests := []struct {
name string
secrets []string
expected map[string]string
}{
{
name: "single Secret",
secrets: []string{"my-secret"},
expected: map[string]string{
AnnotationSecretReload: "my-secret",
},
},
{
name: "multiple Secrets",
secrets: []string{"secret1", "secret2"},
expected: map[string]string{
AnnotationSecretReload: "secret1,secret2",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := BuildSecretReloadAnnotation(tt.secrets...)
for k, v := range tt.expected {
if result[k] != v {
t.Errorf("BuildSecretReloadAnnotation()[%q] = %q, want %q", k, result[k], v)
}
}
})
}
}
func TestBuildAutoAnnotations(t *testing.T) {
t.Run("BuildAutoTrueAnnotation", func(t *testing.T) {
result := BuildAutoTrueAnnotation()
if result[AnnotationAuto] != AnnotationValueTrue {
t.Errorf("BuildAutoTrueAnnotation()[%q] = %q, want %q",
AnnotationAuto, result[AnnotationAuto], AnnotationValueTrue)
}
})
t.Run("BuildAutoFalseAnnotation", func(t *testing.T) {
result := BuildAutoFalseAnnotation()
if result[AnnotationAuto] != AnnotationValueFalse {
t.Errorf("BuildAutoFalseAnnotation()[%q] = %q, want %q",
AnnotationAuto, result[AnnotationAuto], AnnotationValueFalse)
}
})
t.Run("BuildConfigMapAutoAnnotation", func(t *testing.T) {
result := BuildConfigMapAutoAnnotation()
if result[AnnotationConfigMapAuto] != AnnotationValueTrue {
t.Errorf("BuildConfigMapAutoAnnotation()[%q] = %q, want %q",
AnnotationConfigMapAuto, result[AnnotationConfigMapAuto], AnnotationValueTrue)
}
})
t.Run("BuildSecretAutoAnnotation", func(t *testing.T) {
result := BuildSecretAutoAnnotation()
if result[AnnotationSecretAuto] != AnnotationValueTrue {
t.Errorf("BuildSecretAutoAnnotation()[%q] = %q, want %q",
AnnotationSecretAuto, result[AnnotationSecretAuto], AnnotationValueTrue)
}
})
}
func TestBuildSearchMatchAnnotations(t *testing.T) {
t.Run("BuildSearchAnnotation", func(t *testing.T) {
result := BuildSearchAnnotation()
if result[AnnotationSearch] != AnnotationValueTrue {
t.Errorf("BuildSearchAnnotation()[%q] = %q, want %q",
AnnotationSearch, result[AnnotationSearch], AnnotationValueTrue)
}
})
t.Run("BuildMatchAnnotation", func(t *testing.T) {
result := BuildMatchAnnotation()
if result[AnnotationMatch] != AnnotationValueTrue {
t.Errorf("BuildMatchAnnotation()[%q] = %q, want %q",
AnnotationMatch, result[AnnotationMatch], AnnotationValueTrue)
}
})
}
func TestBuildIgnoreAnnotation(t *testing.T) {
result := BuildIgnoreAnnotation()
if result[AnnotationIgnore] != AnnotationValueTrue {
t.Errorf("BuildIgnoreAnnotation()[%q] = %q, want %q",
AnnotationIgnore, result[AnnotationIgnore], AnnotationValueTrue)
}
}
func TestBuildRolloutRestartStrategyAnnotation(t *testing.T) {
result := BuildRolloutRestartStrategyAnnotation()
if result[AnnotationRolloutStrategy] != AnnotationValueRestart {
t.Errorf("BuildRolloutRestartStrategyAnnotation()[%q] = %q, want %q",
AnnotationRolloutStrategy, result[AnnotationRolloutStrategy], AnnotationValueRestart)
}
}
func TestBuildExcludeAnnotations(t *testing.T) {
t.Run("BuildConfigMapExcludeAnnotation single", func(t *testing.T) {
result := BuildConfigMapExcludeAnnotation("excluded-cm")
if result[AnnotationConfigMapExclude] != "excluded-cm" {
t.Errorf("BuildConfigMapExcludeAnnotation()[%q] = %q, want %q",
AnnotationConfigMapExclude, result[AnnotationConfigMapExclude], "excluded-cm")
}
})
t.Run("BuildConfigMapExcludeAnnotation multiple", func(t *testing.T) {
result := BuildConfigMapExcludeAnnotation("cm1", "cm2", "cm3")
expected := "cm1,cm2,cm3"
if result[AnnotationConfigMapExclude] != expected {
t.Errorf("BuildConfigMapExcludeAnnotation()[%q] = %q, want %q",
AnnotationConfigMapExclude, result[AnnotationConfigMapExclude], expected)
}
})
t.Run("BuildSecretExcludeAnnotation single", func(t *testing.T) {
result := BuildSecretExcludeAnnotation("excluded-secret")
if result[AnnotationSecretExclude] != "excluded-secret" {
t.Errorf("BuildSecretExcludeAnnotation()[%q] = %q, want %q",
AnnotationSecretExclude, result[AnnotationSecretExclude], "excluded-secret")
}
})
t.Run("BuildSecretExcludeAnnotation multiple", func(t *testing.T) {
result := BuildSecretExcludeAnnotation("s1", "s2")
expected := "s1,s2"
if result[AnnotationSecretExclude] != expected {
t.Errorf("BuildSecretExcludeAnnotation()[%q] = %q, want %q",
AnnotationSecretExclude, result[AnnotationSecretExclude], expected)
}
})
}
func TestBuildPausePeriodAnnotation(t *testing.T) {
tests := []struct {
name string
duration string
expected string
}{
{
name: "10 seconds",
duration: "10s",
expected: "10s",
},
{
name: "1 minute",
duration: "1m",
expected: "1m",
},
{
name: "30 minutes",
duration: "30m",
expected: "30m",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := BuildPausePeriodAnnotation(tt.duration)
if result[AnnotationDeploymentPausePeriod] != tt.expected {
t.Errorf("BuildPausePeriodAnnotation(%q)[%q] = %q, want %q",
tt.duration, AnnotationDeploymentPausePeriod,
result[AnnotationDeploymentPausePeriod], tt.expected)
}
})
}
}
func TestJoinNames(t *testing.T) {
tests := []struct {
name string
names []string
expected string
}{
{
name: "empty slice",
names: []string{},
expected: "",
},
{
name: "single name",
names: []string{"one"},
expected: "one",
},
{
name: "two names",
names: []string{"one", "two"},
expected: "one,two",
},
{
name: "three names",
names: []string{"alpha", "beta", "gamma"},
expected: "alpha,beta,gamma",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := joinNames(tt.names)
if result != tt.expected {
t.Errorf("joinNames(%v) = %q, want %q", tt.names, result, tt.expected)
}
})
}
}
func TestAnnotationConstants(t *testing.T) {
// Verify annotation constants have expected values
// This ensures we don't accidentally change the annotation keys
tests := []struct {
name string
constant string
expected string
}{
{"AnnotationLastReloadedFrom", AnnotationLastReloadedFrom, "reloader.stakater.com/last-reloaded-from"},
{"AnnotationConfigMapReload", AnnotationConfigMapReload, "configmap.reloader.stakater.com/reload"},
{"AnnotationSecretReload", AnnotationSecretReload, "secret.reloader.stakater.com/reload"},
{"AnnotationAuto", AnnotationAuto, "reloader.stakater.com/auto"},
{"AnnotationConfigMapAuto", AnnotationConfigMapAuto, "configmap.reloader.stakater.com/auto"},
{"AnnotationSecretAuto", AnnotationSecretAuto, "secret.reloader.stakater.com/auto"},
{"AnnotationConfigMapExclude", AnnotationConfigMapExclude, "configmaps.exclude.reloader.stakater.com/reload"},
{"AnnotationSecretExclude", AnnotationSecretExclude, "secrets.exclude.reloader.stakater.com/reload"},
{"AnnotationSearch", AnnotationSearch, "reloader.stakater.com/search"},
{"AnnotationMatch", AnnotationMatch, "reloader.stakater.com/match"},
{"AnnotationIgnore", AnnotationIgnore, "reloader.stakater.com/ignore"},
{"AnnotationDeploymentPausePeriod", AnnotationDeploymentPausePeriod, "deployment.reloader.stakater.com/pause-period"},
{"AnnotationDeploymentPausedAt", AnnotationDeploymentPausedAt, "deployment.reloader.stakater.com/paused-at"},
{"AnnotationRolloutStrategy", AnnotationRolloutStrategy, "reloader.stakater.com/rollout-strategy"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.constant != tt.expected {
t.Errorf("%s = %q, want %q", tt.name, tt.constant, tt.expected)
}
})
}
}
func TestAnnotationValues(t *testing.T) {
// Verify annotation value constants
if AnnotationValueTrue != "true" {
t.Errorf("AnnotationValueTrue = %q, want \"true\"", AnnotationValueTrue)
}
if AnnotationValueFalse != "false" {
t.Errorf("AnnotationValueFalse = %q, want \"false\"", AnnotationValueFalse)
}
if AnnotationValueRestart != "restart" {
t.Errorf("AnnotationValueRestart = %q, want \"restart\"", AnnotationValueRestart)
}
}
+308
View File
@@ -0,0 +1,308 @@
package utils
import (
"context"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/wait"
"k8s.io/client-go/dynamic"
)
// ArgoRolloutGVR returns the GroupVersionResource for Argo Rollouts.
var ArgoRolloutGVR = schema.GroupVersionResource{
Group: "argoproj.io",
Version: "v1alpha1",
Resource: "rollouts",
}
// RolloutOption is a functional option for configuring an Argo Rollout.
type RolloutOption func(*unstructured.Unstructured)
// IsArgoRolloutsInstalled checks if Argo Rollouts CRD is installed in the cluster.
func IsArgoRolloutsInstalled(ctx context.Context, dynamicClient dynamic.Interface) bool {
// Try to list rollouts - if CRD exists, this will succeed (possibly with empty list)
_, err := dynamicClient.Resource(ArgoRolloutGVR).Namespace("default").List(ctx, metav1.ListOptions{Limit: 1})
return err == nil
}
// CreateArgoRollout creates an Argo Rollout with the given options.
func CreateArgoRollout(ctx context.Context, dynamicClient dynamic.Interface, namespace, name string, opts ...RolloutOption) error {
rollout := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "argoproj.io/v1alpha1",
"kind": "Rollout",
"metadata": map[string]interface{}{
"name": name,
"namespace": namespace,
},
"spec": map[string]interface{}{
"replicas": int64(1),
"selector": map[string]interface{}{
"matchLabels": map[string]interface{}{
"app": name,
},
},
"template": map[string]interface{}{
"metadata": map[string]interface{}{
"labels": map[string]interface{}{
"app": name,
},
},
"spec": map[string]interface{}{
"containers": []interface{}{
map[string]interface{}{
"name": "app",
"image": "busybox:1.36",
"command": []interface{}{"sh", "-c", "sleep 3600"},
},
},
},
},
"strategy": map[string]interface{}{
"canary": map[string]interface{}{
"steps": []interface{}{
map[string]interface{}{
"setWeight": int64(100),
},
},
},
},
},
},
}
// Apply options
for _, opt := range opts {
opt(rollout)
}
_, err := dynamicClient.Resource(ArgoRolloutGVR).Namespace(namespace).Create(ctx, rollout, metav1.CreateOptions{})
return err
}
// DeleteArgoRollout deletes an Argo Rollout.
func DeleteArgoRollout(ctx context.Context, dynamicClient dynamic.Interface, namespace, name string) error {
err := dynamicClient.Resource(ArgoRolloutGVR).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{})
return err
}
// GetArgoRollout retrieves an Argo Rollout.
func GetArgoRollout(ctx context.Context, dynamicClient dynamic.Interface, namespace, name string) (*unstructured.Unstructured, error) {
return dynamicClient.Resource(ArgoRolloutGVR).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
}
// WithRolloutConfigMapEnvFrom adds a ConfigMap envFrom to the Rollout.
func WithRolloutConfigMapEnvFrom(configMapName string) RolloutOption {
return func(rollout *unstructured.Unstructured) {
containers, _, _ := unstructured.NestedSlice(rollout.Object, "spec", "template", "spec", "containers")
if len(containers) > 0 {
container := containers[0].(map[string]interface{})
envFrom, _, _ := unstructured.NestedSlice(container, "envFrom")
envFrom = append(envFrom, map[string]interface{}{
"configMapRef": map[string]interface{}{
"name": configMapName,
},
})
container["envFrom"] = envFrom
containers[0] = container
_ = unstructured.SetNestedSlice(rollout.Object, containers, "spec", "template", "spec", "containers")
}
}
}
// WithRolloutSecretEnvFrom adds a Secret envFrom to the Rollout.
func WithRolloutSecretEnvFrom(secretName string) RolloutOption {
return func(rollout *unstructured.Unstructured) {
containers, _, _ := unstructured.NestedSlice(rollout.Object, "spec", "template", "spec", "containers")
if len(containers) > 0 {
container := containers[0].(map[string]interface{})
envFrom, _, _ := unstructured.NestedSlice(container, "envFrom")
envFrom = append(envFrom, map[string]interface{}{
"secretRef": map[string]interface{}{
"name": secretName,
},
})
container["envFrom"] = envFrom
containers[0] = container
_ = unstructured.SetNestedSlice(rollout.Object, containers, "spec", "template", "spec", "containers")
}
}
}
// WithRolloutConfigMapVolume adds a ConfigMap volume to the Rollout.
func WithRolloutConfigMapVolume(configMapName string) RolloutOption {
return func(rollout *unstructured.Unstructured) {
// Add volume
volumes, _, _ := unstructured.NestedSlice(rollout.Object, "spec", "template", "spec", "volumes")
volumes = append(volumes, map[string]interface{}{
"name": configMapName + "-volume",
"configMap": map[string]interface{}{
"name": configMapName,
},
})
_ = unstructured.SetNestedSlice(rollout.Object, volumes, "spec", "template", "spec", "volumes")
// Add volumeMount
containers, _, _ := unstructured.NestedSlice(rollout.Object, "spec", "template", "spec", "containers")
if len(containers) > 0 {
container := containers[0].(map[string]interface{})
volumeMounts, _, _ := unstructured.NestedSlice(container, "volumeMounts")
volumeMounts = append(volumeMounts, map[string]interface{}{
"name": configMapName + "-volume",
"mountPath": "/etc/config/" + configMapName,
})
container["volumeMounts"] = volumeMounts
containers[0] = container
_ = unstructured.SetNestedSlice(rollout.Object, containers, "spec", "template", "spec", "containers")
}
}
}
// WithRolloutSecretVolume adds a Secret volume to the Rollout.
func WithRolloutSecretVolume(secretName string) RolloutOption {
return func(rollout *unstructured.Unstructured) {
// Add volume
volumes, _, _ := unstructured.NestedSlice(rollout.Object, "spec", "template", "spec", "volumes")
volumes = append(volumes, map[string]interface{}{
"name": secretName + "-volume",
"secret": map[string]interface{}{
"secretName": secretName,
},
})
_ = unstructured.SetNestedSlice(rollout.Object, volumes, "spec", "template", "spec", "volumes")
// Add volumeMount
containers, _, _ := unstructured.NestedSlice(rollout.Object, "spec", "template", "spec", "containers")
if len(containers) > 0 {
container := containers[0].(map[string]interface{})
volumeMounts, _, _ := unstructured.NestedSlice(container, "volumeMounts")
volumeMounts = append(volumeMounts, map[string]interface{}{
"name": secretName + "-volume",
"mountPath": "/etc/secrets/" + secretName,
})
container["volumeMounts"] = volumeMounts
containers[0] = container
_ = unstructured.SetNestedSlice(rollout.Object, containers, "spec", "template", "spec", "containers")
}
}
}
// WithRolloutAnnotations adds annotations to the Rollout's pod template.
func WithRolloutAnnotations(annotations map[string]string) RolloutOption {
return func(rollout *unstructured.Unstructured) {
annotationsMap := make(map[string]interface{})
for k, v := range annotations {
annotationsMap[k] = v
}
_ = unstructured.SetNestedMap(rollout.Object, annotationsMap, "spec", "template", "metadata", "annotations")
}
}
// WithRolloutObjectAnnotations adds annotations to the Rollout's top-level metadata.
// Use this for annotations that are read from the Rollout object itself (like rollout-strategy).
func WithRolloutObjectAnnotations(annotations map[string]string) RolloutOption {
return func(rollout *unstructured.Unstructured) {
annotationsMap := make(map[string]interface{})
for k, v := range annotations {
annotationsMap[k] = v
}
_ = unstructured.SetNestedMap(rollout.Object, annotationsMap, "metadata", "annotations")
}
}
// WaitForRolloutReady waits for an Argo Rollout to be ready.
func WaitForRolloutReady(ctx context.Context, dynamicClient dynamic.Interface, namespace, name string, timeout time.Duration) error {
return wait.PollUntilContextTimeout(ctx, DefaultInterval, timeout, true, func(ctx context.Context) (bool, error) {
rollout, err := dynamicClient.Resource(ArgoRolloutGVR).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return false, nil // Keep polling
}
// Check status.phase == "Healthy" or replicas == availableReplicas
status, found, _ := unstructured.NestedMap(rollout.Object, "status")
if !found {
return false, nil
}
phase, _, _ := unstructured.NestedString(status, "phase")
if phase == "Healthy" {
return true, nil
}
// Alternative: check replicas
replicas, _, _ := unstructured.NestedInt64(rollout.Object, "spec", "replicas")
availableReplicas, _, _ := unstructured.NestedInt64(status, "availableReplicas")
if replicas > 0 && replicas == availableReplicas {
return true, nil
}
return false, nil
})
}
// WaitForRolloutReloaded waits for an Argo Rollout's pod template to have the reloader annotation.
func WaitForRolloutReloaded(ctx context.Context, dynamicClient dynamic.Interface, namespace, name, annotationKey string, timeout time.Duration) (bool, error) {
var found bool
err := wait.PollUntilContextTimeout(ctx, DefaultInterval, timeout, true, func(ctx context.Context) (bool, error) {
rollout, err := dynamicClient.Resource(ArgoRolloutGVR).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return false, nil
}
// Check pod template annotations
annotations, _, _ := unstructured.NestedStringMap(rollout.Object, "spec", "template", "metadata", "annotations")
if annotations != nil {
if _, ok := annotations[annotationKey]; ok {
found = true
return true, nil
}
}
return false, nil
})
if err != nil && err != context.DeadlineExceeded {
return false, err
}
return found, nil
}
// GetRolloutPodTemplateAnnotations retrieves the pod template annotations from an Argo Rollout.
func GetRolloutPodTemplateAnnotations(ctx context.Context, dynamicClient dynamic.Interface, namespace, name string) (map[string]string, error) {
rollout, err := dynamicClient.Resource(ArgoRolloutGVR).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return nil, err
}
annotations, _, _ := unstructured.NestedStringMap(rollout.Object, "spec", "template", "metadata", "annotations")
return annotations, nil
}
// WaitForRolloutRestartAt waits for an Argo Rollout's spec.restartAt field to be set.
// This is used when the restart strategy is specified.
func WaitForRolloutRestartAt(ctx context.Context, dynamicClient dynamic.Interface, namespace, name string, timeout time.Duration) (bool, error) {
var found bool
err := wait.PollUntilContextTimeout(ctx, DefaultInterval, timeout, true, func(ctx context.Context) (bool, error) {
rollout, err := dynamicClient.Resource(ArgoRolloutGVR).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return false, nil
}
// Check if spec.restartAt is set
restartAt, exists, _ := unstructured.NestedString(rollout.Object, "spec", "restartAt")
if exists && restartAt != "" {
found = true
return true, nil
}
return false, nil
})
if err != nil && err != context.DeadlineExceeded {
return false, err
}
return found, nil
}
+224
View File
@@ -0,0 +1,224 @@
package utils
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// Helm-related constants.
const (
// DefaultTestImage is the default image to test if RELOADER_IMAGE is not set.
DefaultTestImage = "ghcr.io/stakater/reloader:test"
// DefaultHelmReleaseName is the Helm release name for Reloader.
DefaultHelmReleaseName = "reloader"
// DefaultHelmChartPath is the path to the Helm chart relative to project root.
DefaultHelmChartPath = "deployments/kubernetes/chart/reloader"
// StakaterEnvVarPrefix is the prefix for Stakater environment variables.
StakaterEnvVarPrefix = "STAKATER_"
)
// DeployOptions configures how Reloader is deployed.
type DeployOptions struct {
// Namespace to deploy Reloader into.
Namespace string
// Image is the full image reference (e.g., "ghcr.io/stakater/reloader:test").
Image string
// Values are additional Helm values to set (key=value pairs).
Values map[string]string
// ReleaseName is the Helm release name. Defaults to DefaultHelmReleaseName.
ReleaseName string
// Timeout for Helm operations. Defaults to "120s".
Timeout string
}
// DeployReloader deploys Reloader using Helm with the specified options.
func DeployReloader(opts DeployOptions) error {
projectDir, err := GetProjectDir()
if err != nil {
return fmt.Errorf("getting project dir: %w", err)
}
if opts.ReleaseName == "" {
opts.ReleaseName = DefaultHelmReleaseName
}
if opts.Timeout == "" {
opts.Timeout = "120s"
}
if opts.Image == "" {
opts.Image = GetTestImage()
}
// Clean up any existing cluster-scoped resources before deploying
// This prevents "already exists" errors when a previous test didn't clean up properly
cleanupClusterResources(opts.ReleaseName)
chartPath := filepath.Join(projectDir, DefaultHelmChartPath)
args := []string{
"upgrade", "--install", opts.ReleaseName,
chartPath,
"--namespace", opts.Namespace,
"--create-namespace",
"--reset-values", // Important: reset values to ensure clean state between tests
"--set", fmt.Sprintf("image.repository=%s", GetImageRepository(opts.Image)),
"--set", fmt.Sprintf("image.tag=%s", GetImageTag(opts.Image)),
"--set", "image.pullPolicy=IfNotPresent",
"--wait",
"--timeout", opts.Timeout,
}
// Add custom values
for key, value := range opts.Values {
args = append(args, "--set", fmt.Sprintf("%s=%s", key, value))
}
cmd := exec.Command("helm", args...)
output, err := Run(cmd)
if err != nil {
return fmt.Errorf("helm install failed: %s: %w", output, err)
}
return nil
}
// UndeployReloader removes the Reloader Helm release and cleans up cluster-scoped resources.
// This function waits for all resources to be fully deleted to prevent race conditions
// between test suites.
func UndeployReloader(namespace, releaseName string) error {
if releaseName == "" {
releaseName = DefaultHelmReleaseName
}
// Use --wait to ensure Helm waits for resources to be deleted
cmd := exec.Command("helm", "uninstall", releaseName, "--namespace", namespace, "--ignore-not-found", "--wait")
output, err := Run(cmd)
if err != nil {
return fmt.Errorf("helm uninstall failed: %s: %w", output, err)
}
// Clean up cluster-scoped resources that Helm might not delete
// Use --wait to ensure resources are fully deleted before returning
clusterResources := []struct {
kind string
name string
}{
{"clusterrole", releaseName + "-reloader-role"},
{"clusterrolebinding", releaseName + "-reloader-role-binding"},
}
for _, res := range clusterResources {
cmd := exec.Command("kubectl", "delete", res.kind, res.name, "--ignore-not-found", "--wait=true")
_, _ = Run(cmd) // Ignore errors - resource may not exist
}
// Additional wait to ensure controller is fully stopped and resources are cleaned up
// This prevents race conditions when the next test tries to deploy immediately
waitForReloaderGone(namespace, releaseName)
return nil
}
// waitForReloaderGone waits for the Reloader deployment to be fully removed.
func waitForReloaderGone(namespace, releaseName string) {
deploymentName := ReloaderDeploymentName(releaseName)
// Poll until deployment is gone (max 30 seconds)
for i := 0; i < 30; i++ {
cmd := exec.Command("kubectl", "get", "deployment", deploymentName, "-n", namespace, "--ignore-not-found", "-o", "name")
output, _ := Run(cmd)
if strings.TrimSpace(output) == "" {
return
}
time.Sleep(1 * time.Second)
}
}
// cleanupClusterResources removes cluster-scoped resources that might be left over
// from a previous test run. This is called before deploying to ensure clean state.
func cleanupClusterResources(releaseName string) {
if releaseName == "" {
releaseName = DefaultHelmReleaseName
}
clusterResources := []struct {
kind string
name string
}{
{"clusterrole", releaseName + "-reloader-role"},
{"clusterrolebinding", releaseName + "-reloader-role-binding"},
}
for _, res := range clusterResources {
cmd := exec.Command("kubectl", "delete", res.kind, res.name, "--ignore-not-found", "--wait=true")
_, _ = Run(cmd)
}
// Small wait to ensure API server has processed the deletions
time.Sleep(500 * time.Millisecond)
}
// GetTestImage returns the test image from environment or the default.
func GetTestImage() string {
if img := os.Getenv("RELOADER_IMAGE"); img != "" {
return img
}
return DefaultTestImage
}
// GetImageRepository extracts the repository (without tag) from a full image reference.
// Example: "ghcr.io/stakater/reloader:v1.0.0" -> "ghcr.io/stakater/reloader"
func GetImageRepository(image string) string {
for i := len(image) - 1; i >= 0; i-- {
if image[i] == ':' {
return image[:i]
}
if image[i] == '/' {
// No tag found, return as-is
break
}
}
return image
}
// GetImageTag extracts the tag from a full image reference.
// Example: "ghcr.io/stakater/reloader:v1.0.0" -> "v1.0.0"
// Returns "latest" if no tag is found.
func GetImageTag(image string) string {
for i := len(image) - 1; i >= 0; i-- {
if image[i] == ':' {
return image[i+1:]
}
if image[i] == '/' {
// No tag found
break
}
}
return "latest"
}
// ReloaderDeploymentName returns the full deployment name for Reloader.
func ReloaderDeploymentName(releaseName string) string {
if releaseName == "" {
releaseName = DefaultHelmReleaseName
}
return releaseName + "-reloader"
}
// ReloaderPodSelector returns the label selector for Reloader pods.
func ReloaderPodSelector(releaseName string) string {
if releaseName == "" {
releaseName = DefaultHelmReleaseName
}
return "app=" + releaseName + "-reloader"
}
+157
View File
@@ -0,0 +1,157 @@
package utils
import (
"testing"
)
func TestGetImageRepository(t *testing.T) {
tests := []struct {
name string
image string
expected string
}{
{
name: "full image with tag",
image: "ghcr.io/stakater/reloader:v1.0.0",
expected: "ghcr.io/stakater/reloader",
},
{
name: "image with latest tag",
image: "nginx:latest",
expected: "nginx",
},
{
name: "image without tag",
image: "ghcr.io/stakater/reloader",
expected: "ghcr.io/stakater/reloader",
},
{
name: "image with digest (not fully supported)",
image: "nginx@sha256:abc123",
expected: "nginx@sha256", // Note: digest handling is limited
},
{
name: "simple image name",
image: "nginx",
expected: "nginx",
},
{
name: "image with port in registry",
image: "localhost:5000/myimage:v1",
expected: "localhost:5000/myimage",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := GetImageRepository(tt.image)
if result != tt.expected {
t.Errorf("GetImageRepository(%q) = %q, want %q", tt.image, result, tt.expected)
}
})
}
}
func TestGetImageTag(t *testing.T) {
tests := []struct {
name string
image string
expected string
}{
{
name: "full image with tag",
image: "ghcr.io/stakater/reloader:v1.0.0",
expected: "v1.0.0",
},
{
name: "image with latest tag",
image: "nginx:latest",
expected: "latest",
},
{
name: "image without tag",
image: "ghcr.io/stakater/reloader",
expected: "latest",
},
{
name: "simple image name",
image: "nginx",
expected: "latest",
},
{
name: "image with port in registry",
image: "localhost:5000/myimage:v1",
expected: "v1",
},
{
name: "tag with sha",
image: "myimage:sha-abc123",
expected: "sha-abc123",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := GetImageTag(tt.image)
if result != tt.expected {
t.Errorf("GetImageTag(%q) = %q, want %q", tt.image, result, tt.expected)
}
})
}
}
func TestReloaderDeploymentName(t *testing.T) {
tests := []struct {
name string
releaseName string
expected string
}{
{
name: "default release name",
releaseName: "",
expected: "reloader-reloader",
},
{
name: "custom release name",
releaseName: "my-reloader",
expected: "my-reloader-reloader",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ReloaderDeploymentName(tt.releaseName)
if result != tt.expected {
t.Errorf("ReloaderDeploymentName(%q) = %q, want %q", tt.releaseName, result, tt.expected)
}
})
}
}
func TestReloaderPodSelector(t *testing.T) {
tests := []struct {
name string
releaseName string
expected string
}{
{
name: "default release name",
releaseName: "",
expected: "app=reloader-reloader",
},
{
name: "custom release name",
releaseName: "my-reloader",
expected: "app=my-reloader-reloader",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ReloaderPodSelector(tt.releaseName)
if result != tt.expected {
t.Errorf("ReloaderPodSelector(%q) = %q, want %q", tt.releaseName, result, tt.expected)
}
})
}
}
+27
View File
@@ -0,0 +1,27 @@
package utils
import (
"fmt"
"os"
"os/exec"
)
// GetKindClusterName returns the Kind cluster name from the KIND_CLUSTER environment variable,
// or "kind" as the default.
func GetKindClusterName() string {
if cluster := os.Getenv("KIND_CLUSTER"); cluster != "" {
return cluster
}
return "kind"
}
// LoadImageToKindCluster loads a Docker image into the Kind cluster using the default cluster name.
func LoadImageToKindCluster(image string) error {
cmd := exec.Command("kind", "load", "docker-image", image, "--name", GetKindClusterName())
output, err := Run(cmd)
if err != nil {
return fmt.Errorf("failed to load image %s to Kind cluster: %w\nOutput: %s",
image, err, output)
}
return nil
}
+265
View File
@@ -0,0 +1,265 @@
package utils
import (
"context"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/wait"
"k8s.io/client-go/discovery"
"k8s.io/client-go/dynamic"
)
// DeploymentConfigGVR returns the GroupVersionResource for OpenShift DeploymentConfigs.
var DeploymentConfigGVR = schema.GroupVersionResource{
Group: "apps.openshift.io",
Version: "v1",
Resource: "deploymentconfigs",
}
// DCOption is a functional option for configuring a DeploymentConfig.
type DCOption func(*unstructured.Unstructured)
// HasDeploymentConfigSupport checks if the cluster has OpenShift DeploymentConfig API available.
func HasDeploymentConfigSupport(discoveryClient discovery.DiscoveryInterface) bool {
_, apiLists, err := discoveryClient.ServerGroupsAndResources()
if err != nil {
return false
}
for _, apiList := range apiLists {
for _, resource := range apiList.APIResources {
if resource.Kind == "DeploymentConfig" {
return true
}
}
}
return false
}
// CreateDeploymentConfig creates an OpenShift DeploymentConfig with the given options.
func CreateDeploymentConfig(ctx context.Context, dynamicClient dynamic.Interface, namespace, name string, opts ...DCOption) error {
dc := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "apps.openshift.io/v1",
"kind": "DeploymentConfig",
"metadata": map[string]interface{}{
"name": name,
"namespace": namespace,
},
"spec": map[string]interface{}{
"replicas": int64(1),
"selector": map[string]interface{}{
"app": name,
},
"template": map[string]interface{}{
"metadata": map[string]interface{}{
"labels": map[string]interface{}{
"app": name,
},
},
"spec": map[string]interface{}{
"containers": []interface{}{
map[string]interface{}{
"name": "app",
"image": "busybox:1.36",
"command": []interface{}{"sh", "-c", "sleep 3600"},
},
},
},
},
"triggers": []interface{}{
map[string]interface{}{
"type": "ConfigChange",
},
},
},
},
}
// Apply options
for _, opt := range opts {
opt(dc)
}
_, err := dynamicClient.Resource(DeploymentConfigGVR).Namespace(namespace).Create(ctx, dc, metav1.CreateOptions{})
return err
}
// DeleteDeploymentConfig deletes a DeploymentConfig.
func DeleteDeploymentConfig(ctx context.Context, dynamicClient dynamic.Interface, namespace, name string) error {
return dynamicClient.Resource(DeploymentConfigGVR).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{})
}
// GetDeploymentConfig retrieves a DeploymentConfig.
func GetDeploymentConfig(ctx context.Context, dynamicClient dynamic.Interface, namespace, name string) (*unstructured.Unstructured, error) {
return dynamicClient.Resource(DeploymentConfigGVR).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
}
// WithDCConfigMapEnvFrom adds a ConfigMap envFrom to the DeploymentConfig.
func WithDCConfigMapEnvFrom(configMapName string) DCOption {
return func(dc *unstructured.Unstructured) {
containers, _, _ := unstructured.NestedSlice(dc.Object, "spec", "template", "spec", "containers")
if len(containers) > 0 {
container := containers[0].(map[string]interface{})
envFrom, _, _ := unstructured.NestedSlice(container, "envFrom")
envFrom = append(envFrom, map[string]interface{}{
"configMapRef": map[string]interface{}{
"name": configMapName,
},
})
container["envFrom"] = envFrom
containers[0] = container
_ = unstructured.SetNestedSlice(dc.Object, containers, "spec", "template", "spec", "containers")
}
}
}
// WithDCSecretEnvFrom adds a Secret envFrom to the DeploymentConfig.
func WithDCSecretEnvFrom(secretName string) DCOption {
return func(dc *unstructured.Unstructured) {
containers, _, _ := unstructured.NestedSlice(dc.Object, "spec", "template", "spec", "containers")
if len(containers) > 0 {
container := containers[0].(map[string]interface{})
envFrom, _, _ := unstructured.NestedSlice(container, "envFrom")
envFrom = append(envFrom, map[string]interface{}{
"secretRef": map[string]interface{}{
"name": secretName,
},
})
container["envFrom"] = envFrom
containers[0] = container
_ = unstructured.SetNestedSlice(dc.Object, containers, "spec", "template", "spec", "containers")
}
}
}
// WithDCConfigMapVolume adds a ConfigMap volume to the DeploymentConfig.
func WithDCConfigMapVolume(configMapName string) DCOption {
return func(dc *unstructured.Unstructured) {
// Add volume
volumes, _, _ := unstructured.NestedSlice(dc.Object, "spec", "template", "spec", "volumes")
volumes = append(volumes, map[string]interface{}{
"name": configMapName + "-volume",
"configMap": map[string]interface{}{
"name": configMapName,
},
})
_ = unstructured.SetNestedSlice(dc.Object, volumes, "spec", "template", "spec", "volumes")
// Add volumeMount
containers, _, _ := unstructured.NestedSlice(dc.Object, "spec", "template", "spec", "containers")
if len(containers) > 0 {
container := containers[0].(map[string]interface{})
volumeMounts, _, _ := unstructured.NestedSlice(container, "volumeMounts")
volumeMounts = append(volumeMounts, map[string]interface{}{
"name": configMapName + "-volume",
"mountPath": "/etc/config/" + configMapName,
})
container["volumeMounts"] = volumeMounts
containers[0] = container
_ = unstructured.SetNestedSlice(dc.Object, containers, "spec", "template", "spec", "containers")
}
}
}
// WithDCSecretVolume adds a Secret volume to the DeploymentConfig.
func WithDCSecretVolume(secretName string) DCOption {
return func(dc *unstructured.Unstructured) {
// Add volume
volumes, _, _ := unstructured.NestedSlice(dc.Object, "spec", "template", "spec", "volumes")
volumes = append(volumes, map[string]interface{}{
"name": secretName + "-volume",
"secret": map[string]interface{}{
"secretName": secretName,
},
})
_ = unstructured.SetNestedSlice(dc.Object, volumes, "spec", "template", "spec", "volumes")
// Add volumeMount
containers, _, _ := unstructured.NestedSlice(dc.Object, "spec", "template", "spec", "containers")
if len(containers) > 0 {
container := containers[0].(map[string]interface{})
volumeMounts, _, _ := unstructured.NestedSlice(container, "volumeMounts")
volumeMounts = append(volumeMounts, map[string]interface{}{
"name": secretName + "-volume",
"mountPath": "/etc/secrets/" + secretName,
})
container["volumeMounts"] = volumeMounts
containers[0] = container
_ = unstructured.SetNestedSlice(dc.Object, containers, "spec", "template", "spec", "containers")
}
}
}
// WithDCAnnotations adds annotations to the DeploymentConfig's pod template.
func WithDCAnnotations(annotations map[string]string) DCOption {
return func(dc *unstructured.Unstructured) {
annotationsMap := make(map[string]interface{})
for k, v := range annotations {
annotationsMap[k] = v
}
_ = unstructured.SetNestedMap(dc.Object, annotationsMap, "spec", "template", "metadata", "annotations")
}
}
// WaitForDeploymentConfigReady waits for a DeploymentConfig to be ready.
func WaitForDeploymentConfigReady(ctx context.Context, dynamicClient dynamic.Interface, namespace, name string, timeout time.Duration) error {
return wait.PollUntilContextTimeout(ctx, DefaultInterval, timeout, true, func(ctx context.Context) (bool, error) {
dc, err := dynamicClient.Resource(DeploymentConfigGVR).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return false, nil // Keep polling
}
// Check replicas == readyReplicas
replicas, _, _ := unstructured.NestedInt64(dc.Object, "spec", "replicas")
readyReplicas, _, _ := unstructured.NestedInt64(dc.Object, "status", "readyReplicas")
if replicas > 0 && replicas == readyReplicas {
return true, nil
}
return false, nil
})
}
// WaitForDeploymentConfigReloaded waits for a DeploymentConfig's pod template to have the reloader annotation.
func WaitForDeploymentConfigReloaded(ctx context.Context, dynamicClient dynamic.Interface, namespace, name, annotationKey string, timeout time.Duration) (bool, error) {
var found bool
err := wait.PollUntilContextTimeout(ctx, DefaultInterval, timeout, true, func(ctx context.Context) (bool, error) {
dc, err := dynamicClient.Resource(DeploymentConfigGVR).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return false, nil
}
// Check pod template annotations
annotations, _, _ := unstructured.NestedStringMap(dc.Object, "spec", "template", "metadata", "annotations")
if annotations != nil {
if _, ok := annotations[annotationKey]; ok {
found = true
return true, nil
}
}
return false, nil
})
if err != nil && err != context.DeadlineExceeded {
return false, err
}
return found, nil
}
// GetDeploymentConfigPodTemplateAnnotations retrieves the pod template annotations from a DeploymentConfig.
func GetDeploymentConfigPodTemplateAnnotations(ctx context.Context, dynamicClient dynamic.Interface, namespace, name string) (map[string]string, error) {
dc, err := dynamicClient.Resource(DeploymentConfigGVR).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return nil, err
}
annotations, _, _ := unstructured.NestedStringMap(dc.Object, "spec", "template", "metadata", "annotations")
return annotations, nil
}
+26
View File
@@ -0,0 +1,26 @@
package utils
import (
"math/rand"
"time"
)
const letters = "abcdefghijklmnopqrstuvwxyz"
var randSource = rand.New(rand.NewSource(time.Now().UnixNano())) //nolint:gosec
// RandSeq generates a random lowercase string of length n.
// This is useful for creating unique resource names in tests.
func RandSeq(n int) string {
b := make([]byte, n)
for i := range b {
b[i] = letters[randSource.Intn(len(letters))]
}
return string(b)
}
// RandName generates a unique name with the given prefix.
// Format: prefix-xxxxx where x is a random lowercase letter.
func RandName(prefix string) string {
return prefix + "-" + RandSeq(5)
}
+135
View File
@@ -0,0 +1,135 @@
package utils
import (
"regexp"
"testing"
)
func TestRandSeq(t *testing.T) {
tests := []struct {
name string
length int
}{
{"length 0", 0},
{"length 1", 1},
{"length 5", 5},
{"length 10", 10},
{"length 100", 100},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := RandSeq(tt.length)
// Verify length
if len(result) != tt.length {
t.Errorf("RandSeq(%d) returned string of length %d, want %d",
tt.length, len(result), tt.length)
}
// Verify only lowercase letters
if tt.length > 0 {
matched, _ := regexp.MatchString("^[a-z]+$", result)
if !matched {
t.Errorf("RandSeq(%d) = %q, contains non-lowercase letters", tt.length, result)
}
}
})
}
}
func TestRandSeqRandomness(t *testing.T) {
// Generate multiple sequences and verify they're different
// (with very high probability)
const iterations = 10
const length = 20
seen := make(map[string]bool)
for i := 0; i < iterations; i++ {
s := RandSeq(length)
if seen[s] {
// This is extremely unlikely with 20 chars (26^20 possibilities)
t.Errorf("RandSeq generated duplicate: %q", s)
}
seen[s] = true
}
// Verify we got 10 unique strings
if len(seen) != iterations {
t.Errorf("Expected %d unique strings, got %d", iterations, len(seen))
}
}
func TestRandName(t *testing.T) {
tests := []struct {
name string
prefix string
}{
{"deploy prefix", "deploy"},
{"cm prefix", "cm"},
{"secret prefix", "secret"},
{"test-app prefix", "test-app"},
{"empty prefix", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := RandName(tt.prefix)
// Verify format: prefix-xxxxx
expectedPrefix := tt.prefix + "-"
if len(result) <= len(expectedPrefix) {
t.Errorf("RandName(%q) = %q, too short", tt.prefix, result)
return
}
// Check prefix
if result[:len(expectedPrefix)] != expectedPrefix {
t.Errorf("RandName(%q) = %q, doesn't start with %q",
tt.prefix, result, expectedPrefix)
}
// Check random suffix is 5 lowercase letters
suffix := result[len(expectedPrefix):]
if len(suffix) != 5 {
t.Errorf("RandName(%q) suffix length = %d, want 5", tt.prefix, len(suffix))
}
matched, _ := regexp.MatchString("^[a-z]{5}$", suffix)
if !matched {
t.Errorf("RandName(%q) suffix = %q, should be 5 lowercase letters",
tt.prefix, suffix)
}
})
}
}
func TestRandNameUniqueness(t *testing.T) {
// Generate multiple names with same prefix and verify uniqueness
const prefix = "test"
const iterations = 100
seen := make(map[string]bool)
for i := 0; i < iterations; i++ {
name := RandName(prefix)
if seen[name] {
t.Errorf("RandName generated duplicate: %q", name)
}
seen[name] = true
}
}
func TestRandNameKubernetesCompatibility(t *testing.T) {
// Verify generated names are valid Kubernetes resource names
// Must match: [a-z0-9]([-a-z0-9]*[a-z0-9])?
prefixes := []string{"deploy", "cm", "secret", "test-app", "my-resource"}
k8sNamePattern := regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`)
for _, prefix := range prefixes {
name := RandName(prefix)
if !k8sNamePattern.MatchString(name) {
t.Errorf("RandName(%q) = %q is not a valid Kubernetes name", prefix, name)
}
}
}
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
package utils
// MergeAnnotations merges multiple annotation maps into one.
func MergeAnnotations(maps ...map[string]string) map[string]string {
result := make(map[string]string)
for _, m := range maps {
for k, v := range m {
result[k] = v
}
}
return result
}
+148
View File
@@ -0,0 +1,148 @@
package utils
import (
"testing"
)
func TestMergeAnnotations(t *testing.T) {
tests := []struct {
name string
maps []map[string]string
expected map[string]string
}{
{
name: "no maps",
maps: []map[string]string{},
expected: map[string]string{},
},
{
name: "single map",
maps: []map[string]string{
{"key1": "value1"},
},
expected: map[string]string{
"key1": "value1",
},
},
{
name: "two maps no overlap",
maps: []map[string]string{
{"key1": "value1"},
{"key2": "value2"},
},
expected: map[string]string{
"key1": "value1",
"key2": "value2",
},
},
{
name: "three maps with overlap - last wins",
maps: []map[string]string{
{"key1": "value1", "shared": "first"},
{"key2": "value2", "shared": "second"},
{"key3": "value3", "shared": "third"},
},
expected: map[string]string{
"key1": "value1",
"key2": "value2",
"key3": "value3",
"shared": "third", // Last map wins
},
},
{
name: "empty map in the middle",
maps: []map[string]string{
{"key1": "value1"},
{},
{"key2": "value2"},
},
expected: map[string]string{
"key1": "value1",
"key2": "value2",
},
},
{
name: "nil map in the middle",
maps: []map[string]string{
{"key1": "value1"},
nil,
{"key2": "value2"},
},
expected: map[string]string{
"key1": "value1",
"key2": "value2",
},
},
{
name: "realistic use case - auto annotation with reload annotation",
maps: []map[string]string{
BuildAutoTrueAnnotation(),
BuildConfigMapReloadAnnotation("my-config"),
},
expected: map[string]string{
AnnotationAuto: AnnotationValueTrue,
AnnotationConfigMapReload: "my-config",
},
},
{
name: "realistic use case - pause period with reload annotation",
maps: []map[string]string{
BuildConfigMapReloadAnnotation("config1"),
BuildPausePeriodAnnotation("10s"),
},
expected: map[string]string{
AnnotationConfigMapReload: "config1",
AnnotationDeploymentPausePeriod: "10s",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := MergeAnnotations(tt.maps...)
if len(result) != len(tt.expected) {
t.Errorf("MergeAnnotations() returned %d entries, want %d", len(result), len(tt.expected))
t.Errorf("Got: %v", result)
t.Errorf("Want: %v", tt.expected)
return
}
for k, v := range tt.expected {
if result[k] != v {
t.Errorf("MergeAnnotations()[%q] = %q, want %q", k, result[k], v)
}
}
})
}
}
func TestMergeAnnotationsDoesNotModifyInput(t *testing.T) {
// Ensure MergeAnnotations doesn't modify the input maps
map1 := map[string]string{"key1": "value1"}
map2 := map[string]string{"key2": "value2"}
_ = MergeAnnotations(map1, map2)
// Verify original maps are unchanged
if len(map1) != 1 || map1["key1"] != "value1" {
t.Errorf("map1 was modified: %v", map1)
}
if len(map2) != 1 || map2["key2"] != "value2" {
t.Errorf("map2 was modified: %v", map2)
}
}
func TestMergeAnnotationsReturnsNewMap(t *testing.T) {
// Ensure MergeAnnotations returns a new map, not a reference to an input
input := map[string]string{"key1": "value1"}
result := MergeAnnotations(input)
// Modify the result
result["key2"] = "value2"
// Verify original is unchanged
if _, exists := input["key2"]; exists {
t.Error("modifying result affected input map - should return a new map")
}
}
+154
View File
@@ -0,0 +1,154 @@
package utils
import (
"context"
"fmt"
. "github.com/onsi/ginkgo/v2"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/discovery"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/clientcmd"
)
// TestEnvironment holds the common test environment state.
type TestEnvironment struct {
Ctx context.Context
Cancel context.CancelFunc
KubeClient kubernetes.Interface
DynamicClient dynamic.Interface
DiscoveryClient discovery.DiscoveryInterface
Namespace string
ReleaseName string // Unique Helm release name to prevent cluster-scoped resource conflicts
TestImage string
ProjectDir string
}
// SetupTestEnvironment creates a new test environment with kubernetes clients.
// It creates a unique namespace with the given prefix.
func SetupTestEnvironment(ctx context.Context, namespacePrefix string) (*TestEnvironment, error) {
env := &TestEnvironment{
Ctx: ctx,
TestImage: GetTestImage(),
}
var err error
// Get project directory
env.ProjectDir, err = GetProjectDir()
if err != nil {
return nil, fmt.Errorf("getting project directory: %w", err)
}
// Setup Kubernetes client
kubeconfig := GetKubeconfig()
GinkgoWriter.Printf("Using kubeconfig: %s\n", kubeconfig)
config, err := clientcmd.BuildConfigFromFlags("", kubeconfig)
if err != nil {
return nil, fmt.Errorf("building config from kubeconfig: %w", err)
}
env.KubeClient, err = kubernetes.NewForConfig(config)
if err != nil {
return nil, fmt.Errorf("creating kubernetes client: %w", err)
}
env.DynamicClient, err = dynamic.NewForConfig(config)
if err != nil {
return nil, fmt.Errorf("creating dynamic client: %w", err)
}
env.DiscoveryClient, err = discovery.NewDiscoveryClientForConfig(config)
if err != nil {
return nil, fmt.Errorf("creating discovery client: %w", err)
}
// Verify cluster connectivity
GinkgoWriter.Println("Verifying cluster connectivity...")
_, err = env.KubeClient.CoreV1().Namespaces().List(ctx, metav1.ListOptions{Limit: 1})
if err != nil {
return nil, fmt.Errorf("connecting to kubernetes cluster: %w", err)
}
GinkgoWriter.Println("Cluster connectivity verified")
// Create test namespace with random suffix
env.Namespace = RandName(namespacePrefix)
// Use a unique release name to prevent cluster-scoped resource conflicts between test suites
env.ReleaseName = RandName("reloader")
GinkgoWriter.Printf("Creating test namespace: %s\n", env.Namespace)
GinkgoWriter.Printf("Using Helm release name: %s\n", env.ReleaseName)
if err := CreateNamespace(ctx, env.KubeClient, env.Namespace); err != nil {
return nil, fmt.Errorf("creating test namespace: %w", err)
}
GinkgoWriter.Printf("Using test image: %s\n", env.TestImage)
GinkgoWriter.Printf("Project directory: %s\n", env.ProjectDir)
return env, nil
}
// Cleanup cleans up the test environment resources.
func (e *TestEnvironment) Cleanup() error {
if e.Namespace == "" {
return nil
}
GinkgoWriter.Printf("Cleaning up test namespace: %s\n", e.Namespace)
GinkgoWriter.Printf("Cleaning up Helm release: %s\n", e.ReleaseName)
// Collect Reloader logs before cleanup (useful for debugging)
logs, err := GetPodLogs(e.Ctx, e.KubeClient, e.Namespace, ReloaderPodSelector(e.ReleaseName))
if err == nil && logs != "" {
GinkgoWriter.Println("Reloader logs:")
GinkgoWriter.Println(logs)
}
// Undeploy Reloader using the suite's release name
_ = UndeployReloader(e.Namespace, e.ReleaseName)
// Delete test namespace
if err := DeleteNamespace(e.Ctx, e.KubeClient, e.Namespace); err != nil {
return fmt.Errorf("deleting namespace: %w", err)
}
return nil
}
// DeployReloaderWithStrategy deploys Reloader with the specified reload strategy.
func (e *TestEnvironment) DeployReloaderWithStrategy(strategy string) error {
return e.DeployReloaderWithValues(map[string]string{
"reloader.reloadStrategy": strategy,
})
}
// DeployReloaderWithValues deploys Reloader with the specified Helm values.
// Each test suite uses a unique release name to prevent cluster-scoped resource conflicts.
func (e *TestEnvironment) DeployReloaderWithValues(values map[string]string) error {
GinkgoWriter.Printf("Deploying Reloader with values: %v\n", values)
return DeployReloader(DeployOptions{
Namespace: e.Namespace,
ReleaseName: e.ReleaseName,
Image: e.TestImage,
Values: values,
})
}
// WaitForReloader waits for the Reloader deployment to be ready.
func (e *TestEnvironment) WaitForReloader() error {
GinkgoWriter.Println("Waiting for Reloader to be ready...")
return WaitForDeploymentReady(e.Ctx, e.KubeClient, e.Namespace, ReloaderDeploymentName(e.ReleaseName), DeploymentReady)
}
// DeployAndWait deploys Reloader with the given values and waits for it to be ready.
func (e *TestEnvironment) DeployAndWait(values map[string]string) error {
if err := e.DeployReloaderWithValues(values); err != nil {
return fmt.Errorf("deploying Reloader: %w", err)
}
if err := e.WaitForReloader(); err != nil {
return fmt.Errorf("waiting for Reloader: %w", err)
}
GinkgoWriter.Println("Reloader is ready")
return nil
}
+114
View File
@@ -0,0 +1,114 @@
// Package utils provides helper functions for e2e tests.
package utils
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
. "github.com/onsi/ginkgo/v2" //nolint:revive,staticcheck
)
// Run executes the provided command and returns its combined stdout/stderr output.
// The command is executed from the project directory.
func Run(cmd *exec.Cmd) (string, error) {
dir, err := GetProjectDir()
if err != nil {
return "", fmt.Errorf("failed to get project dir: %w", err)
}
cmd.Dir = dir
if err := os.Chdir(cmd.Dir); err != nil {
_, _ = fmt.Fprintf(GinkgoWriter, "chdir dir: %q\n", err)
}
cmd.Env = append(os.Environ(), "GO111MODULE=on")
command := strings.Join(cmd.Args, " ")
_, _ = fmt.Fprintf(GinkgoWriter, "running: %q\n", command)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err = cmd.Run()
output := stdout.String() + stderr.String()
if err != nil {
return output, fmt.Errorf("%q failed with error %q: %w", command, output, err)
}
return output, nil
}
// GetProjectDir returns the root directory of the project.
// It works by finding the directory containing go.mod.
func GetProjectDir() (string, error) {
wd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("failed to get current working directory: %w", err)
}
// Walk up the directory tree looking for go.mod
dir := wd
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
// Reached root without finding go.mod
break
}
dir = parent
}
// Fallback: try to strip common test paths
wd = strings.ReplaceAll(wd, "/test/e2e", "")
wd = strings.ReplaceAll(wd, "/test/e2e/annotations", "")
wd = strings.ReplaceAll(wd, "/test/e2e/envvars", "")
wd = strings.ReplaceAll(wd, "/test/e2e/flags", "")
wd = strings.ReplaceAll(wd, "/test/e2e/advanced", "")
wd = strings.ReplaceAll(wd, "/test/e2e/argo", "")
wd = strings.ReplaceAll(wd, "/test/e2e/openshift", "")
return wd, nil
}
// GetNonEmptyLines splits the given output string into individual lines,
// filtering out empty lines.
func GetNonEmptyLines(output string) []string {
var result []string
lines := strings.Split(output, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
// GetEnvOrDefault returns the value of the environment variable named by key,
// or defaultValue if the variable is not present or empty.
func GetEnvOrDefault(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
// GetKubeconfig returns the path to the kubeconfig file.
// It checks KUBECONFIG environment variable first, then falls back to ~/.kube/config.
func GetKubeconfig() string {
if kubeconfig := os.Getenv("KUBECONFIG"); kubeconfig != "" {
return kubeconfig
}
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".kube", "config")
}
+498
View File
@@ -0,0 +1,498 @@
package utils
import (
"context"
"fmt"
"strings"
"time"
appsv1 "k8s.io/api/apps/v1"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/wait"
"k8s.io/client-go/kubernetes"
)
// Timeout and interval constants for polling operations.
const (
DefaultTimeout = 30 * time.Second // General operations
DefaultInterval = 1 * time.Second // Polling interval (faster feedback)
ShortTimeout = 5 * time.Second // Quick checks
NegativeTestWait = 3 * time.Second // Wait before checking negative conditions
DeploymentReady = 60 * time.Second // Workload readiness (buffer for CI)
ReloadTimeout = 15 * time.Second // Time for reload to trigger
)
// WaitForDeploymentReady waits for a deployment to have all replicas available.
func WaitForDeploymentReady(ctx context.Context, client kubernetes.Interface, namespace, name string, timeout time.Duration) error {
return wait.PollUntilContextTimeout(ctx, DefaultInterval, timeout, true, func(ctx context.Context) (bool, error) {
deploy, err := client.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return false, nil // Keep polling
}
// Check if deployment is ready
if deploy.Status.ReadyReplicas == *deploy.Spec.Replicas &&
deploy.Status.UpdatedReplicas == *deploy.Spec.Replicas &&
deploy.Status.AvailableReplicas == *deploy.Spec.Replicas {
return true, nil
}
return false, nil
})
}
// WaitForDeploymentReloaded waits for a deployment's pod template to have the reloader annotation.
// Returns true if the annotation was found, false if timeout occurred.
func WaitForDeploymentReloaded(ctx context.Context, client kubernetes.Interface, namespace, name, annotationKey string, timeout time.Duration) (bool, error) {
var found bool
err := wait.PollUntilContextTimeout(ctx, DefaultInterval, timeout, true, func(ctx context.Context) (bool, error) {
deploy, err := client.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return false, nil // Keep polling
}
// Check pod template annotations
if deploy.Spec.Template.Annotations != nil {
if _, ok := deploy.Spec.Template.Annotations[annotationKey]; ok {
found = true
return true, nil
}
}
return false, nil
})
if err != nil && err != context.DeadlineExceeded {
return false, err
}
return found, nil
}
// WaitForDaemonSetReloaded waits for a DaemonSet's pod template to have the reloader annotation.
func WaitForDaemonSetReloaded(ctx context.Context, client kubernetes.Interface, namespace, name, annotationKey string, timeout time.Duration) (bool, error) {
var found bool
err := wait.PollUntilContextTimeout(ctx, DefaultInterval, timeout, true, func(ctx context.Context) (bool, error) {
ds, err := client.AppsV1().DaemonSets(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return false, nil
}
if ds.Spec.Template.Annotations != nil {
if _, ok := ds.Spec.Template.Annotations[annotationKey]; ok {
found = true
return true, nil
}
}
return false, nil
})
if err != nil && err != context.DeadlineExceeded {
return false, err
}
return found, nil
}
// WaitForStatefulSetReloaded waits for a StatefulSet's pod template to have the reloader annotation.
func WaitForStatefulSetReloaded(ctx context.Context, client kubernetes.Interface, namespace, name, annotationKey string, timeout time.Duration) (bool, error) {
var found bool
err := wait.PollUntilContextTimeout(ctx, DefaultInterval, timeout, true, func(ctx context.Context) (bool, error) {
ss, err := client.AppsV1().StatefulSets(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return false, nil
}
if ss.Spec.Template.Annotations != nil {
if _, ok := ss.Spec.Template.Annotations[annotationKey]; ok {
found = true
return true, nil
}
}
return false, nil
})
if err != nil && err != context.DeadlineExceeded {
return false, err
}
return found, nil
}
// WaitForCronJobReloaded waits for a CronJob's pod template to have the reloader annotation.
func WaitForCronJobReloaded(ctx context.Context, client kubernetes.Interface, namespace, name, annotationKey string, timeout time.Duration) (bool, error) {
var found bool
err := wait.PollUntilContextTimeout(ctx, DefaultInterval, timeout, true, func(ctx context.Context) (bool, error) {
cj, err := client.BatchV1().CronJobs(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return false, nil
}
if cj.Spec.JobTemplate.Spec.Template.Annotations != nil {
if _, ok := cj.Spec.JobTemplate.Spec.Template.Annotations[annotationKey]; ok {
found = true
return true, nil
}
}
return false, nil
})
if err != nil && err != context.DeadlineExceeded {
return false, err
}
return found, nil
}
// WaitForJobCreated waits for a Job to be created with the given label selector.
func WaitForJobCreated(ctx context.Context, client kubernetes.Interface, namespace, labelSelector string, timeout time.Duration) (bool, error) {
var found bool
err := wait.PollUntilContextTimeout(ctx, DefaultInterval, timeout, true, func(ctx context.Context) (bool, error) {
jobs, err := client.BatchV1().Jobs(namespace).List(ctx, metav1.ListOptions{
LabelSelector: labelSelector,
})
if err != nil {
return false, nil
}
if len(jobs.Items) > 0 {
found = true
return true, nil
}
return false, nil
})
if err != nil && err != context.DeadlineExceeded {
return false, err
}
return found, nil
}
// WaitForCronJobTriggeredJob waits for a Job to be created by the specified CronJob.
// It checks owner references to find Jobs created by Reloader's manual trigger.
func WaitForCronJobTriggeredJob(ctx context.Context, client kubernetes.Interface, namespace, cronJobName string, timeout time.Duration) (bool, error) {
var found bool
err := wait.PollUntilContextTimeout(ctx, DefaultInterval, timeout, true, func(ctx context.Context) (bool, error) {
jobs, err := client.BatchV1().Jobs(namespace).List(ctx, metav1.ListOptions{})
if err != nil {
return false, nil
}
for _, job := range jobs.Items {
// Check if this job is owned by the CronJob
for _, ownerRef := range job.OwnerReferences {
if ownerRef.Kind == "CronJob" && ownerRef.Name == cronJobName {
// Check for the manual instantiate annotation (added by Reloader)
if job.Annotations != nil {
if _, ok := job.Annotations["cronjob.kubernetes.io/instantiate"]; ok {
found = true
return true, nil
}
}
}
}
}
return false, nil
})
if err != nil && err != context.DeadlineExceeded {
return false, err
}
return found, nil
}
// WaitForDeploymentEnvVar waits for a deployment's containers to have an environment variable
// with the given prefix (e.g., "STAKATER_").
func WaitForDeploymentEnvVar(ctx context.Context, client kubernetes.Interface, namespace, name, prefix string, timeout time.Duration) (bool, error) {
var found bool
err := wait.PollUntilContextTimeout(ctx, DefaultInterval, timeout, true, func(ctx context.Context) (bool, error) {
deploy, err := client.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return false, nil
}
if hasEnvVarWithPrefix(deploy.Spec.Template.Spec.Containers, prefix) {
found = true
return true, nil
}
return false, nil
})
if err != nil && err != context.DeadlineExceeded {
return false, err
}
return found, nil
}
// WaitForDaemonSetEnvVar waits for a DaemonSet's containers to have an environment variable
// with the given prefix.
func WaitForDaemonSetEnvVar(ctx context.Context, client kubernetes.Interface, namespace, name, prefix string, timeout time.Duration) (bool, error) {
var found bool
err := wait.PollUntilContextTimeout(ctx, DefaultInterval, timeout, true, func(ctx context.Context) (bool, error) {
ds, err := client.AppsV1().DaemonSets(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return false, nil
}
if hasEnvVarWithPrefix(ds.Spec.Template.Spec.Containers, prefix) {
found = true
return true, nil
}
return false, nil
})
if err != nil && err != context.DeadlineExceeded {
return false, err
}
return found, nil
}
// WaitForStatefulSetEnvVar waits for a StatefulSet's containers to have an environment variable
// with the given prefix.
func WaitForStatefulSetEnvVar(ctx context.Context, client kubernetes.Interface, namespace, name, prefix string, timeout time.Duration) (bool, error) {
var found bool
err := wait.PollUntilContextTimeout(ctx, DefaultInterval, timeout, true, func(ctx context.Context) (bool, error) {
ss, err := client.AppsV1().StatefulSets(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return false, nil
}
if hasEnvVarWithPrefix(ss.Spec.Template.Spec.Containers, prefix) {
found = true
return true, nil
}
return false, nil
})
if err != nil && err != context.DeadlineExceeded {
return false, err
}
return found, nil
}
// WaitForDeploymentPaused waits for a deployment to have the paused-at annotation.
func WaitForDeploymentPaused(ctx context.Context, client kubernetes.Interface, namespace, name, pausedAtAnnotation string, timeout time.Duration) (bool, error) {
var found bool
err := wait.PollUntilContextTimeout(ctx, DefaultInterval, timeout, true, func(ctx context.Context) (bool, error) {
deploy, err := client.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return false, nil
}
// Check deployment annotations (not pod template)
if deploy.Annotations != nil {
if _, ok := deploy.Annotations[pausedAtAnnotation]; ok {
found = true
return true, nil
}
}
return false, nil
})
if err != nil && err != context.DeadlineExceeded {
return false, err
}
return found, nil
}
// WaitForDeploymentUnpaused waits for a deployment to NOT have the paused-at annotation.
func WaitForDeploymentUnpaused(ctx context.Context, client kubernetes.Interface, namespace, name, pausedAtAnnotation string, timeout time.Duration) (bool, error) {
var unpaused bool
err := wait.PollUntilContextTimeout(ctx, DefaultInterval, timeout, true, func(ctx context.Context) (bool, error) {
deploy, err := client.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return false, nil
}
// Check if paused-at annotation is gone
if deploy.Annotations == nil {
unpaused = true
return true, nil
}
if _, ok := deploy.Annotations[pausedAtAnnotation]; !ok {
unpaused = true
return true, nil
}
return false, nil
})
if err != nil && err != context.DeadlineExceeded {
return false, err
}
return unpaused, nil
}
// WaitForDaemonSetReady waits for a DaemonSet to have all pods ready.
func WaitForDaemonSetReady(ctx context.Context, client kubernetes.Interface, namespace, name string, timeout time.Duration) error {
return wait.PollUntilContextTimeout(ctx, DefaultInterval, timeout, true, func(ctx context.Context) (bool, error) {
ds, err := client.AppsV1().DaemonSets(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return false, nil
}
if ds.Status.DesiredNumberScheduled > 0 &&
ds.Status.NumberReady == ds.Status.DesiredNumberScheduled {
return true, nil
}
return false, nil
})
}
// WaitForStatefulSetReady waits for a StatefulSet to have all replicas ready.
func WaitForStatefulSetReady(ctx context.Context, client kubernetes.Interface, namespace, name string, timeout time.Duration) error {
return wait.PollUntilContextTimeout(ctx, DefaultInterval, timeout, true, func(ctx context.Context) (bool, error) {
ss, err := client.AppsV1().StatefulSets(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return false, nil
}
if ss.Status.ReadyReplicas == *ss.Spec.Replicas {
return true, nil
}
return false, nil
})
}
// GetDeployment retrieves a deployment by name.
func GetDeployment(ctx context.Context, client kubernetes.Interface, namespace, name string) (*appsv1.Deployment, error) {
return client.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{})
}
// GetDaemonSet retrieves a DaemonSet by name.
func GetDaemonSet(ctx context.Context, client kubernetes.Interface, namespace, name string) (*appsv1.DaemonSet, error) {
return client.AppsV1().DaemonSets(namespace).Get(ctx, name, metav1.GetOptions{})
}
// GetStatefulSet retrieves a StatefulSet by name.
func GetStatefulSet(ctx context.Context, client kubernetes.Interface, namespace, name string) (*appsv1.StatefulSet, error) {
return client.AppsV1().StatefulSets(namespace).Get(ctx, name, metav1.GetOptions{})
}
// GetCronJob retrieves a CronJob by name.
func GetCronJob(ctx context.Context, client kubernetes.Interface, namespace, name string) (*batchv1.CronJob, error) {
return client.BatchV1().CronJobs(namespace).Get(ctx, name, metav1.GetOptions{})
}
// WaitForCronJobExists waits for a CronJob to exist in the cluster.
// This is useful for giving Reloader time to detect and index the CronJob before making changes.
func WaitForCronJobExists(ctx context.Context, client kubernetes.Interface, namespace, name string, timeout time.Duration) error {
return wait.PollUntilContextTimeout(ctx, DefaultInterval, timeout, true, func(ctx context.Context) (bool, error) {
_, err := client.BatchV1().CronJobs(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return false, nil // Keep polling
}
return true, nil
})
}
// GetJob retrieves a Job by name.
func GetJob(ctx context.Context, client kubernetes.Interface, namespace, name string) (*batchv1.Job, error) {
return client.BatchV1().Jobs(namespace).Get(ctx, name, metav1.GetOptions{})
}
// hasEnvVarWithPrefix checks if any container has an environment variable with the given prefix.
func hasEnvVarWithPrefix(containers []corev1.Container, prefix string) bool {
for _, container := range containers {
for _, env := range container.Env {
if strings.HasPrefix(env.Name, prefix) {
return true
}
}
}
return false
}
// WaitForJobRecreated waits for a Job to be deleted and recreated with a new UID.
// Returns the new Job's UID if recreation was detected.
func WaitForJobRecreated(ctx context.Context, client kubernetes.Interface, namespace, name, originalUID string, timeout time.Duration) (string, bool, error) {
var newUID string
var recreated bool
err := wait.PollUntilContextTimeout(ctx, DefaultInterval, timeout, true, func(ctx context.Context) (bool, error) {
job, err := client.BatchV1().Jobs(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
// Job not found means it's been deleted, keep polling for recreation
return false, nil
}
// Check if the UID has changed (indicating recreation)
if string(job.UID) != originalUID {
newUID = string(job.UID)
recreated = true
return true, nil
}
return false, nil
})
if err != nil && err != context.DeadlineExceeded {
return "", false, err
}
return newUID, recreated, nil
}
// WaitForJobNotFound waits for a Job to be deleted.
func WaitForJobNotFound(ctx context.Context, client kubernetes.Interface, namespace, name string, timeout time.Duration) (bool, error) {
var deleted bool
err := wait.PollUntilContextTimeout(ctx, DefaultInterval, timeout, true, func(ctx context.Context) (bool, error) {
_, err := client.BatchV1().Jobs(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
deleted = true
return true, nil
}
return false, nil
})
if err != nil && err != context.DeadlineExceeded {
return false, err
}
return deleted, nil
}
// WaitForJobExists waits for a Job to exist in the cluster.
func WaitForJobExists(ctx context.Context, client kubernetes.Interface, namespace, name string, timeout time.Duration) error {
return wait.PollUntilContextTimeout(ctx, DefaultInterval, timeout, true, func(ctx context.Context) (bool, error) {
_, err := client.BatchV1().Jobs(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return false, nil // Keep polling
}
return true, nil
})
}
// GetPodLogs retrieves logs from pods matching the given label selector.
func GetPodLogs(ctx context.Context, client kubernetes.Interface, namespace, labelSelector string) (string, error) {
pods, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
LabelSelector: labelSelector,
})
if err != nil {
return "", fmt.Errorf("failed to list pods: %w", err)
}
var allLogs strings.Builder
for _, pod := range pods.Items {
for _, container := range pod.Spec.Containers {
logs, err := client.CoreV1().Pods(namespace).GetLogs(pod.Name, &corev1.PodLogOptions{
Container: container.Name,
}).Do(ctx).Raw()
if err != nil {
allLogs.WriteString(fmt.Sprintf("Error getting logs for %s/%s: %v\n", pod.Name, container.Name, err))
continue
}
allLogs.WriteString(fmt.Sprintf("=== %s/%s ===\n%s\n", pod.Name, container.Name, string(logs)))
}
}
return allLogs.String(), nil
}
+160
View File
@@ -0,0 +1,160 @@
package utils
import (
"context"
"time"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes"
)
// WorkloadType represents the type of Kubernetes workload.
type WorkloadType string
const (
WorkloadDeployment WorkloadType = "Deployment"
WorkloadDaemonSet WorkloadType = "DaemonSet"
WorkloadStatefulSet WorkloadType = "StatefulSet"
WorkloadCronJob WorkloadType = "CronJob"
WorkloadJob WorkloadType = "Job"
WorkloadArgoRollout WorkloadType = "ArgoRollout"
WorkloadDeploymentConfig WorkloadType = "DeploymentConfig"
)
// ReloadStrategy represents the reload strategy used by Reloader.
type ReloadStrategy string
const (
StrategyAnnotations ReloadStrategy = "annotations"
StrategyEnvVars ReloadStrategy = "envvars"
)
// WorkloadConfig holds configuration for workload creation.
type WorkloadConfig struct {
// Resource references
ConfigMapName string
SecretName string
// Annotations to set on the workload
Annotations map[string]string
// Reference methods (flags - multiple can be true)
UseConfigMapEnvFrom bool
UseSecretEnvFrom bool
UseConfigMapVolume bool
UseSecretVolume bool
UseProjectedVolume bool
UseConfigMapKeyRef bool
UseSecretKeyRef bool
UseInitContainer bool
UseInitContainerVolume bool
// For valueFrom references
ConfigMapKey string
SecretKey string
EnvVarName string
// Special options
MultipleContainers int // Number of containers (0 or 1 means single container)
}
// WorkloadAdapter provides a unified interface for all workload types.
// This allows tests to be parameterized across different workload types.
type WorkloadAdapter interface {
// Type returns the workload type.
Type() WorkloadType
// Create creates the workload with the given config.
Create(ctx context.Context, namespace, name string, cfg WorkloadConfig) error
// Delete removes the workload.
Delete(ctx context.Context, namespace, name string) error
// WaitReady waits for the workload to be ready.
WaitReady(ctx context.Context, namespace, name string, timeout time.Duration) error
// WaitReloaded waits for the workload to have the reload annotation.
// Returns true if the annotation was found, false if timeout occurred.
WaitReloaded(ctx context.Context, namespace, name, annotationKey string, timeout time.Duration) (bool, error)
// WaitEnvVar waits for the workload to have a STAKATER_ env var (for envvars strategy).
// Returns true if the env var was found, false if timeout occurred.
WaitEnvVar(ctx context.Context, namespace, name, prefix string, timeout time.Duration) (bool, error)
// SupportsEnvVarStrategy returns true if the workload supports env var reload strategy.
// CronJob does not support this as it uses job creation instead.
SupportsEnvVarStrategy() bool
// RequiresSpecialHandling returns true for workloads that need special handling.
// For example, CronJob triggers a new job instead of rolling restart.
RequiresSpecialHandling() bool
}
// AdapterRegistry holds adapters for all workload types.
type AdapterRegistry struct {
kubeClient kubernetes.Interface
dynamicClient dynamic.Interface
adapters map[WorkloadType]WorkloadAdapter
}
// NewAdapterRegistry creates a new adapter registry with all standard adapters.
func NewAdapterRegistry(kubeClient kubernetes.Interface, dynamicClient dynamic.Interface) *AdapterRegistry {
r := &AdapterRegistry{
kubeClient: kubeClient,
dynamicClient: dynamicClient,
adapters: make(map[WorkloadType]WorkloadAdapter),
}
// Register standard adapters
r.adapters[WorkloadDeployment] = NewDeploymentAdapter(kubeClient)
r.adapters[WorkloadDaemonSet] = NewDaemonSetAdapter(kubeClient)
r.adapters[WorkloadStatefulSet] = NewStatefulSetAdapter(kubeClient)
r.adapters[WorkloadCronJob] = NewCronJobAdapter(kubeClient)
r.adapters[WorkloadJob] = NewJobAdapter(kubeClient)
// Argo and OpenShift adapters are registered separately via RegisterAdapter
// as they require specific cluster support
return r
}
// RegisterAdapter registers a custom adapter for a workload type.
// Use this to add Argo Rollout or DeploymentConfig adapters.
func (r *AdapterRegistry) RegisterAdapter(adapter WorkloadAdapter) {
r.adapters[adapter.Type()] = adapter
}
// Get returns the adapter for the given workload type.
// Returns nil if the adapter is not registered.
func (r *AdapterRegistry) Get(wt WorkloadType) WorkloadAdapter {
return r.adapters[wt]
}
// GetStandardWorkloads returns the standard workload types that are always available.
func (r *AdapterRegistry) GetStandardWorkloads() []WorkloadType {
return []WorkloadType{
WorkloadDeployment,
WorkloadDaemonSet,
WorkloadStatefulSet,
}
}
// GetAllWorkloads returns all registered workload types.
func (r *AdapterRegistry) GetAllWorkloads() []WorkloadType {
result := make([]WorkloadType, 0, len(r.adapters))
for wt := range r.adapters {
result = append(result, wt)
}
return result
}
// GetEnvVarWorkloads returns workload types that support env var reload strategy.
func (r *AdapterRegistry) GetEnvVarWorkloads() []WorkloadType {
result := make([]WorkloadType, 0)
for wt, adapter := range r.adapters {
if adapter.SupportsEnvVarStrategy() {
result = append(result, wt)
}
}
return result
}
+340
View File
@@ -0,0 +1,340 @@
package utils
import (
"context"
"fmt"
"strings"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/util/wait"
"k8s.io/client-go/dynamic"
)
// ArgoRolloutAdapter implements WorkloadAdapter for Argo Rollouts.
type ArgoRolloutAdapter struct {
dynamicClient dynamic.Interface
}
// NewArgoRolloutAdapter creates a new ArgoRolloutAdapter.
func NewArgoRolloutAdapter(dynamicClient dynamic.Interface) *ArgoRolloutAdapter {
return &ArgoRolloutAdapter{dynamicClient: dynamicClient}
}
// Type returns the workload type.
func (a *ArgoRolloutAdapter) Type() WorkloadType {
return WorkloadArgoRollout
}
// Create creates an Argo Rollout with the given config.
func (a *ArgoRolloutAdapter) Create(ctx context.Context, namespace, name string, cfg WorkloadConfig) error {
opts := buildRolloutOptions(cfg)
return CreateArgoRollout(ctx, a.dynamicClient, namespace, name, opts...)
}
// Delete removes the Argo Rollout.
func (a *ArgoRolloutAdapter) Delete(ctx context.Context, namespace, name string) error {
return DeleteArgoRollout(ctx, a.dynamicClient, namespace, name)
}
// WaitReady waits for the Argo Rollout to be ready.
func (a *ArgoRolloutAdapter) WaitReady(ctx context.Context, namespace, name string, timeout time.Duration) error {
return WaitForRolloutReady(ctx, a.dynamicClient, namespace, name, timeout)
}
// WaitReloaded waits for the Argo Rollout to have the reload annotation.
func (a *ArgoRolloutAdapter) WaitReloaded(ctx context.Context, namespace, name, annotationKey string, timeout time.Duration) (bool, error) {
return WaitForRolloutReloaded(ctx, a.dynamicClient, namespace, name, annotationKey, timeout)
}
// WaitEnvVar waits for the Argo Rollout to have a STAKATER_ env var.
func (a *ArgoRolloutAdapter) WaitEnvVar(ctx context.Context, namespace, name, prefix string, timeout time.Duration) (bool, error) {
return WaitForRolloutEnvVar(ctx, a.dynamicClient, namespace, name, prefix, timeout)
}
// SupportsEnvVarStrategy returns true as Argo Rollouts support env var reload strategy.
func (a *ArgoRolloutAdapter) SupportsEnvVarStrategy() bool {
return true
}
// RequiresSpecialHandling returns false as Argo Rollouts use standard rolling restart.
func (a *ArgoRolloutAdapter) RequiresSpecialHandling() bool {
return false
}
// buildRolloutOptions converts WorkloadConfig to RolloutOption slice.
func buildRolloutOptions(cfg WorkloadConfig) []RolloutOption {
var opts []RolloutOption
// Add annotations (to pod template)
if len(cfg.Annotations) > 0 {
opts = append(opts, WithRolloutAnnotations(cfg.Annotations))
}
// Add envFrom references
if cfg.UseConfigMapEnvFrom && cfg.ConfigMapName != "" {
opts = append(opts, WithRolloutConfigMapEnvFrom(cfg.ConfigMapName))
}
if cfg.UseSecretEnvFrom && cfg.SecretName != "" {
opts = append(opts, WithRolloutSecretEnvFrom(cfg.SecretName))
}
// Add volume mounts
if cfg.UseConfigMapVolume && cfg.ConfigMapName != "" {
opts = append(opts, WithRolloutConfigMapVolume(cfg.ConfigMapName))
}
if cfg.UseSecretVolume && cfg.SecretName != "" {
opts = append(opts, WithRolloutSecretVolume(cfg.SecretName))
}
// Add projected volume
if cfg.UseProjectedVolume {
opts = append(opts, WithRolloutProjectedVolume(cfg.ConfigMapName, cfg.SecretName))
}
// Add valueFrom references
if cfg.UseConfigMapKeyRef && cfg.ConfigMapName != "" {
key := cfg.ConfigMapKey
if key == "" {
key = "key"
}
envVar := cfg.EnvVarName
if envVar == "" {
envVar = "CONFIG_VAR"
}
opts = append(opts, WithRolloutConfigMapKeyRef(cfg.ConfigMapName, key, envVar))
}
if cfg.UseSecretKeyRef && cfg.SecretName != "" {
key := cfg.SecretKey
if key == "" {
key = "key"
}
envVar := cfg.EnvVarName
if envVar == "" {
envVar = "SECRET_VAR"
}
opts = append(opts, WithRolloutSecretKeyRef(cfg.SecretName, key, envVar))
}
// Add init container with envFrom
if cfg.UseInitContainer {
opts = append(opts, WithRolloutInitContainer(cfg.ConfigMapName, cfg.SecretName))
}
// Add init container with volume mount
if cfg.UseInitContainerVolume {
opts = append(opts, WithRolloutInitContainerVolume(cfg.ConfigMapName, cfg.SecretName))
}
return opts
}
// WithRolloutProjectedVolume adds a projected volume with ConfigMap and/or Secret sources to a Rollout.
func WithRolloutProjectedVolume(cmName, secretName string) RolloutOption {
return func(rollout *unstructured.Unstructured) {
volumeName := "projected-config"
sources := []interface{}{}
if cmName != "" {
sources = append(sources, map[string]interface{}{
"configMap": map[string]interface{}{
"name": cmName,
},
})
}
if secretName != "" {
sources = append(sources, map[string]interface{}{
"secret": map[string]interface{}{
"name": secretName,
},
})
}
// Add volume
volumes, _, _ := unstructured.NestedSlice(rollout.Object, "spec", "template", "spec", "volumes")
volumes = append(volumes, map[string]interface{}{
"name": volumeName,
"projected": map[string]interface{}{
"sources": sources,
},
})
_ = unstructured.SetNestedSlice(rollout.Object, volumes, "spec", "template", "spec", "volumes")
// Add volumeMount
containers, _, _ := unstructured.NestedSlice(rollout.Object, "spec", "template", "spec", "containers")
if len(containers) > 0 {
container := containers[0].(map[string]interface{})
volumeMounts, _, _ := unstructured.NestedSlice(container, "volumeMounts")
volumeMounts = append(volumeMounts, map[string]interface{}{
"name": volumeName,
"mountPath": "/etc/projected",
})
container["volumeMounts"] = volumeMounts
containers[0] = container
_ = unstructured.SetNestedSlice(rollout.Object, containers, "spec", "template", "spec", "containers")
}
}
}
// WithRolloutConfigMapKeyRef adds an env var with valueFrom.configMapKeyRef to a Rollout.
func WithRolloutConfigMapKeyRef(cmName, key, envVarName string) RolloutOption {
return func(rollout *unstructured.Unstructured) {
containers, _, _ := unstructured.NestedSlice(rollout.Object, "spec", "template", "spec", "containers")
if len(containers) > 0 {
container := containers[0].(map[string]interface{})
env, _, _ := unstructured.NestedSlice(container, "env")
env = append(env, map[string]interface{}{
"name": envVarName,
"valueFrom": map[string]interface{}{
"configMapKeyRef": map[string]interface{}{
"name": cmName,
"key": key,
},
},
})
container["env"] = env
containers[0] = container
_ = unstructured.SetNestedSlice(rollout.Object, containers, "spec", "template", "spec", "containers")
}
}
}
// WithRolloutSecretKeyRef adds an env var with valueFrom.secretKeyRef to a Rollout.
func WithRolloutSecretKeyRef(secretName, key, envVarName string) RolloutOption {
return func(rollout *unstructured.Unstructured) {
containers, _, _ := unstructured.NestedSlice(rollout.Object, "spec", "template", "spec", "containers")
if len(containers) > 0 {
container := containers[0].(map[string]interface{})
env, _, _ := unstructured.NestedSlice(container, "env")
env = append(env, map[string]interface{}{
"name": envVarName,
"valueFrom": map[string]interface{}{
"secretKeyRef": map[string]interface{}{
"name": secretName,
"key": key,
},
},
})
container["env"] = env
containers[0] = container
_ = unstructured.SetNestedSlice(rollout.Object, containers, "spec", "template", "spec", "containers")
}
}
}
// WithRolloutInitContainer adds an init container that references ConfigMap and/or Secret.
func WithRolloutInitContainer(cmName, secretName string) RolloutOption {
return func(rollout *unstructured.Unstructured) {
initContainer := map[string]interface{}{
"name": "init",
"image": DefaultImage,
"command": []interface{}{"sh", "-c", "echo init done"},
}
envFrom := []interface{}{}
if cmName != "" {
envFrom = append(envFrom, map[string]interface{}{
"configMapRef": map[string]interface{}{
"name": cmName,
},
})
}
if secretName != "" {
envFrom = append(envFrom, map[string]interface{}{
"secretRef": map[string]interface{}{
"name": secretName,
},
})
}
if len(envFrom) > 0 {
initContainer["envFrom"] = envFrom
}
initContainers, _, _ := unstructured.NestedSlice(rollout.Object, "spec", "template", "spec", "initContainers")
initContainers = append(initContainers, initContainer)
_ = unstructured.SetNestedSlice(rollout.Object, initContainers, "spec", "template", "spec", "initContainers")
}
}
// WithRolloutInitContainerVolume adds an init container with ConfigMap/Secret volume mounts.
func WithRolloutInitContainerVolume(cmName, secretName string) RolloutOption {
return func(rollout *unstructured.Unstructured) {
initContainer := map[string]interface{}{
"name": "init",
"image": DefaultImage,
"command": []interface{}{"sh", "-c", "echo init done"},
}
volumeMounts := []interface{}{}
volumes, _, _ := unstructured.NestedSlice(rollout.Object, "spec", "template", "spec", "volumes")
if cmName != "" {
volumeName := fmt.Sprintf("init-cm-%s", cmName)
volumes = append(volumes, map[string]interface{}{
"name": volumeName,
"configMap": map[string]interface{}{
"name": cmName,
},
})
volumeMounts = append(volumeMounts, map[string]interface{}{
"name": volumeName,
"mountPath": fmt.Sprintf("/etc/init-config/%s", cmName),
})
}
if secretName != "" {
volumeName := fmt.Sprintf("init-secret-%s", secretName)
volumes = append(volumes, map[string]interface{}{
"name": volumeName,
"secret": map[string]interface{}{
"secretName": secretName,
},
})
volumeMounts = append(volumeMounts, map[string]interface{}{
"name": volumeName,
"mountPath": fmt.Sprintf("/etc/init-secrets/%s", secretName),
})
}
if len(volumeMounts) > 0 {
initContainer["volumeMounts"] = volumeMounts
}
_ = unstructured.SetNestedSlice(rollout.Object, volumes, "spec", "template", "spec", "volumes")
initContainers, _, _ := unstructured.NestedSlice(rollout.Object, "spec", "template", "spec", "initContainers")
initContainers = append(initContainers, initContainer)
_ = unstructured.SetNestedSlice(rollout.Object, initContainers, "spec", "template", "spec", "initContainers")
}
}
// WaitForRolloutEnvVar waits for an Argo Rollout's container to have an env var with the given prefix.
func WaitForRolloutEnvVar(ctx context.Context, dynamicClient dynamic.Interface, namespace, name, prefix string, timeout time.Duration) (bool, error) {
var found bool
err := wait.PollUntilContextTimeout(ctx, DefaultInterval, timeout, true, func(ctx context.Context) (bool, error) {
rollout, err := dynamicClient.Resource(ArgoRolloutGVR).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return false, nil
}
containers, _, _ := unstructured.NestedSlice(rollout.Object, "spec", "template", "spec", "containers")
for _, c := range containers {
container := c.(map[string]interface{})
env, _, _ := unstructured.NestedSlice(container, "env")
for _, e := range env {
envVar := e.(map[string]interface{})
if name, ok := envVar["name"].(string); ok && strings.HasPrefix(name, prefix) {
found = true
return true, nil
}
}
}
return false, nil
})
if err != nil && err != context.DeadlineExceeded {
return false, err
}
return found, nil
}
+223
View File
@@ -0,0 +1,223 @@
package utils
import (
"context"
"time"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/util/wait"
"k8s.io/client-go/kubernetes"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// CronJobAdapter implements WorkloadAdapter for Kubernetes CronJobs.
type CronJobAdapter struct {
client kubernetes.Interface
}
// NewCronJobAdapter creates a new CronJobAdapter.
func NewCronJobAdapter(client kubernetes.Interface) *CronJobAdapter {
return &CronJobAdapter{client: client}
}
// Type returns the workload type.
func (a *CronJobAdapter) Type() WorkloadType {
return WorkloadCronJob
}
// Create creates a CronJob with the given config.
func (a *CronJobAdapter) Create(ctx context.Context, namespace, name string, cfg WorkloadConfig) error {
opts := buildCronJobOptions(cfg)
_, err := CreateCronJob(ctx, a.client, namespace, name, opts...)
return err
}
// Delete removes the CronJob.
func (a *CronJobAdapter) Delete(ctx context.Context, namespace, name string) error {
return DeleteCronJob(ctx, a.client, namespace, name)
}
// WaitReady waits for the CronJob to exist (CronJobs are "ready" immediately after creation).
func (a *CronJobAdapter) WaitReady(ctx context.Context, namespace, name string, timeout time.Duration) error {
return WaitForCronJobExists(ctx, a.client, namespace, name, timeout)
}
// WaitReloaded waits for the CronJob to have the reload annotation OR for a triggered Job.
// For CronJobs, Reloader can either:
// 1. Add an annotation to the pod template
// 2. Trigger a new Job (which is the special handling case)
func (a *CronJobAdapter) WaitReloaded(ctx context.Context, namespace, name, annotationKey string, timeout time.Duration) (bool, error) {
return WaitForCronJobReloaded(ctx, a.client, namespace, name, annotationKey, timeout)
}
// WaitEnvVar is not supported for CronJobs as they don't use env var reload strategy.
func (a *CronJobAdapter) WaitEnvVar(ctx context.Context, namespace, name, prefix string, timeout time.Duration) (bool, error) {
// CronJobs don't support env var strategy
return false, nil
}
// SupportsEnvVarStrategy returns false as CronJobs don't support env var reload strategy.
func (a *CronJobAdapter) SupportsEnvVarStrategy() bool {
return false
}
// RequiresSpecialHandling returns true as CronJobs use job triggering instead of rolling restart.
func (a *CronJobAdapter) RequiresSpecialHandling() bool {
return true
}
// WaitForTriggeredJob waits for Reloader to trigger a new Job from this CronJob.
func (a *CronJobAdapter) WaitForTriggeredJob(ctx context.Context, namespace, cronJobName string, timeout time.Duration) (bool, error) {
return WaitForCronJobTriggeredJob(ctx, a.client, namespace, cronJobName, timeout)
}
// buildCronJobOptions converts WorkloadConfig to CronJobOption slice.
func buildCronJobOptions(cfg WorkloadConfig) []CronJobOption {
var opts []CronJobOption
// Add annotations
if len(cfg.Annotations) > 0 {
opts = append(opts, WithCronJobAnnotations(cfg.Annotations))
}
// Add envFrom references
if cfg.UseConfigMapEnvFrom && cfg.ConfigMapName != "" {
opts = append(opts, WithCronJobConfigMapEnvFrom(cfg.ConfigMapName))
}
if cfg.UseSecretEnvFrom && cfg.SecretName != "" {
opts = append(opts, WithCronJobSecretEnvFrom(cfg.SecretName))
}
// Add volume mounts
if cfg.UseConfigMapVolume && cfg.ConfigMapName != "" {
opts = append(opts, WithCronJobConfigMapVolume(cfg.ConfigMapName))
}
if cfg.UseSecretVolume && cfg.SecretName != "" {
opts = append(opts, WithCronJobSecretVolume(cfg.SecretName))
}
// Add projected volume
if cfg.UseProjectedVolume {
opts = append(opts, WithCronJobProjectedVolume(cfg.ConfigMapName, cfg.SecretName))
}
return opts
}
// WithCronJobConfigMapVolume adds a volume mount for a ConfigMap to a CronJob.
func WithCronJobConfigMapVolume(name string) CronJobOption {
return func(cj *batchv1.CronJob) {
volumeName := "cm-" + name
cj.Spec.JobTemplate.Spec.Template.Spec.Volumes = append(
cj.Spec.JobTemplate.Spec.Template.Spec.Volumes,
corev1.Volume{
Name: volumeName,
VolumeSource: corev1.VolumeSource{
ConfigMap: &corev1.ConfigMapVolumeSource{
LocalObjectReference: corev1.LocalObjectReference{Name: name},
},
},
},
)
cj.Spec.JobTemplate.Spec.Template.Spec.Containers[0].VolumeMounts = append(
cj.Spec.JobTemplate.Spec.Template.Spec.Containers[0].VolumeMounts,
corev1.VolumeMount{
Name: volumeName,
MountPath: "/etc/config/" + name,
},
)
}
}
// WithCronJobSecretVolume adds a volume mount for a Secret to a CronJob.
func WithCronJobSecretVolume(name string) CronJobOption {
return func(cj *batchv1.CronJob) {
volumeName := "secret-" + name
cj.Spec.JobTemplate.Spec.Template.Spec.Volumes = append(
cj.Spec.JobTemplate.Spec.Template.Spec.Volumes,
corev1.Volume{
Name: volumeName,
VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{
SecretName: name,
},
},
},
)
cj.Spec.JobTemplate.Spec.Template.Spec.Containers[0].VolumeMounts = append(
cj.Spec.JobTemplate.Spec.Template.Spec.Containers[0].VolumeMounts,
corev1.VolumeMount{
Name: volumeName,
MountPath: "/etc/secrets/" + name,
},
)
}
}
// WithCronJobProjectedVolume adds a projected volume with ConfigMap and/or Secret sources to a CronJob.
func WithCronJobProjectedVolume(cmName, secretName string) CronJobOption {
return func(cj *batchv1.CronJob) {
volumeName := "projected-config"
sources := []corev1.VolumeProjection{}
if cmName != "" {
sources = append(sources, corev1.VolumeProjection{
ConfigMap: &corev1.ConfigMapProjection{
LocalObjectReference: corev1.LocalObjectReference{Name: cmName},
},
})
}
if secretName != "" {
sources = append(sources, corev1.VolumeProjection{
Secret: &corev1.SecretProjection{
LocalObjectReference: corev1.LocalObjectReference{Name: secretName},
},
})
}
cj.Spec.JobTemplate.Spec.Template.Spec.Volumes = append(
cj.Spec.JobTemplate.Spec.Template.Spec.Volumes,
corev1.Volume{
Name: volumeName,
VolumeSource: corev1.VolumeSource{
Projected: &corev1.ProjectedVolumeSource{
Sources: sources,
},
},
},
)
cj.Spec.JobTemplate.Spec.Template.Spec.Containers[0].VolumeMounts = append(
cj.Spec.JobTemplate.Spec.Template.Spec.Containers[0].VolumeMounts,
corev1.VolumeMount{
Name: volumeName,
MountPath: "/etc/projected",
},
)
}
}
// WaitForCronJobEnvVar waits for a CronJob's containers to have an environment variable
// with the given prefix. Note: CronJobs don't typically use this strategy.
func WaitForCronJobEnvVar(ctx context.Context, client kubernetes.Interface, namespace, name, prefix string, timeout time.Duration) (bool, error) {
var found bool
err := wait.PollUntilContextTimeout(ctx, DefaultInterval, timeout, true, func(ctx context.Context) (bool, error) {
cj, err := client.BatchV1().CronJobs(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return false, nil
}
if hasEnvVarWithPrefix(cj.Spec.JobTemplate.Spec.Template.Spec.Containers, prefix) {
found = true
return true, nil
}
return false, nil
})
if err != nil && err != context.DeadlineExceeded {
return false, err
}
return found, nil
}
+246
View File
@@ -0,0 +1,246 @@
package utils
import (
"context"
"fmt"
"time"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/client-go/kubernetes"
)
// DaemonSetAdapter implements WorkloadAdapter for Kubernetes DaemonSets.
type DaemonSetAdapter struct {
client kubernetes.Interface
}
// NewDaemonSetAdapter creates a new DaemonSetAdapter.
func NewDaemonSetAdapter(client kubernetes.Interface) *DaemonSetAdapter {
return &DaemonSetAdapter{client: client}
}
// Type returns the workload type.
func (a *DaemonSetAdapter) Type() WorkloadType {
return WorkloadDaemonSet
}
// Create creates a DaemonSet with the given config.
func (a *DaemonSetAdapter) Create(ctx context.Context, namespace, name string, cfg WorkloadConfig) error {
opts := buildDaemonSetOptions(cfg)
_, err := CreateDaemonSet(ctx, a.client, namespace, name, opts...)
return err
}
// Delete removes the DaemonSet.
func (a *DaemonSetAdapter) Delete(ctx context.Context, namespace, name string) error {
return DeleteDaemonSet(ctx, a.client, namespace, name)
}
// WaitReady waits for the DaemonSet to be ready.
func (a *DaemonSetAdapter) WaitReady(ctx context.Context, namespace, name string, timeout time.Duration) error {
return WaitForDaemonSetReady(ctx, a.client, namespace, name, timeout)
}
// WaitReloaded waits for the DaemonSet to have the reload annotation.
func (a *DaemonSetAdapter) WaitReloaded(ctx context.Context, namespace, name, annotationKey string, timeout time.Duration) (bool, error) {
return WaitForDaemonSetReloaded(ctx, a.client, namespace, name, annotationKey, timeout)
}
// WaitEnvVar waits for the DaemonSet to have a STAKATER_ env var.
func (a *DaemonSetAdapter) WaitEnvVar(ctx context.Context, namespace, name, prefix string, timeout time.Duration) (bool, error) {
return WaitForDaemonSetEnvVar(ctx, a.client, namespace, name, prefix, timeout)
}
// SupportsEnvVarStrategy returns true as DaemonSets support env var reload strategy.
func (a *DaemonSetAdapter) SupportsEnvVarStrategy() bool {
return true
}
// RequiresSpecialHandling returns false as DaemonSets use standard rolling restart.
func (a *DaemonSetAdapter) RequiresSpecialHandling() bool {
return false
}
// buildDaemonSetOptions converts WorkloadConfig to DaemonSetOption slice.
func buildDaemonSetOptions(cfg WorkloadConfig) []DaemonSetOption {
var opts []DaemonSetOption
// Add annotations
if len(cfg.Annotations) > 0 {
opts = append(opts, WithDaemonSetAnnotations(cfg.Annotations))
}
// Add envFrom references
if cfg.UseConfigMapEnvFrom && cfg.ConfigMapName != "" {
opts = append(opts, WithDaemonSetConfigMapEnvFrom(cfg.ConfigMapName))
}
if cfg.UseSecretEnvFrom && cfg.SecretName != "" {
opts = append(opts, WithDaemonSetSecretEnvFrom(cfg.SecretName))
}
// Add volume mounts
if cfg.UseConfigMapVolume && cfg.ConfigMapName != "" {
opts = append(opts, WithDaemonSetConfigMapVolume(cfg.ConfigMapName))
}
if cfg.UseSecretVolume && cfg.SecretName != "" {
opts = append(opts, WithDaemonSetSecretVolume(cfg.SecretName))
}
// Add projected volume
if cfg.UseProjectedVolume {
opts = append(opts, WithDaemonSetProjectedVolume(cfg.ConfigMapName, cfg.SecretName))
}
// Add valueFrom references
if cfg.UseConfigMapKeyRef && cfg.ConfigMapName != "" {
key := cfg.ConfigMapKey
if key == "" {
key = "key"
}
envVar := cfg.EnvVarName
if envVar == "" {
envVar = "CONFIG_VAR"
}
opts = append(opts, WithDaemonSetConfigMapKeyRef(cfg.ConfigMapName, key, envVar))
}
if cfg.UseSecretKeyRef && cfg.SecretName != "" {
key := cfg.SecretKey
if key == "" {
key = "key"
}
envVar := cfg.EnvVarName
if envVar == "" {
envVar = "SECRET_VAR"
}
opts = append(opts, WithDaemonSetSecretKeyRef(cfg.SecretName, key, envVar))
}
// Add init container with envFrom
if cfg.UseInitContainer {
opts = append(opts, WithDaemonSetInitContainer(cfg.ConfigMapName, cfg.SecretName))
}
// Add init container with volume mount
if cfg.UseInitContainerVolume {
opts = append(opts, WithDaemonSetInitContainerVolume(cfg.ConfigMapName, cfg.SecretName))
}
return opts
}
// WithDaemonSetConfigMapVolume adds a volume mount for a ConfigMap to a DaemonSet.
func WithDaemonSetConfigMapVolume(name string) DaemonSetOption {
return func(ds *appsv1.DaemonSet) {
volumeName := fmt.Sprintf("cm-%s", name)
ds.Spec.Template.Spec.Volumes = append(ds.Spec.Template.Spec.Volumes, corev1.Volume{
Name: volumeName,
VolumeSource: corev1.VolumeSource{
ConfigMap: &corev1.ConfigMapVolumeSource{
LocalObjectReference: corev1.LocalObjectReference{Name: name},
},
},
})
ds.Spec.Template.Spec.Containers[0].VolumeMounts = append(
ds.Spec.Template.Spec.Containers[0].VolumeMounts,
corev1.VolumeMount{
Name: volumeName,
MountPath: fmt.Sprintf("/etc/config/%s", name),
},
)
}
}
// WithDaemonSetSecretVolume adds a volume mount for a Secret to a DaemonSet.
func WithDaemonSetSecretVolume(name string) DaemonSetOption {
return func(ds *appsv1.DaemonSet) {
volumeName := fmt.Sprintf("secret-%s", name)
ds.Spec.Template.Spec.Volumes = append(ds.Spec.Template.Spec.Volumes, corev1.Volume{
Name: volumeName,
VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{
SecretName: name,
},
},
})
ds.Spec.Template.Spec.Containers[0].VolumeMounts = append(
ds.Spec.Template.Spec.Containers[0].VolumeMounts,
corev1.VolumeMount{
Name: volumeName,
MountPath: fmt.Sprintf("/etc/secrets/%s", name),
},
)
}
}
// WithDaemonSetInitContainer adds an init container that references ConfigMap and/or Secret.
func WithDaemonSetInitContainer(cmName, secretName string) DaemonSetOption {
return func(ds *appsv1.DaemonSet) {
initContainer := corev1.Container{
Name: "init",
Image: DefaultImage,
Command: []string{"sh", "-c", "echo init done"},
}
if cmName != "" {
initContainer.EnvFrom = append(initContainer.EnvFrom, corev1.EnvFromSource{
ConfigMapRef: &corev1.ConfigMapEnvSource{
LocalObjectReference: corev1.LocalObjectReference{Name: cmName},
},
})
}
if secretName != "" {
initContainer.EnvFrom = append(initContainer.EnvFrom, corev1.EnvFromSource{
SecretRef: &corev1.SecretEnvSource{
LocalObjectReference: corev1.LocalObjectReference{Name: secretName},
},
})
}
ds.Spec.Template.Spec.InitContainers = append(ds.Spec.Template.Spec.InitContainers, initContainer)
}
}
// WithDaemonSetInitContainerVolume adds an init container with ConfigMap/Secret volume mounts.
func WithDaemonSetInitContainerVolume(cmName, secretName string) DaemonSetOption {
return func(ds *appsv1.DaemonSet) {
initContainer := corev1.Container{
Name: "init",
Image: DefaultImage,
Command: []string{"sh", "-c", "echo init done"},
}
if cmName != "" {
volumeName := fmt.Sprintf("init-cm-%s", cmName)
ds.Spec.Template.Spec.Volumes = append(ds.Spec.Template.Spec.Volumes, corev1.Volume{
Name: volumeName,
VolumeSource: corev1.VolumeSource{
ConfigMap: &corev1.ConfigMapVolumeSource{
LocalObjectReference: corev1.LocalObjectReference{Name: cmName},
},
},
})
initContainer.VolumeMounts = append(initContainer.VolumeMounts, corev1.VolumeMount{
Name: volumeName,
MountPath: fmt.Sprintf("/etc/init-config/%s", cmName),
})
}
if secretName != "" {
volumeName := fmt.Sprintf("init-secret-%s", secretName)
ds.Spec.Template.Spec.Volumes = append(ds.Spec.Template.Spec.Volumes, corev1.Volume{
Name: volumeName,
VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{
SecretName: secretName,
},
},
})
initContainer.VolumeMounts = append(initContainer.VolumeMounts, corev1.VolumeMount{
Name: volumeName,
MountPath: fmt.Sprintf("/etc/init-secrets/%s", secretName),
})
}
ds.Spec.Template.Spec.InitContainers = append(ds.Spec.Template.Spec.InitContainers, initContainer)
}
}
+132
View File
@@ -0,0 +1,132 @@
package utils
import (
"context"
"time"
"k8s.io/client-go/kubernetes"
)
// DeploymentAdapter implements WorkloadAdapter for Kubernetes Deployments.
type DeploymentAdapter struct {
client kubernetes.Interface
}
// NewDeploymentAdapter creates a new DeploymentAdapter.
func NewDeploymentAdapter(client kubernetes.Interface) *DeploymentAdapter {
return &DeploymentAdapter{client: client}
}
// Type returns the workload type.
func (a *DeploymentAdapter) Type() WorkloadType {
return WorkloadDeployment
}
// Create creates a Deployment with the given config.
func (a *DeploymentAdapter) Create(ctx context.Context, namespace, name string, cfg WorkloadConfig) error {
opts := buildDeploymentOptions(cfg)
_, err := CreateDeployment(ctx, a.client, namespace, name, opts...)
return err
}
// Delete removes the Deployment.
func (a *DeploymentAdapter) Delete(ctx context.Context, namespace, name string) error {
return DeleteDeployment(ctx, a.client, namespace, name)
}
// WaitReady waits for the Deployment to be ready.
func (a *DeploymentAdapter) WaitReady(ctx context.Context, namespace, name string, timeout time.Duration) error {
return WaitForDeploymentReady(ctx, a.client, namespace, name, timeout)
}
// WaitReloaded waits for the Deployment to have the reload annotation.
func (a *DeploymentAdapter) WaitReloaded(ctx context.Context, namespace, name, annotationKey string, timeout time.Duration) (bool, error) {
return WaitForDeploymentReloaded(ctx, a.client, namespace, name, annotationKey, timeout)
}
// WaitEnvVar waits for the Deployment to have a STAKATER_ env var.
func (a *DeploymentAdapter) WaitEnvVar(ctx context.Context, namespace, name, prefix string, timeout time.Duration) (bool, error) {
return WaitForDeploymentEnvVar(ctx, a.client, namespace, name, prefix, timeout)
}
// SupportsEnvVarStrategy returns true as Deployments support env var reload strategy.
func (a *DeploymentAdapter) SupportsEnvVarStrategy() bool {
return true
}
// RequiresSpecialHandling returns false as Deployments use standard rolling restart.
func (a *DeploymentAdapter) RequiresSpecialHandling() bool {
return false
}
// buildDeploymentOptions converts WorkloadConfig to DeploymentOption slice.
func buildDeploymentOptions(cfg WorkloadConfig) []DeploymentOption {
var opts []DeploymentOption
// Add annotations
if len(cfg.Annotations) > 0 {
opts = append(opts, WithAnnotations(cfg.Annotations))
}
// Add envFrom references
if cfg.UseConfigMapEnvFrom && cfg.ConfigMapName != "" {
opts = append(opts, WithConfigMapEnvFrom(cfg.ConfigMapName))
}
if cfg.UseSecretEnvFrom && cfg.SecretName != "" {
opts = append(opts, WithSecretEnvFrom(cfg.SecretName))
}
// Add volume mounts
if cfg.UseConfigMapVolume && cfg.ConfigMapName != "" {
opts = append(opts, WithConfigMapVolume(cfg.ConfigMapName))
}
if cfg.UseSecretVolume && cfg.SecretName != "" {
opts = append(opts, WithSecretVolume(cfg.SecretName))
}
// Add projected volume
if cfg.UseProjectedVolume {
opts = append(opts, WithProjectedVolume(cfg.ConfigMapName, cfg.SecretName))
}
// Add valueFrom references
if cfg.UseConfigMapKeyRef && cfg.ConfigMapName != "" {
key := cfg.ConfigMapKey
if key == "" {
key = "key"
}
envVar := cfg.EnvVarName
if envVar == "" {
envVar = "CONFIG_VAR"
}
opts = append(opts, WithConfigMapKeyRef(cfg.ConfigMapName, key, envVar))
}
if cfg.UseSecretKeyRef && cfg.SecretName != "" {
key := cfg.SecretKey
if key == "" {
key = "key"
}
envVar := cfg.EnvVarName
if envVar == "" {
envVar = "SECRET_VAR"
}
opts = append(opts, WithSecretKeyRef(cfg.SecretName, key, envVar))
}
// Add init container with envFrom
if cfg.UseInitContainer {
opts = append(opts, WithInitContainer(cfg.ConfigMapName, cfg.SecretName))
}
// Add init container with volume mount
if cfg.UseInitContainerVolume {
opts = append(opts, WithInitContainerVolume(cfg.ConfigMapName, cfg.SecretName))
}
// Add multiple containers
if cfg.MultipleContainers > 1 {
opts = append(opts, WithMultipleContainers(cfg.MultipleContainers))
}
return opts
}
+207
View File
@@ -0,0 +1,207 @@
package utils
import (
"context"
"time"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/client-go/kubernetes"
)
// JobAdapter implements WorkloadAdapter for Kubernetes Jobs.
// Note: Jobs are handled specially by Reloader - they are recreated rather than updated.
type JobAdapter struct {
client kubernetes.Interface
}
// NewJobAdapter creates a new JobAdapter.
func NewJobAdapter(client kubernetes.Interface) *JobAdapter {
return &JobAdapter{client: client}
}
// Type returns the workload type.
func (a *JobAdapter) Type() WorkloadType {
return WorkloadJob
}
// Create creates a Job with the given config.
func (a *JobAdapter) Create(ctx context.Context, namespace, name string, cfg WorkloadConfig) error {
opts := buildJobOptions(cfg)
_, err := CreateJob(ctx, a.client, namespace, name, opts...)
return err
}
// Delete removes the Job.
func (a *JobAdapter) Delete(ctx context.Context, namespace, name string) error {
return DeleteJob(ctx, a.client, namespace, name)
}
// WaitReady waits for the Job to exist.
func (a *JobAdapter) WaitReady(ctx context.Context, namespace, name string, timeout time.Duration) error {
return WaitForJobExists(ctx, a.client, namespace, name, timeout)
}
// WaitReloaded waits for the Job to be recreated (new UID).
// For Jobs, Reloader recreates the Job rather than updating annotations.
func (a *JobAdapter) WaitReloaded(ctx context.Context, namespace, name, annotationKey string, timeout time.Duration) (bool, error) {
// For Jobs, we check if it was recreated by looking for a new UID
// This requires storing the original UID before the test
// For simplicity, we use the same pattern as other workloads
// The test should verify recreation using WaitForJobRecreated instead
return false, nil
}
// WaitEnvVar is not supported for Jobs as they don't use env var reload strategy.
func (a *JobAdapter) WaitEnvVar(ctx context.Context, namespace, name, prefix string, timeout time.Duration) (bool, error) {
return false, nil
}
// SupportsEnvVarStrategy returns false as Jobs don't support env var reload strategy.
func (a *JobAdapter) SupportsEnvVarStrategy() bool {
return false
}
// RequiresSpecialHandling returns true as Jobs are recreated by Reloader.
func (a *JobAdapter) RequiresSpecialHandling() bool {
return true
}
// GetOriginalUID retrieves the current UID of the Job for recreation verification.
func (a *JobAdapter) GetOriginalUID(ctx context.Context, namespace, name string) (string, error) {
job, err := GetJob(ctx, a.client, namespace, name)
if err != nil {
return "", err
}
return string(job.UID), nil
}
// WaitForRecreation waits for the Job to be recreated with a new UID.
func (a *JobAdapter) WaitForRecreation(ctx context.Context, namespace, name, originalUID string, timeout time.Duration) (string, bool, error) {
return WaitForJobRecreated(ctx, a.client, namespace, name, originalUID, timeout)
}
// buildJobOptions converts WorkloadConfig to JobOption slice.
func buildJobOptions(cfg WorkloadConfig) []JobOption {
var opts []JobOption
// Add annotations
if len(cfg.Annotations) > 0 {
opts = append(opts, WithJobAnnotations(cfg.Annotations))
}
// Add envFrom references
if cfg.UseConfigMapEnvFrom && cfg.ConfigMapName != "" {
opts = append(opts, WithJobConfigMapEnvFrom(cfg.ConfigMapName))
}
if cfg.UseSecretEnvFrom && cfg.SecretName != "" {
opts = append(opts, WithJobSecretEnvFrom(cfg.SecretName))
}
// Add volume mounts
if cfg.UseConfigMapVolume && cfg.ConfigMapName != "" {
opts = append(opts, WithJobConfigMapVolume(cfg.ConfigMapName))
}
if cfg.UseSecretVolume && cfg.SecretName != "" {
opts = append(opts, WithJobSecretVolume(cfg.SecretName))
}
// Add projected volume
if cfg.UseProjectedVolume {
opts = append(opts, WithJobProjectedVolume(cfg.ConfigMapName, cfg.SecretName))
}
return opts
}
// WithJobConfigMapVolume adds a volume mount for a ConfigMap to a Job.
func WithJobConfigMapVolume(name string) JobOption {
return func(j *batchv1.Job) {
volumeName := "cm-" + name
j.Spec.Template.Spec.Volumes = append(
j.Spec.Template.Spec.Volumes,
corev1.Volume{
Name: volumeName,
VolumeSource: corev1.VolumeSource{
ConfigMap: &corev1.ConfigMapVolumeSource{
LocalObjectReference: corev1.LocalObjectReference{Name: name},
},
},
},
)
j.Spec.Template.Spec.Containers[0].VolumeMounts = append(
j.Spec.Template.Spec.Containers[0].VolumeMounts,
corev1.VolumeMount{
Name: volumeName,
MountPath: "/etc/config/" + name,
},
)
}
}
// WithJobSecretVolume adds a volume mount for a Secret to a Job.
func WithJobSecretVolume(name string) JobOption {
return func(j *batchv1.Job) {
volumeName := "secret-" + name
j.Spec.Template.Spec.Volumes = append(
j.Spec.Template.Spec.Volumes,
corev1.Volume{
Name: volumeName,
VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{
SecretName: name,
},
},
},
)
j.Spec.Template.Spec.Containers[0].VolumeMounts = append(
j.Spec.Template.Spec.Containers[0].VolumeMounts,
corev1.VolumeMount{
Name: volumeName,
MountPath: "/etc/secrets/" + name,
},
)
}
}
// WithJobProjectedVolume adds a projected volume with ConfigMap and/or Secret sources to a Job.
func WithJobProjectedVolume(cmName, secretName string) JobOption {
return func(j *batchv1.Job) {
volumeName := "projected-config"
sources := []corev1.VolumeProjection{}
if cmName != "" {
sources = append(sources, corev1.VolumeProjection{
ConfigMap: &corev1.ConfigMapProjection{
LocalObjectReference: corev1.LocalObjectReference{Name: cmName},
},
})
}
if secretName != "" {
sources = append(sources, corev1.VolumeProjection{
Secret: &corev1.SecretProjection{
LocalObjectReference: corev1.LocalObjectReference{Name: secretName},
},
})
}
j.Spec.Template.Spec.Volumes = append(
j.Spec.Template.Spec.Volumes,
corev1.Volume{
Name: volumeName,
VolumeSource: corev1.VolumeSource{
Projected: &corev1.ProjectedVolumeSource{
Sources: sources,
},
},
},
)
j.Spec.Template.Spec.Containers[0].VolumeMounts = append(
j.Spec.Template.Spec.Containers[0].VolumeMounts,
corev1.VolumeMount{
Name: volumeName,
MountPath: "/etc/projected",
},
)
}
}
+340
View File
@@ -0,0 +1,340 @@
package utils
import (
"context"
"fmt"
"strings"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/util/wait"
"k8s.io/client-go/dynamic"
)
// DeploymentConfigAdapter implements WorkloadAdapter for OpenShift DeploymentConfigs.
type DeploymentConfigAdapter struct {
dynamicClient dynamic.Interface
}
// NewDeploymentConfigAdapter creates a new DeploymentConfigAdapter.
func NewDeploymentConfigAdapter(dynamicClient dynamic.Interface) *DeploymentConfigAdapter {
return &DeploymentConfigAdapter{dynamicClient: dynamicClient}
}
// Type returns the workload type.
func (a *DeploymentConfigAdapter) Type() WorkloadType {
return WorkloadDeploymentConfig
}
// Create creates a DeploymentConfig with the given config.
func (a *DeploymentConfigAdapter) Create(ctx context.Context, namespace, name string, cfg WorkloadConfig) error {
opts := buildDCOptions(cfg)
return CreateDeploymentConfig(ctx, a.dynamicClient, namespace, name, opts...)
}
// Delete removes the DeploymentConfig.
func (a *DeploymentConfigAdapter) Delete(ctx context.Context, namespace, name string) error {
return DeleteDeploymentConfig(ctx, a.dynamicClient, namespace, name)
}
// WaitReady waits for the DeploymentConfig to be ready.
func (a *DeploymentConfigAdapter) WaitReady(ctx context.Context, namespace, name string, timeout time.Duration) error {
return WaitForDeploymentConfigReady(ctx, a.dynamicClient, namespace, name, timeout)
}
// WaitReloaded waits for the DeploymentConfig to have the reload annotation.
func (a *DeploymentConfigAdapter) WaitReloaded(ctx context.Context, namespace, name, annotationKey string, timeout time.Duration) (bool, error) {
return WaitForDeploymentConfigReloaded(ctx, a.dynamicClient, namespace, name, annotationKey, timeout)
}
// WaitEnvVar waits for the DeploymentConfig to have a STAKATER_ env var.
func (a *DeploymentConfigAdapter) WaitEnvVar(ctx context.Context, namespace, name, prefix string, timeout time.Duration) (bool, error) {
return WaitForDeploymentConfigEnvVar(ctx, a.dynamicClient, namespace, name, prefix, timeout)
}
// SupportsEnvVarStrategy returns true as DeploymentConfigs support env var reload strategy.
func (a *DeploymentConfigAdapter) SupportsEnvVarStrategy() bool {
return true
}
// RequiresSpecialHandling returns false as DeploymentConfigs use standard rolling restart.
func (a *DeploymentConfigAdapter) RequiresSpecialHandling() bool {
return false
}
// buildDCOptions converts WorkloadConfig to DCOption slice.
func buildDCOptions(cfg WorkloadConfig) []DCOption {
var opts []DCOption
// Add annotations (to pod template)
if len(cfg.Annotations) > 0 {
opts = append(opts, WithDCAnnotations(cfg.Annotations))
}
// Add envFrom references
if cfg.UseConfigMapEnvFrom && cfg.ConfigMapName != "" {
opts = append(opts, WithDCConfigMapEnvFrom(cfg.ConfigMapName))
}
if cfg.UseSecretEnvFrom && cfg.SecretName != "" {
opts = append(opts, WithDCSecretEnvFrom(cfg.SecretName))
}
// Add volume mounts
if cfg.UseConfigMapVolume && cfg.ConfigMapName != "" {
opts = append(opts, WithDCConfigMapVolume(cfg.ConfigMapName))
}
if cfg.UseSecretVolume && cfg.SecretName != "" {
opts = append(opts, WithDCSecretVolume(cfg.SecretName))
}
// Add projected volume
if cfg.UseProjectedVolume {
opts = append(opts, WithDCProjectedVolume(cfg.ConfigMapName, cfg.SecretName))
}
// Add valueFrom references
if cfg.UseConfigMapKeyRef && cfg.ConfigMapName != "" {
key := cfg.ConfigMapKey
if key == "" {
key = "key"
}
envVar := cfg.EnvVarName
if envVar == "" {
envVar = "CONFIG_VAR"
}
opts = append(opts, WithDCConfigMapKeyRef(cfg.ConfigMapName, key, envVar))
}
if cfg.UseSecretKeyRef && cfg.SecretName != "" {
key := cfg.SecretKey
if key == "" {
key = "key"
}
envVar := cfg.EnvVarName
if envVar == "" {
envVar = "SECRET_VAR"
}
opts = append(opts, WithDCSecretKeyRef(cfg.SecretName, key, envVar))
}
// Add init container with envFrom
if cfg.UseInitContainer {
opts = append(opts, WithDCInitContainer(cfg.ConfigMapName, cfg.SecretName))
}
// Add init container with volume mount
if cfg.UseInitContainerVolume {
opts = append(opts, WithDCInitContainerVolume(cfg.ConfigMapName, cfg.SecretName))
}
return opts
}
// WithDCProjectedVolume adds a projected volume with ConfigMap and/or Secret sources to a DeploymentConfig.
func WithDCProjectedVolume(cmName, secretName string) DCOption {
return func(dc *unstructured.Unstructured) {
volumeName := "projected-config"
sources := []interface{}{}
if cmName != "" {
sources = append(sources, map[string]interface{}{
"configMap": map[string]interface{}{
"name": cmName,
},
})
}
if secretName != "" {
sources = append(sources, map[string]interface{}{
"secret": map[string]interface{}{
"name": secretName,
},
})
}
// Add volume
volumes, _, _ := unstructured.NestedSlice(dc.Object, "spec", "template", "spec", "volumes")
volumes = append(volumes, map[string]interface{}{
"name": volumeName,
"projected": map[string]interface{}{
"sources": sources,
},
})
_ = unstructured.SetNestedSlice(dc.Object, volumes, "spec", "template", "spec", "volumes")
// Add volumeMount
containers, _, _ := unstructured.NestedSlice(dc.Object, "spec", "template", "spec", "containers")
if len(containers) > 0 {
container := containers[0].(map[string]interface{})
volumeMounts, _, _ := unstructured.NestedSlice(container, "volumeMounts")
volumeMounts = append(volumeMounts, map[string]interface{}{
"name": volumeName,
"mountPath": "/etc/projected",
})
container["volumeMounts"] = volumeMounts
containers[0] = container
_ = unstructured.SetNestedSlice(dc.Object, containers, "spec", "template", "spec", "containers")
}
}
}
// WithDCConfigMapKeyRef adds an env var with valueFrom.configMapKeyRef to a DeploymentConfig.
func WithDCConfigMapKeyRef(cmName, key, envVarName string) DCOption {
return func(dc *unstructured.Unstructured) {
containers, _, _ := unstructured.NestedSlice(dc.Object, "spec", "template", "spec", "containers")
if len(containers) > 0 {
container := containers[0].(map[string]interface{})
env, _, _ := unstructured.NestedSlice(container, "env")
env = append(env, map[string]interface{}{
"name": envVarName,
"valueFrom": map[string]interface{}{
"configMapKeyRef": map[string]interface{}{
"name": cmName,
"key": key,
},
},
})
container["env"] = env
containers[0] = container
_ = unstructured.SetNestedSlice(dc.Object, containers, "spec", "template", "spec", "containers")
}
}
}
// WithDCSecretKeyRef adds an env var with valueFrom.secretKeyRef to a DeploymentConfig.
func WithDCSecretKeyRef(secretName, key, envVarName string) DCOption {
return func(dc *unstructured.Unstructured) {
containers, _, _ := unstructured.NestedSlice(dc.Object, "spec", "template", "spec", "containers")
if len(containers) > 0 {
container := containers[0].(map[string]interface{})
env, _, _ := unstructured.NestedSlice(container, "env")
env = append(env, map[string]interface{}{
"name": envVarName,
"valueFrom": map[string]interface{}{
"secretKeyRef": map[string]interface{}{
"name": secretName,
"key": key,
},
},
})
container["env"] = env
containers[0] = container
_ = unstructured.SetNestedSlice(dc.Object, containers, "spec", "template", "spec", "containers")
}
}
}
// WithDCInitContainer adds an init container that references ConfigMap and/or Secret via envFrom.
func WithDCInitContainer(cmName, secretName string) DCOption {
return func(dc *unstructured.Unstructured) {
initContainer := map[string]interface{}{
"name": "init",
"image": DefaultImage,
"command": []interface{}{"sh", "-c", "echo init done"},
}
envFrom := []interface{}{}
if cmName != "" {
envFrom = append(envFrom, map[string]interface{}{
"configMapRef": map[string]interface{}{
"name": cmName,
},
})
}
if secretName != "" {
envFrom = append(envFrom, map[string]interface{}{
"secretRef": map[string]interface{}{
"name": secretName,
},
})
}
if len(envFrom) > 0 {
initContainer["envFrom"] = envFrom
}
initContainers, _, _ := unstructured.NestedSlice(dc.Object, "spec", "template", "spec", "initContainers")
initContainers = append(initContainers, initContainer)
_ = unstructured.SetNestedSlice(dc.Object, initContainers, "spec", "template", "spec", "initContainers")
}
}
// WithDCInitContainerVolume adds an init container with ConfigMap/Secret volume mounts to a DeploymentConfig.
func WithDCInitContainerVolume(cmName, secretName string) DCOption {
return func(dc *unstructured.Unstructured) {
initContainer := map[string]interface{}{
"name": "init",
"image": DefaultImage,
"command": []interface{}{"sh", "-c", "echo init done"},
}
volumeMounts := []interface{}{}
volumes, _, _ := unstructured.NestedSlice(dc.Object, "spec", "template", "spec", "volumes")
if cmName != "" {
volumeName := fmt.Sprintf("init-cm-%s", cmName)
volumes = append(volumes, map[string]interface{}{
"name": volumeName,
"configMap": map[string]interface{}{
"name": cmName,
},
})
volumeMounts = append(volumeMounts, map[string]interface{}{
"name": volumeName,
"mountPath": fmt.Sprintf("/etc/init-config/%s", cmName),
})
}
if secretName != "" {
volumeName := fmt.Sprintf("init-secret-%s", secretName)
volumes = append(volumes, map[string]interface{}{
"name": volumeName,
"secret": map[string]interface{}{
"secretName": secretName,
},
})
volumeMounts = append(volumeMounts, map[string]interface{}{
"name": volumeName,
"mountPath": fmt.Sprintf("/etc/init-secrets/%s", secretName),
})
}
if len(volumeMounts) > 0 {
initContainer["volumeMounts"] = volumeMounts
}
_ = unstructured.SetNestedSlice(dc.Object, volumes, "spec", "template", "spec", "volumes")
initContainers, _, _ := unstructured.NestedSlice(dc.Object, "spec", "template", "spec", "initContainers")
initContainers = append(initContainers, initContainer)
_ = unstructured.SetNestedSlice(dc.Object, initContainers, "spec", "template", "spec", "initContainers")
}
}
// WaitForDeploymentConfigEnvVar waits for a DeploymentConfig's container to have an env var with the given prefix.
func WaitForDeploymentConfigEnvVar(ctx context.Context, dynamicClient dynamic.Interface, namespace, name, prefix string, timeout time.Duration) (bool, error) {
var found bool
err := wait.PollUntilContextTimeout(ctx, DefaultInterval, timeout, true, func(ctx context.Context) (bool, error) {
dc, err := dynamicClient.Resource(DeploymentConfigGVR).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return false, nil
}
containers, _, _ := unstructured.NestedSlice(dc.Object, "spec", "template", "spec", "containers")
for _, c := range containers {
container := c.(map[string]interface{})
env, _, _ := unstructured.NestedSlice(container, "env")
for _, e := range env {
envVar := e.(map[string]interface{})
if envName, ok := envVar["name"].(string); ok && strings.HasPrefix(envName, prefix) {
found = true
return true, nil
}
}
}
return false, nil
})
if err != nil && err != context.DeadlineExceeded {
return false, err
}
return found, nil
}
+246
View File
@@ -0,0 +1,246 @@
package utils
import (
"context"
"fmt"
"time"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/client-go/kubernetes"
)
// StatefulSetAdapter implements WorkloadAdapter for Kubernetes StatefulSets.
type StatefulSetAdapter struct {
client kubernetes.Interface
}
// NewStatefulSetAdapter creates a new StatefulSetAdapter.
func NewStatefulSetAdapter(client kubernetes.Interface) *StatefulSetAdapter {
return &StatefulSetAdapter{client: client}
}
// Type returns the workload type.
func (a *StatefulSetAdapter) Type() WorkloadType {
return WorkloadStatefulSet
}
// Create creates a StatefulSet with the given config.
func (a *StatefulSetAdapter) Create(ctx context.Context, namespace, name string, cfg WorkloadConfig) error {
opts := buildStatefulSetOptions(cfg)
_, err := CreateStatefulSet(ctx, a.client, namespace, name, opts...)
return err
}
// Delete removes the StatefulSet.
func (a *StatefulSetAdapter) Delete(ctx context.Context, namespace, name string) error {
return DeleteStatefulSet(ctx, a.client, namespace, name)
}
// WaitReady waits for the StatefulSet to be ready.
func (a *StatefulSetAdapter) WaitReady(ctx context.Context, namespace, name string, timeout time.Duration) error {
return WaitForStatefulSetReady(ctx, a.client, namespace, name, timeout)
}
// WaitReloaded waits for the StatefulSet to have the reload annotation.
func (a *StatefulSetAdapter) WaitReloaded(ctx context.Context, namespace, name, annotationKey string, timeout time.Duration) (bool, error) {
return WaitForStatefulSetReloaded(ctx, a.client, namespace, name, annotationKey, timeout)
}
// WaitEnvVar waits for the StatefulSet to have a STAKATER_ env var.
func (a *StatefulSetAdapter) WaitEnvVar(ctx context.Context, namespace, name, prefix string, timeout time.Duration) (bool, error) {
return WaitForStatefulSetEnvVar(ctx, a.client, namespace, name, prefix, timeout)
}
// SupportsEnvVarStrategy returns true as StatefulSets support env var reload strategy.
func (a *StatefulSetAdapter) SupportsEnvVarStrategy() bool {
return true
}
// RequiresSpecialHandling returns false as StatefulSets use standard rolling restart.
func (a *StatefulSetAdapter) RequiresSpecialHandling() bool {
return false
}
// buildStatefulSetOptions converts WorkloadConfig to StatefulSetOption slice.
func buildStatefulSetOptions(cfg WorkloadConfig) []StatefulSetOption {
var opts []StatefulSetOption
// Add annotations
if len(cfg.Annotations) > 0 {
opts = append(opts, WithStatefulSetAnnotations(cfg.Annotations))
}
// Add envFrom references
if cfg.UseConfigMapEnvFrom && cfg.ConfigMapName != "" {
opts = append(opts, WithStatefulSetConfigMapEnvFrom(cfg.ConfigMapName))
}
if cfg.UseSecretEnvFrom && cfg.SecretName != "" {
opts = append(opts, WithStatefulSetSecretEnvFrom(cfg.SecretName))
}
// Add volume mounts
if cfg.UseConfigMapVolume && cfg.ConfigMapName != "" {
opts = append(opts, WithStatefulSetConfigMapVolume(cfg.ConfigMapName))
}
if cfg.UseSecretVolume && cfg.SecretName != "" {
opts = append(opts, WithStatefulSetSecretVolume(cfg.SecretName))
}
// Add projected volume
if cfg.UseProjectedVolume {
opts = append(opts, WithStatefulSetProjectedVolume(cfg.ConfigMapName, cfg.SecretName))
}
// Add valueFrom references
if cfg.UseConfigMapKeyRef && cfg.ConfigMapName != "" {
key := cfg.ConfigMapKey
if key == "" {
key = "key"
}
envVar := cfg.EnvVarName
if envVar == "" {
envVar = "CONFIG_VAR"
}
opts = append(opts, WithStatefulSetConfigMapKeyRef(cfg.ConfigMapName, key, envVar))
}
if cfg.UseSecretKeyRef && cfg.SecretName != "" {
key := cfg.SecretKey
if key == "" {
key = "key"
}
envVar := cfg.EnvVarName
if envVar == "" {
envVar = "SECRET_VAR"
}
opts = append(opts, WithStatefulSetSecretKeyRef(cfg.SecretName, key, envVar))
}
// Add init container with envFrom
if cfg.UseInitContainer {
opts = append(opts, WithStatefulSetInitContainer(cfg.ConfigMapName, cfg.SecretName))
}
// Add init container with volume mount
if cfg.UseInitContainerVolume {
opts = append(opts, WithStatefulSetInitContainerVolume(cfg.ConfigMapName, cfg.SecretName))
}
return opts
}
// WithStatefulSetConfigMapVolume adds a volume mount for a ConfigMap to a StatefulSet.
func WithStatefulSetConfigMapVolume(name string) StatefulSetOption {
return func(ss *appsv1.StatefulSet) {
volumeName := fmt.Sprintf("cm-%s", name)
ss.Spec.Template.Spec.Volumes = append(ss.Spec.Template.Spec.Volumes, corev1.Volume{
Name: volumeName,
VolumeSource: corev1.VolumeSource{
ConfigMap: &corev1.ConfigMapVolumeSource{
LocalObjectReference: corev1.LocalObjectReference{Name: name},
},
},
})
ss.Spec.Template.Spec.Containers[0].VolumeMounts = append(
ss.Spec.Template.Spec.Containers[0].VolumeMounts,
corev1.VolumeMount{
Name: volumeName,
MountPath: fmt.Sprintf("/etc/config/%s", name),
},
)
}
}
// WithStatefulSetSecretVolume adds a volume mount for a Secret to a StatefulSet.
func WithStatefulSetSecretVolume(name string) StatefulSetOption {
return func(ss *appsv1.StatefulSet) {
volumeName := fmt.Sprintf("secret-%s", name)
ss.Spec.Template.Spec.Volumes = append(ss.Spec.Template.Spec.Volumes, corev1.Volume{
Name: volumeName,
VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{
SecretName: name,
},
},
})
ss.Spec.Template.Spec.Containers[0].VolumeMounts = append(
ss.Spec.Template.Spec.Containers[0].VolumeMounts,
corev1.VolumeMount{
Name: volumeName,
MountPath: fmt.Sprintf("/etc/secrets/%s", name),
},
)
}
}
// WithStatefulSetInitContainer adds an init container that references ConfigMap and/or Secret.
func WithStatefulSetInitContainer(cmName, secretName string) StatefulSetOption {
return func(ss *appsv1.StatefulSet) {
initContainer := corev1.Container{
Name: "init",
Image: DefaultImage,
Command: []string{"sh", "-c", "echo init done"},
}
if cmName != "" {
initContainer.EnvFrom = append(initContainer.EnvFrom, corev1.EnvFromSource{
ConfigMapRef: &corev1.ConfigMapEnvSource{
LocalObjectReference: corev1.LocalObjectReference{Name: cmName},
},
})
}
if secretName != "" {
initContainer.EnvFrom = append(initContainer.EnvFrom, corev1.EnvFromSource{
SecretRef: &corev1.SecretEnvSource{
LocalObjectReference: corev1.LocalObjectReference{Name: secretName},
},
})
}
ss.Spec.Template.Spec.InitContainers = append(ss.Spec.Template.Spec.InitContainers, initContainer)
}
}
// WithStatefulSetInitContainerVolume adds an init container with ConfigMap/Secret volume mounts.
func WithStatefulSetInitContainerVolume(cmName, secretName string) StatefulSetOption {
return func(ss *appsv1.StatefulSet) {
initContainer := corev1.Container{
Name: "init",
Image: DefaultImage,
Command: []string{"sh", "-c", "echo init done"},
}
if cmName != "" {
volumeName := fmt.Sprintf("init-cm-%s", cmName)
ss.Spec.Template.Spec.Volumes = append(ss.Spec.Template.Spec.Volumes, corev1.Volume{
Name: volumeName,
VolumeSource: corev1.VolumeSource{
ConfigMap: &corev1.ConfigMapVolumeSource{
LocalObjectReference: corev1.LocalObjectReference{Name: cmName},
},
},
})
initContainer.VolumeMounts = append(initContainer.VolumeMounts, corev1.VolumeMount{
Name: volumeName,
MountPath: fmt.Sprintf("/etc/init-config/%s", cmName),
})
}
if secretName != "" {
volumeName := fmt.Sprintf("init-secret-%s", secretName)
ss.Spec.Template.Spec.Volumes = append(ss.Spec.Template.Spec.Volumes, corev1.Volume{
Name: volumeName,
VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{
SecretName: secretName,
},
},
})
initContainer.VolumeMounts = append(initContainer.VolumeMounts, corev1.VolumeMount{
Name: volumeName,
MountPath: fmt.Sprintf("/etc/init-secrets/%s", secretName),
})
}
ss.Spec.Template.Spec.InitContainers = append(ss.Spec.Template.Spec.InitContainers, initContainer)
}
}