diff --git a/docs/README.md b/docs/README.md index 191fb38d..d84fa21e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,3 +5,4 @@ - Secure credentials [doc](https://github.com/kaasops/vector-operator/blob/main/docs/secure-credential.md) - Collect logs from file [doc](https://github.com/kaasops/vector-operator/blob/main/docs/logs-from-file.md) - Collect journald services logs [doc](https://github.com/kaasops/vector-operator/blob/main/docs/journald-logs.md) +- Force ConfigCheck via annotation [doc](https://github.com/kaasops/vector-operator/blob/main/docs/force-configcheck.md) diff --git a/docs/force-configcheck.md b/docs/force-configcheck.md new file mode 100644 index 00000000..0663d0be --- /dev/null +++ b/docs/force-configcheck.md @@ -0,0 +1,61 @@ +# Force ConfigCheck via Annotation + +## Problem + +The pipeline controller uses hash-based change detection that includes `Spec`, `Labels`, and `ServiceName` annotation. +When external dependencies change (Secrets, ConfigMaps, Aggregator endpoints), the hash remains the same and configcheck does not run. + +## Solution + +Annotate your VectorPipeline or ClusterVectorPipeline with `vector-operator.kaasops.io/force-configcheck` to trigger configcheck. + +The annotation value is included in the pipeline hash. When it changes, the hash changes, and configcheck runs. + +## Usage + +```bash +# Trigger configcheck +kubectl annotate vp my-pipeline \ + vector-operator.kaasops.io/force-configcheck="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +# Re-trigger (change the value) +kubectl annotate vp my-pipeline \ + vector-operator.kaasops.io/force-configcheck="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --overwrite + +# ClusterVectorPipeline +kubectl annotate cvp my-cluster-pipeline \ + vector-operator.kaasops.io/force-configcheck="v2" --overwrite + +# Batch: all pipelines with a specific label +kubectl get vp -l app=myapp -o name | \ + xargs -P10 -I{} kubectl annotate {} \ + vector-operator.kaasops.io/force-configcheck="$(date +%s)" --overwrite + +# Batch: all pipelines in a namespace +kubectl get vp -n production -o name | \ + xargs -P10 -I{} kubectl annotate -n production {} \ + vector-operator.kaasops.io/force-configcheck="$(date +%s)" --overwrite + +# Batch: only invalid pipelines +kubectl get vp -A -o json | \ + jq -r '.items[] | select(.status.configCheckResult == false) | "-n \(.metadata.namespace) vp \(.metadata.name)"' | \ + xargs -P10 -l kubectl annotate \ + vector-operator.kaasops.io/force-configcheck="$(date +%s)" --overwrite +``` + +## How it works + +1. User sets annotation with any value (timestamp, version, UUID) +2. The annotation value is included in the pipeline hash calculation +3. Changed value = changed hash = configcheck runs +4. After configcheck, the new hash is saved in `status.LastAppliedPipelineHash` +5. Same annotation value on next reconcile = same hash = no configcheck + +## Notes + +- The annotation value can be any non-empty string +- Setting the annotation to the same value has no effect (hash unchanged) +- Removing the annotation also changes the hash and triggers configcheck +- Pipelines without the annotation are unaffected (backward compatible) +- The controller never modifies the annotation — GitOps compatible diff --git a/internal/common/annotations.go b/internal/common/annotations.go index 4503849d..21da6ec4 100644 --- a/internal/common/annotations.go +++ b/internal/common/annotations.go @@ -1,6 +1,7 @@ package common const ( - AnnotationServiceName = "observability.kaasops.io/service-name" - AnnotationRestartedAt = "vector-operator.kaasops.io/restartedAt" + AnnotationServiceName = "observability.kaasops.io/service-name" + AnnotationRestartedAt = "vector-operator.kaasops.io/restartedAt" + AnnotationForceConfigCheck = "vector-operator.kaasops.io/force-configcheck" ) diff --git a/internal/pipeline/hash.go b/internal/pipeline/hash.go index 0219849a..204841cf 100644 --- a/internal/pipeline/hash.go +++ b/internal/pipeline/hash.go @@ -25,16 +25,18 @@ import ( ) type tmp struct { - Spec v1alpha1.VectorPipelineSpec - Labels map[string]string - ServiceName string + Spec v1alpha1.VectorPipelineSpec + Labels map[string]string + ServiceName string + ForceConfigCheck string `json:",omitempty"` } func GetPipelineHash(pipeline Pipeline) (*uint32, error) { a, err := json.Marshal(tmp{ - Spec: pipeline.GetSpec(), - Labels: pipeline.GetLabels(), - ServiceName: pipeline.GetAnnotations()[common.AnnotationServiceName], + Spec: pipeline.GetSpec(), + Labels: pipeline.GetLabels(), + ServiceName: pipeline.GetAnnotations()[common.AnnotationServiceName], + ForceConfigCheck: pipeline.GetAnnotations()[common.AnnotationForceConfigCheck], }) if err != nil { return nil, err diff --git a/test/e2e/force_configcheck_e2e_test.go b/test/e2e/force_configcheck_e2e_test.go new file mode 100644 index 00000000..78fd85f4 --- /dev/null +++ b/test/e2e/force_configcheck_e2e_test.go @@ -0,0 +1,174 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package e2e + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/kaasops/vector-operator/test/e2e/framework" + "github.com/kaasops/vector-operator/test/e2e/framework/config" +) + +const ( + forceConfigCheckAgent = "force-cc-agent" + forceConfigCheckPipeline = "force-cc-pipeline" + forceConfigCheckClusterPipeline = "force-cc-cvp" +) + +// Force ConfigCheck tests verify that the force-configcheck annotation +// triggers configcheck even when the pipeline spec has not changed. +var _ = Describe("Force ConfigCheck Annotation", Label(config.LabelSmoke, config.LabelFast), Ordered, func() { + f := framework.NewUniqueFramework("test-force-configcheck") + + BeforeAll(func() { + f.Setup() + + By("deploying Vector Agent") + f.ApplyTestData("force-configcheck/agent.yaml") + time.Sleep(5 * time.Second) + }) + + AfterAll(func() { + f.DeleteClusterResource("clustervectorpipeline", forceConfigCheckClusterPipeline) + f.Teardown() + f.PrintMetrics() + }) + + Context("pipeline without annotation", func() { + It("should validate pipeline normally", func() { + By("creating a VectorPipeline without force-configcheck annotation") + f.ApplyTestData("force-configcheck/pipeline.yaml") + + By("waiting for pipeline to become valid") + f.WaitForPipelineValid(forceConfigCheckPipeline) + + By("verifying agent processes the pipeline") + Eventually(func() error { + return f.VerifyAgentHasPipeline(forceConfigCheckAgent, forceConfigCheckPipeline) + }, config.ServiceCreateTimeout, config.DefaultPollInterval).Should(Succeed()) + }) + }) + + Context("adding force-configcheck annotation", func() { + It("should re-run configcheck when annotation is added", func() { + By("recording current pipeline hash") + hashBefore := f.GetPipelineStatus(forceConfigCheckPipeline, "LastAppliedPipelineHash") + Expect(hashBefore).NotTo(BeEmpty(), "Pipeline should have a hash after initial validation") + + By("applying pipeline with force-configcheck annotation set to v1") + f.ApplyTestData("force-configcheck/pipeline-with-annotation.yaml") + + By("waiting for pipeline to become valid again") + f.WaitForPipelineValid(forceConfigCheckPipeline) + + By("verifying pipeline hash changed due to annotation") + Eventually(func() string { + return f.GetPipelineStatus(forceConfigCheckPipeline, "LastAppliedPipelineHash") + }, config.PipelineValidTimeout, config.DefaultPollInterval).ShouldNot(Equal(hashBefore), + "Hash should change after adding force-configcheck annotation") + }) + }) + + Context("same annotation value", func() { + It("should not re-run configcheck for the same annotation value", func() { + By("recording current pipeline hash") + hashBefore := f.GetPipelineStatus(forceConfigCheckPipeline, "LastAppliedPipelineHash") + + By("re-applying pipeline with same annotation value v1") + f.ApplyTestData("force-configcheck/pipeline-with-annotation.yaml") + + By("waiting briefly for any reconciliation") + time.Sleep(5 * time.Second) + + By("verifying hash has not changed (no configcheck re-run)") + hashAfter := f.GetPipelineStatus(forceConfigCheckPipeline, "LastAppliedPipelineHash") + Expect(hashAfter).To(Equal(hashBefore), + "Hash should not change when annotation value is the same") + }) + }) + + Context("changed annotation value", func() { + It("should re-run configcheck when annotation value changes", func() { + By("recording current pipeline hash") + hashBefore := f.GetPipelineStatus(forceConfigCheckPipeline, "LastAppliedPipelineHash") + + By("applying pipeline with changed annotation value v2") + f.ApplyTestData("force-configcheck/pipeline-with-annotation-v2.yaml") + + By("waiting for pipeline to become valid again") + f.WaitForPipelineValid(forceConfigCheckPipeline) + + By("verifying pipeline hash changed due to new annotation value") + Eventually(func() string { + return f.GetPipelineStatus(forceConfigCheckPipeline, "LastAppliedPipelineHash") + }, config.PipelineValidTimeout, config.DefaultPollInterval).ShouldNot(Equal(hashBefore), + "Hash should change after changing force-configcheck annotation value") + }) + }) + + Context("ClusterVectorPipeline without annotation", func() { + It("should validate CVP normally", func() { + By("creating a ClusterVectorPipeline without force-configcheck annotation") + f.ApplyTestDataWithoutNamespaceReplacement("force-configcheck/cluster-pipeline.yaml") + + By("waiting for CVP to become valid") + f.WaitForClusterPipelineValid(forceConfigCheckClusterPipeline) + }) + }) + + Context("ClusterVectorPipeline with annotation", func() { + It("should re-run configcheck when annotation is added to CVP", func() { + By("recording current CVP hash") + hashBefore := f.GetClusterPipelineStatus(forceConfigCheckClusterPipeline, "LastAppliedPipelineHash") + Expect(hashBefore).NotTo(BeEmpty(), "CVP should have a hash after initial validation") + + By("applying CVP with force-configcheck annotation set to v1") + f.ApplyTestDataWithoutNamespaceReplacement("force-configcheck/cluster-pipeline-with-annotation.yaml") + + By("waiting for CVP to become valid again") + f.WaitForClusterPipelineValid(forceConfigCheckClusterPipeline) + + By("verifying CVP hash changed due to annotation") + Eventually(func() string { + return f.GetClusterPipelineStatus(forceConfigCheckClusterPipeline, "LastAppliedPipelineHash") + }, config.PipelineValidTimeout, config.DefaultPollInterval).ShouldNot(Equal(hashBefore), + "CVP hash should change after adding force-configcheck annotation") + }) + }) + + Context("ClusterVectorPipeline changed annotation value", func() { + It("should re-run configcheck when CVP annotation value changes", func() { + By("recording current CVP hash") + hashBefore := f.GetClusterPipelineStatus(forceConfigCheckClusterPipeline, "LastAppliedPipelineHash") + + By("applying CVP with changed annotation value v2") + f.ApplyTestDataWithoutNamespaceReplacement("force-configcheck/cluster-pipeline-with-annotation-v2.yaml") + + By("waiting for CVP to become valid again") + f.WaitForClusterPipelineValid(forceConfigCheckClusterPipeline) + + By("verifying CVP hash changed due to new annotation value") + Eventually(func() string { + return f.GetClusterPipelineStatus(forceConfigCheckClusterPipeline, "LastAppliedPipelineHash") + }, config.PipelineValidTimeout, config.DefaultPollInterval).ShouldNot(Equal(hashBefore), + "CVP hash should change after changing force-configcheck annotation value") + }) + }) +}) diff --git a/test/e2e/testdata/force-configcheck/agent.yaml b/test/e2e/testdata/force-configcheck/agent.yaml new file mode 100644 index 00000000..f1592d76 --- /dev/null +++ b/test/e2e/testdata/force-configcheck/agent.yaml @@ -0,0 +1,7 @@ +apiVersion: observability.kaasops.io/v1alpha1 +kind: Vector +metadata: + name: force-cc-agent +spec: + agent: + image: timberio/vector:0.40.0-alpine diff --git a/test/e2e/testdata/force-configcheck/cluster-pipeline-with-annotation-v2.yaml b/test/e2e/testdata/force-configcheck/cluster-pipeline-with-annotation-v2.yaml new file mode 100644 index 00000000..61aa0a86 --- /dev/null +++ b/test/e2e/testdata/force-configcheck/cluster-pipeline-with-annotation-v2.yaml @@ -0,0 +1,18 @@ +apiVersion: observability.kaasops.io/v1alpha1 +kind: ClusterVectorPipeline +metadata: + name: force-cc-cvp + annotations: + vector-operator.kaasops.io/force-configcheck: "v2" +spec: + sources: + kubernetes_logs: + type: kubernetes_logs + extra_label_selector: "app=test-cluster" + sinks: + console: + type: console + inputs: + - kubernetes_logs + encoding: + codec: json diff --git a/test/e2e/testdata/force-configcheck/cluster-pipeline-with-annotation.yaml b/test/e2e/testdata/force-configcheck/cluster-pipeline-with-annotation.yaml new file mode 100644 index 00000000..701eae7e --- /dev/null +++ b/test/e2e/testdata/force-configcheck/cluster-pipeline-with-annotation.yaml @@ -0,0 +1,18 @@ +apiVersion: observability.kaasops.io/v1alpha1 +kind: ClusterVectorPipeline +metadata: + name: force-cc-cvp + annotations: + vector-operator.kaasops.io/force-configcheck: "v1" +spec: + sources: + kubernetes_logs: + type: kubernetes_logs + extra_label_selector: "app=test-cluster" + sinks: + console: + type: console + inputs: + - kubernetes_logs + encoding: + codec: json diff --git a/test/e2e/testdata/force-configcheck/cluster-pipeline.yaml b/test/e2e/testdata/force-configcheck/cluster-pipeline.yaml new file mode 100644 index 00000000..86c33298 --- /dev/null +++ b/test/e2e/testdata/force-configcheck/cluster-pipeline.yaml @@ -0,0 +1,16 @@ +apiVersion: observability.kaasops.io/v1alpha1 +kind: ClusterVectorPipeline +metadata: + name: force-cc-cvp +spec: + sources: + kubernetes_logs: + type: kubernetes_logs + extra_label_selector: "app=test-cluster" + sinks: + console: + type: console + inputs: + - kubernetes_logs + encoding: + codec: json diff --git a/test/e2e/testdata/force-configcheck/pipeline-with-annotation-v2.yaml b/test/e2e/testdata/force-configcheck/pipeline-with-annotation-v2.yaml new file mode 100644 index 00000000..d6337aa9 --- /dev/null +++ b/test/e2e/testdata/force-configcheck/pipeline-with-annotation-v2.yaml @@ -0,0 +1,18 @@ +apiVersion: observability.kaasops.io/v1alpha1 +kind: VectorPipeline +metadata: + name: force-cc-pipeline + annotations: + vector-operator.kaasops.io/force-configcheck: "v2" +spec: + sources: + kubernetes_logs: + type: kubernetes_logs + extra_label_selector: "app=test-app" + sinks: + console: + type: console + inputs: + - kubernetes_logs + encoding: + codec: json diff --git a/test/e2e/testdata/force-configcheck/pipeline-with-annotation.yaml b/test/e2e/testdata/force-configcheck/pipeline-with-annotation.yaml new file mode 100644 index 00000000..93bec536 --- /dev/null +++ b/test/e2e/testdata/force-configcheck/pipeline-with-annotation.yaml @@ -0,0 +1,18 @@ +apiVersion: observability.kaasops.io/v1alpha1 +kind: VectorPipeline +metadata: + name: force-cc-pipeline + annotations: + vector-operator.kaasops.io/force-configcheck: "v1" +spec: + sources: + kubernetes_logs: + type: kubernetes_logs + extra_label_selector: "app=test-app" + sinks: + console: + type: console + inputs: + - kubernetes_logs + encoding: + codec: json diff --git a/test/e2e/testdata/force-configcheck/pipeline.yaml b/test/e2e/testdata/force-configcheck/pipeline.yaml new file mode 100644 index 00000000..6ee55c78 --- /dev/null +++ b/test/e2e/testdata/force-configcheck/pipeline.yaml @@ -0,0 +1,16 @@ +apiVersion: observability.kaasops.io/v1alpha1 +kind: VectorPipeline +metadata: + name: force-cc-pipeline +spec: + sources: + kubernetes_logs: + type: kubernetes_logs + extra_label_selector: "app=test-app" + sinks: + console: + type: console + inputs: + - kubernetes_logs + encoding: + codec: json