diff --git a/.github/workflows/pull_request.yaml b/.github/workflows/pull_request.yaml index 1b005a16..de4b6240 100644 --- a/.github/workflows/pull_request.yaml +++ b/.github/workflows/pull_request.yaml @@ -63,6 +63,11 @@ jobs: check-latest: true cache: true + - name: Create timestamp + id: prep + run: echo "created=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT + + # Get highest tag and remove any suffixes with '-' - name: Get Highest tag id: highest_tag @@ -104,6 +109,7 @@ jobs: kind create cluster kubectl cluster-info + - name: Test run: make test @@ -135,7 +141,12 @@ jobs: file: ${{ env.DOCKER_FILE_PATH }} pull: true push: false - build-args: BUILD_PARAMETERS=${{ env.BUILD_PARAMETERS }} + build-args: | + VERSION=merge-${{ steps.generate_tag.outputs.GIT_TAG }} + COMMIT=${{github.event.pull_request.head.sha}} + BUILD_DATE=${{ steps.prep.outputs.created }} + BUILD_PARAMETERS=${{ env.BUILD_PARAMETERS }} + cache-to: type=inline platforms: linux/amd64,linux/arm,linux/arm64 tags: | diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index 5fa6204d..814277f2 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -91,6 +91,10 @@ jobs: with: username: ${{ secrets.STAKATER_DOCKERHUB_USERNAME }} password: ${{ secrets.STAKATER_DOCKERHUB_PASSWORD }} + + - name: Create timestamp + id: prep + run: echo "created=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT - name: Generate image repository path for Docker registry run: | @@ -148,7 +152,11 @@ jobs: file: ${{ env.DOCKER_FILE_PATH }} pull: true push: true - build-args: BUILD_PARAMETERS=${{ env.BUILD_PARAMETERS }} + build-args: | + VERSION=merge-${{ github.event.number }} + COMMIT=${{ github.sha }} + BUILD_DATE=${{ steps.prep.outputs.created }} + BUILD_PARAMETERS=${{ env.BUILD_PARAMETERS }} cache-to: type=inline platforms: linux/amd64,linux/arm,linux/arm64 tags: | diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 775fa080..71d63e5f 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -79,6 +79,10 @@ jobs: id: generate_tag run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT + - name: Create timestamp + id: prep + run: echo "created=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT + - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -106,6 +110,10 @@ jobs: platforms: linux/amd64,linux/arm,linux/arm64 tags: | ${{ env.DOCKER_IMAGE_REPOSITORY }}:${{ steps.generate_tag.outputs.RELEASE_VERSION }} + build-args: | + VERSION=${{ steps.generate_tag.outputs.RELEASE_VERSION }} + COMMIT=${{ github.sha }} + BUILD_DATE=${{ steps.prep.outputs.created }} labels: | org.opencontainers.image.source=${{ github.event.repository.clone_url }} org.opencontainers.image.created=${{ steps.prep.outputs.created }} @@ -152,6 +160,10 @@ jobs: platforms: linux/amd64,linux/arm,linux/arm64 tags: | ${{ env.GHCR_IMAGE_REPOSITORY }}:${{ steps.generate_tag.outputs.RELEASE_VERSION }},${{ env.GHCR_IMAGE_REPOSITORY }}:latest + build-args: | + VERSION=${{ steps.generate_tag.outputs.RELEASE_VERSION }} + COMMIT=${{ github.sha }} + BUILD_DATE=${{ steps.prep.outputs.created }} labels: | org.opencontainers.image.source=${{ github.event.repository.clone_url }} org.opencontainers.image.created=${{ steps.prep.outputs.created }} diff --git a/Dockerfile b/Dockerfile index 57a3999d..34c8941e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,10 @@ ARG TARGETARCH ARG GOPROXY ARG GOPRIVATE +ARG COMMIT +ARG VERSION +ARG BUILD_DATE + WORKDIR /workspace # Copy the Go Modules manifests @@ -30,7 +34,10 @@ RUN CGO_ENABLED=0 \ GOPROXY=${GOPROXY} \ GOPRIVATE=${GOPRIVATE} \ GO111MODULE=on \ - go build -mod=mod -a -o manager main.go + go build -ldflags="-s -w -X github.com/stakater/Reloader/pkg/metainfo.Version=${VERSION} \ + -X github.com/stakater/Reloader/pkg/metainfo.Commit=${COMMIT} \ + -X github.com/stakater/Reloader/pkg/metainfo.BuildDate=${BUILD_DATE}" \ + -installsuffix 'static' -mod=mod -a -o manager ./ # Use distroless as minimal base image to package the manager binary # Refer to https://github.com/GoogleContainerTools/distroless for more details diff --git a/README.md b/README.md index 46b2c49d..f20b648f 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,25 @@ reloader: ALERT_ADDITIONAL_INFO: "Triggered by Reloader in staging environment" ``` +### 7. ⏸️ Pause Deployments + +This feature allows you to pause rollouts for a deployment for a specified duration, helping to prevent multiple restarts when several ConfigMaps or Secrets are updated in quick succession. + +| Annotation | Applies To | Description | +|---------------------------------------------------------|--------------|-----------------------------------------------------------------------------| +| `deployment.reloader.stakater.com/pause-period: "5m"` | Deployment | Pauses reloads for the specified period (e.g., `5m`, `1h`) | + +#### How it works + +1. Add the `deployment.reloader.stakater.com/pause-period` annotation to your Deployment, specifying the pause duration (e.g., `"5m"` for five minutes). +1. When a watched ConfigMap or Secret changes, Reloader will still trigger a reload event, but if the deployment is paused, the rollout will have no effect until the pause period has elapsed. +1. This avoids repeated restarts if multiple resources are updated close together. + +#### Use when + +1. βœ… Your deployment references multiple ConfigMaps or Secrets that may be updated at the same time. +1. βœ… You want to minimize unnecessary rollouts and reduce downtime caused by back-to-back configuration changes. + ## πŸš€ Installation ### 1. πŸ“¦ Helm @@ -336,6 +355,8 @@ These flags allow you to redefine annotation keys used in your workloads or reso | `--search-match-annotation` | Overrides `reloader.stakater.com/match` | | `--secret-annotation` | Overrides `secret.reloader.stakater.com/reload` | | `--configmap-annotation` | Overrides `configmap.reloader.stakater.com/reload` | +| `--pause-deployment-annotation` | Overrides `deployment.reloader.stakater.com/pause-period` | +| `--pause-deployment-time-annotation` | Overrides `deployment.reloader.stakater.com/paused-at` | ## Compatibility diff --git a/deployments/kubernetes/chart/reloader/templates/deployment.yaml b/deployments/kubernetes/chart/reloader/templates/deployment.yaml index e150647c..486dbb57 100644 --- a/deployments/kubernetes/chart/reloader/templates/deployment.yaml +++ b/deployments/kubernetes/chart/reloader/templates/deployment.yaml @@ -143,6 +143,15 @@ spec: fieldRef: fieldPath: metadata.namespace {{- end }} + + - name: RELOADER_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + + - name: RELOADER_DEPLOYMENT_NAME + value: {{ template "reloader-fullname" . }} + {{- if .Values.reloader.enableHA }} - name: POD_NAME valueFrom: diff --git a/deployments/kubernetes/chart/reloader/templates/role.yaml b/deployments/kubernetes/chart/reloader/templates/role.yaml index a031e3e2..70a68157 100644 --- a/deployments/kubernetes/chart/reloader/templates/role.yaml +++ b/deployments/kubernetes/chart/reloader/templates/role.yaml @@ -101,3 +101,34 @@ rules: - create - patch {{- end }} + +--- + +{{- if .Values.reloader.rbac.enabled }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + annotations: +{{ include "reloader-helm3.annotations" . | indent 4 }} + labels: +{{ include "reloader-labels.chart" . | indent 4 }} +{{- if .Values.reloader.rbac.labels }} +{{ tpl (toYaml .Values.reloader.rbac.labels) . | indent 4 }} +{{- end }} +{{- if .Values.reloader.matchLabels }} +{{ tpl (toYaml .Values.reloader.matchLabels) . | indent 4 }} +{{- end }} + name: {{ template "reloader-fullname" . }}-metadata-role + namespace: {{ .Values.namespace | default .Release.Namespace }} +rules: + - apiGroups: + - "" + resources: + - configmaps + verbs: + - list + - get + - watch + - create + - update +{{- end }} \ No newline at end of file diff --git a/deployments/kubernetes/chart/reloader/templates/rolebinding.yaml b/deployments/kubernetes/chart/reloader/templates/rolebinding.yaml index dccff31a..5cf4cf38 100644 --- a/deployments/kubernetes/chart/reloader/templates/rolebinding.yaml +++ b/deployments/kubernetes/chart/reloader/templates/rolebinding.yaml @@ -27,3 +27,30 @@ subjects: name: {{ template "reloader-serviceAccountName" . }} namespace: {{ .Values.namespace | default .Release.Namespace }} {{- end }} + +--- +{{- if .Values.reloader.rbac.enabled }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + annotations: +{{ include "reloader-helm3.annotations" . | indent 4 }} + labels: +{{ include "reloader-labels.chart" . | indent 4 }} +{{- if .Values.reloader.rbac.labels }} +{{ tpl (toYaml .Values.reloader.rbac.labels) . | indent 4 }} +{{- end }} +{{- if .Values.reloader.matchLabels }} +{{ tpl (toYaml .Values.reloader.matchLabels) . | indent 4 }} +{{- end }} + name: {{ template "reloader-fullname" . }}-metadata-role-binding + namespace: {{ .Values.namespace | default .Release.Namespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ template "reloader-fullname" . }}-metadata-role +subjects: + - kind: ServiceAccount + name: {{ template "reloader-serviceAccountName" . }} + namespace: {{ .Values.namespace | default .Release.Namespace }} +{{- end }} \ No newline at end of file diff --git a/deployments/kubernetes/kustomization.yaml b/deployments/kubernetes/kustomization.yaml index b4b7efbf..9bc6038a 100644 --- a/deployments/kubernetes/kustomization.yaml +++ b/deployments/kubernetes/kustomization.yaml @@ -6,3 +6,4 @@ resources: - manifests/clusterrolebinding.yaml - manifests/serviceaccount.yaml - manifests/deployment.yaml + - manifests/role.yaml diff --git a/deployments/kubernetes/manifests/deployment.yaml b/deployments/kubernetes/manifests/deployment.yaml index fb80fa47..11aa749d 100644 --- a/deployments/kubernetes/manifests/deployment.yaml +++ b/deployments/kubernetes/manifests/deployment.yaml @@ -31,6 +31,13 @@ spec: resourceFieldRef: resource: limits.memory divisor: '1' + - name: RELOADER_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + + - name: RELOADER_DEPLOYMENT_NAME + value: reloader-reloader ports: - name: http containerPort: 9090 diff --git a/deployments/kubernetes/manifests/role.yaml b/deployments/kubernetes/manifests/role.yaml new file mode 100644 index 00000000..0224ab9e --- /dev/null +++ b/deployments/kubernetes/manifests/role.yaml @@ -0,0 +1,32 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: reloader-reloader-metadata-role + namespace: default +rules: + - apiGroups: + - "" + resources: + - configmaps + verbs: + - list + - get + - watch + - create + - update + +--- + +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: reloader-reloader-metadata-rolebinding + namespace: default +subjects: + - kind: ServiceAccount + name: reloader-reloader + namespace: default +roleRef: + kind: Role + name: reloader-reloader-metadata-role + apiGroup: rbac.authorization.k8s.io \ No newline at end of file diff --git a/deployments/kubernetes/reloader.yaml b/deployments/kubernetes/reloader.yaml index 254420b1..9151e64d 100644 --- a/deployments/kubernetes/reloader.yaml +++ b/deployments/kubernetes/reloader.yaml @@ -5,6 +5,23 @@ metadata: namespace: default --- apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: reloader-reloader-metadata-role + namespace: default +rules: +- apiGroups: + - "" + resources: + - configmaps + verbs: + - list + - get + - watch + - create + - update +--- +apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: reloader-reloader-role @@ -64,6 +81,20 @@ rules: - patch --- apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: reloader-reloader-metadata-rolebinding + namespace: default +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: reloader-reloader-metadata-role +subjects: +- kind: ServiceAccount + name: reloader-reloader + namespace: default +--- +apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: reloader-reloader-role-binding @@ -104,7 +135,13 @@ spec: resourceFieldRef: divisor: "1" resource: limits.memory - image: "ghcr.io/stakater/reloader:latest" + - name: RELOADER_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: RELOADER_DEPLOYMENT_NAME + value: reloader-reloader + image: ghcr.io/stakater/reloader:latest imagePullPolicy: IfNotPresent livenessProbe: failureThreshold: 5 diff --git a/internal/pkg/cmd/reloader.go b/internal/pkg/cmd/reloader.go index 86f8379b..ab5284d4 100644 --- a/internal/pkg/cmd/reloader.go +++ b/internal/pkg/cmd/reloader.go @@ -14,7 +14,6 @@ import ( "github.com/sirupsen/logrus" "github.com/spf13/cobra" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/labels" "github.com/stakater/Reloader/internal/pkg/controller" "github.com/stakater/Reloader/internal/pkg/metrics" @@ -33,27 +32,7 @@ func NewReloaderCommand() *cobra.Command { } // options - cmd.PersistentFlags().BoolVar(&options.AutoReloadAll, "auto-reload-all", false, "Auto reload all resources") - cmd.PersistentFlags().StringVar(&options.ConfigmapUpdateOnChangeAnnotation, "configmap-annotation", "configmap.reloader.stakater.com/reload", "annotation to detect changes in configmaps, specified by name") - cmd.PersistentFlags().StringVar(&options.SecretUpdateOnChangeAnnotation, "secret-annotation", "secret.reloader.stakater.com/reload", "annotation to detect changes in secrets, specified by name") - cmd.PersistentFlags().StringVar(&options.ReloaderAutoAnnotation, "auto-annotation", "reloader.stakater.com/auto", "annotation to detect changes in secrets/configmaps") - cmd.PersistentFlags().StringVar(&options.ConfigmapReloaderAutoAnnotation, "configmap-auto-annotation", "configmap.reloader.stakater.com/auto", "annotation to detect changes in configmaps") - cmd.PersistentFlags().StringVar(&options.SecretReloaderAutoAnnotation, "secret-auto-annotation", "secret.reloader.stakater.com/auto", "annotation to detect changes in secrets") - cmd.PersistentFlags().StringVar(&options.AutoSearchAnnotation, "auto-search-annotation", "reloader.stakater.com/search", "annotation to detect changes in configmaps or secrets tagged with special match annotation") - cmd.PersistentFlags().StringVar(&options.SearchMatchAnnotation, "search-match-annotation", "reloader.stakater.com/match", "annotation to mark secrets or configmaps to match the search") - cmd.PersistentFlags().StringVar(&options.LogFormat, "log-format", "", "Log format to use (empty string for text, or JSON)") - cmd.PersistentFlags().StringVar(&options.LogLevel, "log-level", "info", "Log level to use (trace, debug, info, warning, error, fatal and panic)") - cmd.PersistentFlags().StringVar(&options.WebhookUrl, "webhook-url", "", "webhook to trigger instead of performing a reload") - cmd.PersistentFlags().StringSlice("resources-to-ignore", []string{}, "list of resources to ignore (valid options 'configMaps' or 'secrets')") - cmd.PersistentFlags().StringSlice("namespaces-to-ignore", []string{}, "list of namespaces to ignore") - cmd.PersistentFlags().StringSlice("namespace-selector", []string{}, "list of key:value labels to filter on for namespaces") - cmd.PersistentFlags().StringSlice("resource-label-selector", []string{}, "list of key:value labels to filter on for configmaps and secrets") - cmd.PersistentFlags().StringVar(&options.IsArgoRollouts, "is-Argo-Rollouts", "false", "Add support for argo rollouts") - cmd.PersistentFlags().StringVar(&options.ReloadStrategy, constants.ReloadStrategyFlag, constants.EnvVarsReloadStrategy, "Specifies the desired reload strategy") - cmd.PersistentFlags().StringVar(&options.ReloadOnCreate, "reload-on-create", "false", "Add support to watch create events") - cmd.PersistentFlags().StringVar(&options.ReloadOnDelete, "reload-on-delete", "false", "Add support to watch delete events") - cmd.PersistentFlags().BoolVar(&options.EnableHA, "enable-ha", false, "Adds support for running multiple replicas via leadership election") - cmd.PersistentFlags().BoolVar(&options.SyncAfterRestart, "sync-after-restart", false, "Sync add events after reloader restarts") + util.ConfigureReloaderFlags(cmd) return cmd } @@ -142,22 +121,22 @@ func startReloader(cmd *cobra.Command, args []string) { logrus.Fatal(err) } - ignoredResourcesList, err := getIgnoredResourcesList(cmd) + ignoredResourcesList, err := util.GetIgnoredResourcesList() if err != nil { logrus.Fatal(err) } - ignoredNamespacesList, err := getIgnoredNamespacesList(cmd) - if err != nil { - logrus.Fatal(err) + ignoredNamespacesList := options.NamespacesToIgnore + namespaceLabelSelector := "" + + if isGlobal { + namespaceLabelSelector, err = util.GetNamespaceLabelSelector() + if err != nil { + logrus.Fatal(err) + } } - namespaceLabelSelector, err := getNamespaceLabelSelector(cmd, isGlobal) - if err != nil { - logrus.Fatal(err) - } - - resourceLabelSelector, err := getResourceLabelSelector(cmd) + resourceLabelSelector, err := util.GetResourceLabelSelector() if err != nil { logrus.Fatal(err) } @@ -209,112 +188,8 @@ func startReloader(cmd *cobra.Command, args []string) { go leadership.RunLeaderElection(lock, ctx, cancel, podName, controllers) } + util.PublishMetaInfoConfigmap(clientset) + leadership.SetupLivenessEndpoint() logrus.Fatal(http.ListenAndServe(constants.DefaultHttpListenAddr, nil)) } - -func getIgnoredNamespacesList(cmd *cobra.Command) (util.List, error) { - return getStringSliceFromFlags(cmd, "namespaces-to-ignore") -} - -func getNamespaceLabelSelector(cmd *cobra.Command, isGlobal bool) (string, error) { - slice, err := getStringSliceFromFlags(cmd, "namespace-selector") - if err != nil { - logrus.Fatal(err) - } - - for i, kv := range slice { - // Legacy support for ":" as a delimiter and "*" for wildcard. - if strings.Contains(kv, ":") { - split := strings.Split(kv, ":") - if split[1] == "*" { - slice[i] = split[0] - } else { - slice[i] = split[0] + "=" + split[1] - } - } - // Convert wildcard to valid apimachinery operator - if strings.Contains(kv, "=") { - split := strings.Split(kv, "=") - if split[1] == "*" { - slice[i] = split[0] - } - } - } - - namespaceLabelSelector := strings.Join(slice[:], ",") - _, err = labels.Parse(namespaceLabelSelector) - if err != nil { - logrus.Fatal(err) - } - - if !isGlobal && len(namespaceLabelSelector) > 0 { - logrus.Warnf("KUBERNETES_NAMESPACE is set but also namespace-selector is set, will ignore the filter and detect changes in the specific namespace.") - return "", nil - } - - return namespaceLabelSelector, nil -} - -func getResourceLabelSelector(cmd *cobra.Command) (string, error) { - slice, err := getStringSliceFromFlags(cmd, "resource-label-selector") - if err != nil { - logrus.Fatal(err) - } - - for i, kv := range slice { - // Legacy support for ":" as a delimiter and "*" for wildcard. - if strings.Contains(kv, ":") { - split := strings.Split(kv, ":") - if split[1] == "*" { - slice[i] = split[0] - } else { - slice[i] = split[0] + "=" + split[1] - } - } - // Convert wildcard to valid apimachinery operator - if strings.Contains(kv, "=") { - split := strings.Split(kv, "=") - if split[1] == "*" { - slice[i] = split[0] - } - } - } - - resourceLabelSelector := strings.Join(slice[:], ",") - _, err = labels.Parse(resourceLabelSelector) - if err != nil { - logrus.Fatal(err) - } - - return resourceLabelSelector, nil -} - -func getStringSliceFromFlags(cmd *cobra.Command, flag string) ([]string, error) { - slice, err := cmd.Flags().GetStringSlice(flag) - if err != nil { - return nil, err - } - - return slice, nil -} - -func getIgnoredResourcesList(cmd *cobra.Command) (util.List, error) { - - ignoredResourcesList, err := getStringSliceFromFlags(cmd, "resources-to-ignore") - if err != nil { - return nil, err - } - - for _, v := range ignoredResourcesList { - if v != "configMaps" && v != "secrets" { - return nil, fmt.Errorf("'resources-to-ignore' only accepts 'configMaps' or 'secrets', not '%s'", v) - } - } - - if len(ignoredResourcesList) > 1 { - return nil, errors.New("'resources-to-ignore' only accepts 'configMaps' or 'secrets', not both") - } - - return ignoredResourcesList, nil -} diff --git a/internal/pkg/options/flags.go b/internal/pkg/options/flags.go index bfa63fc9..f8ddedf4 100644 --- a/internal/pkg/options/flags.go +++ b/internal/pkg/options/flags.go @@ -63,6 +63,14 @@ var ( EnableHA = false // Url to send a request to instead of triggering a reload WebhookUrl = "" + + ResourcesToIgnore = []string{} + + NamespacesToIgnore = []string{} + + NamespaceSelectors = []string{} + + ResourceSelectors = []string{} ) func ToArgoRolloutStrategy(s string) ArgoRolloutStrategy { diff --git a/internal/pkg/util/util.go b/internal/pkg/util/util.go index 1a2696d8..f8a5ddaa 100644 --- a/internal/pkg/util/util.go +++ b/internal/pkg/util/util.go @@ -2,12 +2,24 @@ package util import ( "bytes" + "context" "encoding/base64" + "errors" + "fmt" + "os" "sort" "strings" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/stakater/Reloader/internal/pkg/constants" "github.com/stakater/Reloader/internal/pkg/crypto" + "github.com/stakater/Reloader/internal/pkg/options" + "github.com/stakater/Reloader/pkg/metainfo" v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/kubernetes" ) // ConvertToEnvVarName converts the given text into a usable env var @@ -52,6 +64,39 @@ func GetSHAfromSecret(data map[string][]byte) string { return crypto.GenerateSHA(strings.Join(values, ";")) } +func PublishMetaInfoConfigmap(clientset kubernetes.Interface) { + namespace := os.Getenv("RELOADER_NAMESPACE") + if namespace == "" { + logrus.Warn("RELOADER_NAMESPACE is not set, skipping meta info configmap creation") + return + } + + metaInfo := &metainfo.MetaInfo{ + BuildInfo: *metainfo.NewBuildInfo(), + ReloaderOptions: *metainfo.GetReloaderOptions(), + DeploymentInfo: metav1.ObjectMeta{ + Name: os.Getenv("RELOADER_DEPLOYMENT_NAME"), + Namespace: namespace, + }, + } + + configMap := metaInfo.ToConfigMap() + + if _, err := clientset.CoreV1().ConfigMaps(namespace).Get(context.Background(), configMap.Name, metav1.GetOptions{}); err == nil { + logrus.Info("Meta info configmap already exists, updating it") + _, err = clientset.CoreV1().ConfigMaps(namespace).Update(context.Background(), configMap, metav1.UpdateOptions{}) + if err != nil { + logrus.Warn("Failed to update existing meta info configmap: ", err) + } + return + } + + _, err := clientset.CoreV1().ConfigMaps(namespace).Create(context.Background(), configMap, metav1.CreateOptions{}) + if err != nil { + logrus.Warn("Failed to create meta info configmap: ", err) + } +} + type List []string type Map map[string]string @@ -64,3 +109,108 @@ func (l *List) Contains(s string) bool { } return false } + +func ConfigureReloaderFlags(cmd *cobra.Command) { + cmd.PersistentFlags().BoolVar(&options.AutoReloadAll, "auto-reload-all", false, "Auto reload all resources") + cmd.PersistentFlags().StringVar(&options.ConfigmapUpdateOnChangeAnnotation, "configmap-annotation", "configmap.reloader.stakater.com/reload", "annotation to detect changes in configmaps, specified by name") + cmd.PersistentFlags().StringVar(&options.SecretUpdateOnChangeAnnotation, "secret-annotation", "secret.reloader.stakater.com/reload", "annotation to detect changes in secrets, specified by name") + cmd.PersistentFlags().StringVar(&options.ReloaderAutoAnnotation, "auto-annotation", "reloader.stakater.com/auto", "annotation to detect changes in secrets/configmaps") + cmd.PersistentFlags().StringVar(&options.ConfigmapReloaderAutoAnnotation, "configmap-auto-annotation", "configmap.reloader.stakater.com/auto", "annotation to detect changes in configmaps") + cmd.PersistentFlags().StringVar(&options.SecretReloaderAutoAnnotation, "secret-auto-annotation", "secret.reloader.stakater.com/auto", "annotation to detect changes in secrets") + cmd.PersistentFlags().StringVar(&options.AutoSearchAnnotation, "auto-search-annotation", "reloader.stakater.com/search", "annotation to detect changes in configmaps or secrets tagged with special match annotation") + cmd.PersistentFlags().StringVar(&options.SearchMatchAnnotation, "search-match-annotation", "reloader.stakater.com/match", "annotation to mark secrets or configmaps to match the search") + cmd.PersistentFlags().StringVar(&options.PauseDeploymentAnnotation, "pause-deployment-annotation", "deployment.reloader.stakater.com/pause-period", "annotation to define the time period to pause a deployment after a configmap/secret change has been detected") + cmd.PersistentFlags().StringVar(&options.PauseDeploymentTimeAnnotation, "pause-deployment-time-annotation", "deployment.reloader.stakater.com/paused-at", "annotation to indicate when a deployment was paused by Reloader") + cmd.PersistentFlags().StringVar(&options.LogFormat, "log-format", "", "Log format to use (empty string for text, or JSON)") + cmd.PersistentFlags().StringVar(&options.LogLevel, "log-level", "info", "Log level to use (trace, debug, info, warning, error, fatal and panic)") + cmd.PersistentFlags().StringVar(&options.WebhookUrl, "webhook-url", "", "webhook to trigger instead of performing a reload") + cmd.PersistentFlags().StringSliceVar(&options.ResourcesToIgnore, "resources-to-ignore", options.ResourcesToIgnore, "list of resources to ignore (valid options 'configMaps' or 'secrets')") + cmd.PersistentFlags().StringSliceVar(&options.NamespacesToIgnore, "namespaces-to-ignore", options.NamespacesToIgnore, "list of namespaces to ignore") + cmd.PersistentFlags().StringSliceVar(&options.NamespaceSelectors, "namespace-selector", options.NamespaceSelectors, "list of key:value labels to filter on for namespaces") + cmd.PersistentFlags().StringSliceVar(&options.ResourceSelectors, "resource-label-selector", options.ResourceSelectors, "list of key:value labels to filter on for configmaps and secrets") + cmd.PersistentFlags().StringVar(&options.IsArgoRollouts, "is-Argo-Rollouts", "false", "Add support for argo rollouts") + cmd.PersistentFlags().StringVar(&options.ReloadStrategy, constants.ReloadStrategyFlag, constants.EnvVarsReloadStrategy, "Specifies the desired reload strategy") + cmd.PersistentFlags().StringVar(&options.ReloadOnCreate, "reload-on-create", "false", "Add support to watch create events") + cmd.PersistentFlags().StringVar(&options.ReloadOnDelete, "reload-on-delete", "false", "Add support to watch delete events") + cmd.PersistentFlags().BoolVar(&options.EnableHA, "enable-ha", false, "Adds support for running multiple replicas via leadership election") + cmd.PersistentFlags().BoolVar(&options.SyncAfterRestart, "sync-after-restart", false, "Sync add events after reloader restarts") +} + +func GetNamespaceLabelSelector() (string, error) { + slice := options.NamespaceSelectors + + for i, kv := range slice { + // Legacy support for ":" as a delimiter and "*" for wildcard. + if strings.Contains(kv, ":") { + split := strings.Split(kv, ":") + if split[1] == "*" { + slice[i] = split[0] + } else { + slice[i] = split[0] + "=" + split[1] + } + } + // Convert wildcard to valid apimachinery operator + if strings.Contains(kv, "=") { + split := strings.Split(kv, "=") + if split[1] == "*" { + slice[i] = split[0] + } + } + } + + namespaceLabelSelector := strings.Join(slice[:], ",") + _, err := labels.Parse(namespaceLabelSelector) + if err != nil { + logrus.Fatal(err) + } + + return namespaceLabelSelector, nil +} + +func GetResourceLabelSelector() (string, error) { + slice := options.ResourceSelectors + + for i, kv := range slice { + // Legacy support for ":" as a delimiter and "*" for wildcard. + if strings.Contains(kv, ":") { + split := strings.Split(kv, ":") + if split[1] == "*" { + slice[i] = split[0] + } else { + slice[i] = split[0] + "=" + split[1] + } + } + // Convert wildcard to valid apimachinery operator + if strings.Contains(kv, "=") { + split := strings.Split(kv, "=") + if split[1] == "*" { + slice[i] = split[0] + } + } + } + + resourceLabelSelector := strings.Join(slice[:], ",") + _, err := labels.Parse(resourceLabelSelector) + if err != nil { + logrus.Fatal(err) + } + + return resourceLabelSelector, nil +} + +func GetIgnoredResourcesList() (List, error) { + + ignoredResourcesList := options.ResourcesToIgnore // getStringSliceFromFlags(cmd, "resources-to-ignore") + + for _, v := range ignoredResourcesList { + if v != "configMaps" && v != "secrets" { + return nil, fmt.Errorf("'resources-to-ignore' only accepts 'configMaps' or 'secrets', not '%s'", v) + } + } + + if len(ignoredResourcesList) > 1 { + return nil, errors.New("'resources-to-ignore' only accepts 'configMaps' or 'secrets', not both") + } + + return ignoredResourcesList, nil +} diff --git a/pkg/metainfo/metainfo.go b/pkg/metainfo/metainfo.go new file mode 100644 index 00000000..8cfad3cb --- /dev/null +++ b/pkg/metainfo/metainfo.go @@ -0,0 +1,234 @@ +package metainfo + +import ( + "encoding/json" + "fmt" + "runtime" + "strconv" + "time" + + "github.com/stakater/Reloader/internal/pkg/options" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Version, Commit, and BuildDate are set during the build process +// using the -X linker flag to inject these values into the binary. +// They provide metadata about the build version, commit hash, build date, and whether there are +// uncommitted changes in the source code at the time of build. +// This information is useful for debugging and tracking the specific build of the Reloader binary. +var Version = "dev" +var Commit = "unknown" +var BuildDate = "unknown" + +const ( + MetaInfoConfigmapName = "reloader-meta-info" + MetaInfoConfigmapLabelKey = "reloader.stakater.com/meta-info" + MetaInfoConfigmapLabelValue = "reloader-oss" +) + +// ReloaderOptions contains all configurable options for the Reloader controller. +// These options control how Reloader behaves when watching for changes in ConfigMaps and Secrets. +type ReloaderOptions struct { + // AutoReloadAll enables automatic reloading of all resources when their corresponding ConfigMaps/Secrets are updated + AutoReloadAll bool `json:"autoReloadAll"` + // ConfigmapUpdateOnChangeAnnotation is the annotation key used to detect changes in ConfigMaps specified by name + ConfigmapUpdateOnChangeAnnotation string `json:"configmapUpdateOnChangeAnnotation"` + // SecretUpdateOnChangeAnnotation is the annotation key used to detect changes in Secrets specified by name + SecretUpdateOnChangeAnnotation string `json:"secretUpdateOnChangeAnnotation"` + // ReloaderAutoAnnotation is the annotation key used to detect changes in any referenced ConfigMaps or Secrets + ReloaderAutoAnnotation string `json:"reloaderAutoAnnotation"` + // IgnoreResourceAnnotation is the annotation key used to ignore resources from being watched + IgnoreResourceAnnotation string `json:"ignoreResourceAnnotation"` + // ConfigmapReloaderAutoAnnotation is the annotation key used to detect changes in ConfigMaps only + ConfigmapReloaderAutoAnnotation string `json:"configmapReloaderAutoAnnotation"` + // SecretReloaderAutoAnnotation is the annotation key used to detect changes in Secrets only + SecretReloaderAutoAnnotation string `json:"secretReloaderAutoAnnotation"` + // ConfigmapExcludeReloaderAnnotation is the annotation key containing comma-separated list of ConfigMaps to exclude from watching + ConfigmapExcludeReloaderAnnotation string `json:"configmapExcludeReloaderAnnotation"` + // SecretExcludeReloaderAnnotation is the annotation key containing comma-separated list of Secrets to exclude from watching + SecretExcludeReloaderAnnotation string `json:"secretExcludeReloaderAnnotation"` + // AutoSearchAnnotation is the annotation key used to detect changes in ConfigMaps/Secrets tagged with SearchMatchAnnotation + AutoSearchAnnotation string `json:"autoSearchAnnotation"` + // SearchMatchAnnotation is the annotation key used to tag ConfigMaps/Secrets to be found by AutoSearchAnnotation + SearchMatchAnnotation string `json:"searchMatchAnnotation"` + // RolloutStrategyAnnotation is the annotation key used to define the rollout update strategy for workloads + RolloutStrategyAnnotation string `json:"rolloutStrategyAnnotation"` + // PauseDeploymentAnnotation is the annotation key used to define the time period to pause a deployment after + PauseDeploymentAnnotation string `json:"pauseDeploymentAnnotation"` + // PauseDeploymentTimeAnnotation is the annotation key used to indicate when a deployment was paused by Reloader + PauseDeploymentTimeAnnotation string `json:"pauseDeploymentTimeAnnotation"` + + // LogFormat specifies the log format to use (json, or empty string for default text format) + LogFormat string `json:"logFormat"` + // LogLevel specifies the log level to use (trace, debug, info, warning, error, fatal, panic) + LogLevel string `json:"logLevel"` + // IsArgoRollouts indicates whether support for Argo Rollouts is enabled + IsArgoRollouts bool `json:"isArgoRollouts"` + // ReloadStrategy specifies the strategy used to trigger resource reloads (env-vars or annotations) + ReloadStrategy string `json:"reloadStrategy"` + // ReloadOnCreate indicates whether to trigger reloads when ConfigMaps/Secrets are created + ReloadOnCreate bool `json:"reloadOnCreate"` + // ReloadOnDelete indicates whether to trigger reloads when ConfigMaps/Secrets are deleted + ReloadOnDelete bool `json:"reloadOnDelete"` + // SyncAfterRestart indicates whether to sync add events after Reloader restarts (only works when ReloadOnCreate is true) + SyncAfterRestart bool `json:"syncAfterRestart"` + // EnableHA indicates whether High Availability mode is enabled with leader election + EnableHA bool `json:"enableHA"` + // WebhookUrl is the URL to send webhook notifications to instead of performing reloads + WebhookUrl string `json:"webhookUrl"` + // ResourcesToIgnore is a list of resource types to ignore (e.g., "configmaps" or "secrets") + ResourcesToIgnore []string `json:"resourcesToIgnore"` + // NamespaceSelectors is a list of label selectors to filter namespaces to watch + NamespaceSelectors []string `json:"namespaceSelectors"` + // ResourceSelectors is a list of label selectors to filter ConfigMaps and Secrets to watch + ResourceSelectors []string `json:"resourceSelectors"` + // NamespacesToIgnore is a list of namespace names to ignore when watching for changes + NamespacesToIgnore []string `json:"namespacesToIgnore"` +} + +// MetaInfo contains comprehensive metadata about the Reloader instance. +// This includes build information, configuration options, and deployment details. +type MetaInfo struct { + // BuildInfo contains information about the build version, commit, and compilation details + BuildInfo BuildInfo `json:"buildInfo"` + // ReloaderOptions contains all the configuration options and flags used by this Reloader instance + ReloaderOptions ReloaderOptions `json:"reloaderOptions"` + // DeploymentInfo contains metadata about the Kubernetes deployment of this Reloader instance + DeploymentInfo metav1.ObjectMeta `json:"deploymentInfo"` +} + +func GetReloaderOptions() *ReloaderOptions { + return &ReloaderOptions{ + AutoReloadAll: options.AutoReloadAll, + ConfigmapUpdateOnChangeAnnotation: options.ConfigmapUpdateOnChangeAnnotation, + SecretUpdateOnChangeAnnotation: options.SecretUpdateOnChangeAnnotation, + ReloaderAutoAnnotation: options.ReloaderAutoAnnotation, + IgnoreResourceAnnotation: options.IgnoreResourceAnnotation, + ConfigmapReloaderAutoAnnotation: options.ConfigmapReloaderAutoAnnotation, + SecretReloaderAutoAnnotation: options.SecretReloaderAutoAnnotation, + ConfigmapExcludeReloaderAnnotation: options.ConfigmapExcludeReloaderAnnotation, + SecretExcludeReloaderAnnotation: options.SecretExcludeReloaderAnnotation, + AutoSearchAnnotation: options.AutoSearchAnnotation, + SearchMatchAnnotation: options.SearchMatchAnnotation, + RolloutStrategyAnnotation: options.RolloutStrategyAnnotation, + PauseDeploymentAnnotation: options.PauseDeploymentAnnotation, + PauseDeploymentTimeAnnotation: options.PauseDeploymentTimeAnnotation, + LogFormat: options.LogFormat, + LogLevel: options.LogLevel, + IsArgoRollouts: parseBool(options.IsArgoRollouts), + ReloadStrategy: options.ReloadStrategy, + ReloadOnCreate: parseBool(options.ReloadOnCreate), + ReloadOnDelete: parseBool(options.ReloadOnDelete), + SyncAfterRestart: options.SyncAfterRestart, + EnableHA: options.EnableHA, + WebhookUrl: options.WebhookUrl, + ResourcesToIgnore: options.ResourcesToIgnore, + NamespaceSelectors: options.NamespaceSelectors, + ResourceSelectors: options.ResourceSelectors, + NamespacesToIgnore: options.NamespacesToIgnore, + } +} + +// BuildInfo contains information about the build and version of the Reloader binary. +// This includes Go version, release version, commit details, and build timestamp. +type BuildInfo struct { + // GoVersion is the version of Go used to compile the binary + GoVersion string `json:"goVersion"` + // ReleaseVersion is the version tag or branch of the Reloader release + ReleaseVersion string `json:"releaseVersion"` + // CommitHash is the Git commit hash of the source code used to build this binary + CommitHash string `json:"commitHash"` + // CommitTime is the timestamp of the Git commit used to build this binary + CommitTime time.Time `json:"commitTime"` +} + +func NewBuildInfo() *BuildInfo { + metaInfo := &BuildInfo{ + GoVersion: runtime.Version(), + ReleaseVersion: Version, + CommitHash: Commit, + CommitTime: ParseUTCTime(BuildDate), + } + + return metaInfo +} + +func (m *MetaInfo) ToConfigMap() *v1.ConfigMap { + return &v1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: MetaInfoConfigmapName, + Namespace: m.DeploymentInfo.Namespace, + Labels: map[string]string{ + MetaInfoConfigmapLabelKey: MetaInfoConfigmapLabelValue, + }, + }, + Data: map[string]string{ + "buildInfo": toJson(m.BuildInfo), + "reloaderOptions": toJson(m.ReloaderOptions), + "deploymentInfo": toJson(m.DeploymentInfo), + }, + } +} + +func NewMetaInfo(configmap *v1.ConfigMap) (*MetaInfo, error) { + var buildInfo BuildInfo + if val, ok := configmap.Data["buildInfo"]; ok { + err := json.Unmarshal([]byte(val), &buildInfo) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal buildInfo: %w", err) + } + } + + var reloaderOptions ReloaderOptions + if val, ok := configmap.Data["reloaderOptions"]; ok { + err := json.Unmarshal([]byte(val), &reloaderOptions) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal reloaderOptions: %w", err) + } + } + + var deploymentInfo metav1.ObjectMeta + if val, ok := configmap.Data["deploymentInfo"]; ok { + err := json.Unmarshal([]byte(val), &deploymentInfo) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal deploymentInfo: %w", err) + } + } + + return &MetaInfo{ + BuildInfo: buildInfo, + ReloaderOptions: reloaderOptions, + DeploymentInfo: deploymentInfo, + }, nil +} + +func toJson(data interface{}) string { + jsonData, err := json.Marshal(data) + if err != nil { + return "" + } + return string(jsonData) +} + +func parseBool(value string) bool { + if value == "" { + return false + } + result, err := strconv.ParseBool(value) + if err != nil { + return false // Default to false if parsing fails + } + return result +} + +func ParseUTCTime(value string) time.Time { + if value == "" { + return time.Time{} // Return zero time if value is empty + } + t, err := time.Parse(time.RFC3339, value) + if err != nil { + return time.Time{} // Return zero time if parsing fails + } + return t +}