diff --git a/UNSAFE.md b/UNSAFE.md deleted file mode 100644 index 849166d3..00000000 --- a/UNSAFE.md +++ /dev/null @@ -1,3 +0,0 @@ -# Unsafe Settings - -To be written ... \ No newline at end of file diff --git a/api/falcon/v1alpha1/falconadmission_types.go b/api/falcon/v1alpha1/falconadmission_types.go index 685c40d5..cfa3ac3c 100644 --- a/api/falcon/v1alpha1/falconadmission_types.go +++ b/api/falcon/v1alpha1/falconadmission_types.go @@ -54,12 +54,6 @@ type FalconAdmissionSpec struct { // Falcon Admission Controller Version. The latest version will be selected when version specifier is missing. Example: 6.31, 6.31.0, 6.31.0-1409, etc. // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Falcon Admission Controller Version",order=8 Version *string `json:"version,omitempty"` - - // FalconUnsafe configures various options that go against industry practices or are otherwise not recommended for use. - // Adjusting these settings may result in incorrect or undesirable behavior. Proceed at your own risk. - // For more information, please see https://github.com/CrowdStrike/falcon-operator/blob/main/UNSAFE.md. - // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Falcon Admission Controller Unsafe Settings" - Unsafe FalconUnsafe `json:"unsafe,omitempty"` } type FalconAdmissionRQSpec struct { diff --git a/api/falcon/v1alpha1/unsafe.go b/api/falcon/v1alpha1/unsafe.go index b86886b4..743924b4 100644 --- a/api/falcon/v1alpha1/unsafe.go +++ b/api/falcon/v1alpha1/unsafe.go @@ -1,5 +1,13 @@ package v1alpha1 +import "strings" + +const ( + Force = "force" + Normal = "normal" + Off = "off" +) + // FalconUnsafe configures various options that go against industry practices or are otherwise not recommended for use. // Adjusting these settings may result in incorrect or undesirable behavior. Proceed at your own risk. // For more information, please see https://github.com/CrowdStrike/falcon-operator/blob/main/UNSAFE.md. @@ -7,4 +15,39 @@ type FalconUnsafe struct { // UpdatePolicy is the name of a sensor update policy configured and enabled in Falcon UI. It is ignored when Image and/or Version are set. // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Falcon Sensor Update Policy",order=1 UpdatePolicy *string `json:"updatePolicy,omitempty"` + + // AutoUpdate determines whether to install new versions of the sensor as they become available. Defaults to "off" and is ignored if FalconAPI is not set. + // Setting this to "force" causes the reconciler to run on every polling cycle, even if a new sensor version is not available. + // Setting it to "normal" only reconciles when a new version is detected. + // +kubebuilder:validation:Enum=off;normal;force + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Falcon Sensor Automatic Updates",order=2 + AutoUpdate *string `json:"autoUpdate,omitempty"` +} + +func (notSafe FalconUnsafe) GetUpdatePolicy() string { + if notSafe.UpdatePolicy == nil { + return "" + } + + return strings.TrimSpace(*notSafe.UpdatePolicy) +} + +func (notSafe FalconUnsafe) HasUpdatePolicy() bool { + return notSafe.GetUpdatePolicy() != "" +} + +func (notSafe FalconUnsafe) IsAutoUpdating() bool { + if notSafe.AutoUpdate == nil { + return false + } + + return *notSafe.AutoUpdate != "off" +} + +func (notSafe FalconUnsafe) IsAutoUpdatingForced() bool { + if notSafe.AutoUpdate == nil { + return false + } + + return *notSafe.AutoUpdate == "force" } diff --git a/api/falcon/v1alpha1/zz_generated.deepcopy.go b/api/falcon/v1alpha1/zz_generated.deepcopy.go index d9cb0bf7..04a131d4 100644 --- a/api/falcon/v1alpha1/zz_generated.deepcopy.go +++ b/api/falcon/v1alpha1/zz_generated.deepcopy.go @@ -462,7 +462,6 @@ func (in *FalconAdmissionSpec) DeepCopyInto(out *FalconAdmissionSpec) { *out = new(string) **out = **in } - in.Unsafe.DeepCopyInto(&out.Unsafe) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FalconAdmissionSpec. @@ -1209,6 +1208,11 @@ func (in *FalconUnsafe) DeepCopyInto(out *FalconUnsafe) { *out = new(string) **out = **in } + if in.AutoUpdate != nil { + in, out := &in.AutoUpdate, &out.AutoUpdate + *out = new(string) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FalconUnsafe. diff --git a/cmd/main.go b/cmd/main.go index ad118040..9fe3b0ad 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -40,6 +40,7 @@ import ( falconv1alpha1 "github.com/crowdstrike/falcon-operator/api/falcon/v1alpha1" admissioncontroller "github.com/crowdstrike/falcon-operator/internal/controller/admission" + "github.com/crowdstrike/falcon-operator/internal/controller/common/sensorversion" containercontroller "github.com/crowdstrike/falcon-operator/internal/controller/falcon_container" imageanalyzercontroller "github.com/crowdstrike/falcon-operator/internal/controller/falcon_image_analyzer" nodecontroller "github.com/crowdstrike/falcon-operator/internal/controller/falcon_node" @@ -48,6 +49,8 @@ import ( // +kubebuilder:scaffold:imports ) +const defaultSensorAutoUpdateInterval = time.Hour * 24 + var ( scheme = runtime.NewScheme() setupLog = ctrl.Log.WithName("setup") @@ -70,6 +73,7 @@ func main() { var enableProfiling bool var ver bool var err error + var sensorAutoUpdateInterval time.Duration flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") @@ -79,6 +83,7 @@ func main() { "Enable leader election for controller manager. "+ "Enabling this will ensure there is only one active controller manager.") flag.BoolVar(&ver, "version", false, "Print version") + flag.DurationVar(&sensorAutoUpdateInterval, "sensor-auto-update-interval", defaultSensorAutoUpdateInterval, "The rate at which the Falcon API is queried for new sensor versions") if env := os.Getenv("ARGS"); env != "" { os.Args = append(os.Args, strings.Split(env, " ")...) @@ -182,18 +187,21 @@ func main() { setupLog.Info("cert-manager installation not found") } + ctx := ctrl.SetupSignalHandler() + tracker := sensorversion.NewTracker(ctx, sensorAutoUpdateInterval) + if err = (&containercontroller.FalconContainerReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), RestConfig: mgr.GetConfig(), - }).SetupWithManager(mgr); err != nil { + }).SetupWithManager(mgr, tracker); err != nil { setupLog.Error(err, "unable to create controller", "controller", "FalconContainer") os.Exit(1) } if err = (&nodecontroller.FalconNodeSensorReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), - }).SetupWithManager(mgr); err != nil { + }).SetupWithManager(mgr, tracker); err != nil { setupLog.Error(err, "unable to create controller", "controller", "FalconNodeSensor") os.Exit(1) } @@ -240,8 +248,10 @@ func main() { }() } + go tracker.StartTracking() + setupLog.Info("starting manager", "version", version.Get(), "go version", version.GoVersion) - if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + if err := mgr.Start(ctx); err != nil { setupLog.Error(err, "problem running manager") os.Exit(1) } diff --git a/config/crd/bases/falcon.crowdstrike.com_falconadmissions.yaml b/config/crd/bases/falcon.crowdstrike.com_falconadmissions.yaml index b64d795c..89161a80 100644 --- a/config/crd/bases/falcon.crowdstrike.com_falconadmissions.yaml +++ b/config/crd/bases/falcon.crowdstrike.com_falconadmissions.yaml @@ -450,18 +450,6 @@ spec: can be created in the namespace. type: string type: object - unsafe: - description: FalconUnsafe configures various options that go against - industry practices or are otherwise not recommended for use. Adjusting - these settings may result in incorrect or undesirable behavior. - Proceed at your own risk. For more information, please see https://github.com/CrowdStrike/falcon-operator/blob/main/UNSAFE.md. - properties: - updatePolicy: - description: UpdatePolicy is the name of a sensor update policy - configured and enabled in Falcon UI. It is ignored when Image - and/or Version are set. - type: string - type: object version: description: 'Falcon Admission Controller Version. The latest version will be selected when version specifier is missing. Example: 6.31, diff --git a/config/crd/bases/falcon.crowdstrike.com_falconcontainers.yaml b/config/crd/bases/falcon.crowdstrike.com_falconcontainers.yaml index 344fe4d1..d85f7ea9 100644 --- a/config/crd/bases/falcon.crowdstrike.com_falconcontainers.yaml +++ b/config/crd/bases/falcon.crowdstrike.com_falconcontainers.yaml @@ -1930,6 +1930,18 @@ spec: these settings may result in incorrect or undesirable behavior. Proceed at your own risk. For more information, please see https://github.com/CrowdStrike/falcon-operator/blob/main/UNSAFE.md. properties: + autoUpdate: + description: AutoUpdate determines whether to install new versions + of the sensor as they become available. Defaults to "off" and + is ignored if FalconAPI is not set. Setting this to "force" + causes the reconciler to run on every polling cycle, even if + a new sensor version is not available. Setting it to "normal" + only reconciles when a new version is detected. + enum: + - "off" + - normal + - force + type: string updatePolicy: description: UpdatePolicy is the name of a sensor update policy configured and enabled in Falcon UI. It is ignored when Image diff --git a/config/crd/bases/falcon.crowdstrike.com_falconnodesensors.yaml b/config/crd/bases/falcon.crowdstrike.com_falconnodesensors.yaml index f6b69fe6..b59687f4 100644 --- a/config/crd/bases/falcon.crowdstrike.com_falconnodesensors.yaml +++ b/config/crd/bases/falcon.crowdstrike.com_falconnodesensors.yaml @@ -523,6 +523,18 @@ spec: behavior. Proceed at your own risk. For more information, please see https://github.com/CrowdStrike/falcon-operator/blob/main/UNSAFE.md. properties: + autoUpdate: + description: AutoUpdate determines whether to install new + versions of the sensor as they become available. Defaults + to "off" and is ignored if FalconAPI is not set. Setting + this to "force" causes the reconciler to run on every polling + cycle, even if a new sensor version is not available. Setting + it to "normal" only reconciles when a new version is detected. + enum: + - "off" + - normal + - force + type: string updatePolicy: description: UpdatePolicy is the name of a sensor update policy configured and enabled in Falcon UI. It is ignored when diff --git a/deploy/falcon-operator.yaml b/deploy/falcon-operator.yaml index 217e1407..c6ad2841 100644 --- a/deploy/falcon-operator.yaml +++ b/deploy/falcon-operator.yaml @@ -464,18 +464,6 @@ spec: can be created in the namespace. type: string type: object - unsafe: - description: FalconUnsafe configures various options that go against - industry practices or are otherwise not recommended for use. Adjusting - these settings may result in incorrect or undesirable behavior. - Proceed at your own risk. For more information, please see https://github.com/CrowdStrike/falcon-operator/blob/main/UNSAFE.md. - properties: - updatePolicy: - description: UpdatePolicy is the name of a sensor update policy - configured and enabled in Falcon UI. It is ignored when Image - and/or Version are set. - type: string - type: object version: description: 'Falcon Admission Controller Version. The latest version will be selected when version specifier is missing. Example: 6.31, @@ -2497,6 +2485,18 @@ spec: these settings may result in incorrect or undesirable behavior. Proceed at your own risk. For more information, please see https://github.com/CrowdStrike/falcon-operator/blob/main/UNSAFE.md. properties: + autoUpdate: + description: AutoUpdate determines whether to install new versions + of the sensor as they become available. Defaults to "off" and + is ignored if FalconAPI is not set. Setting this to "force" + causes the reconciler to run on every polling cycle, even if + a new sensor version is not available. Setting it to "normal" + only reconciles when a new version is detected. + enum: + - "off" + - normal + - force + type: string updatePolicy: description: UpdatePolicy is the name of a sensor update policy configured and enabled in Falcon UI. It is ignored when Image @@ -3534,6 +3534,18 @@ spec: behavior. Proceed at your own risk. For more information, please see https://github.com/CrowdStrike/falcon-operator/blob/main/UNSAFE.md. properties: + autoUpdate: + description: AutoUpdate determines whether to install new + versions of the sensor as they become available. Defaults + to "off" and is ignored if FalconAPI is not set. Setting + this to "force" causes the reconciler to run on every polling + cycle, even if a new sensor version is not available. Setting + it to "normal" only reconciles when a new version is detected. + enum: + - "off" + - normal + - force + type: string updatePolicy: description: UpdatePolicy is the name of a sensor update policy configured and enabled in Falcon UI. It is ignored when diff --git a/docs/UNSAFE.md b/docs/UNSAFE.md new file mode 100644 index 00000000..a051341a --- /dev/null +++ b/docs/UNSAFE.md @@ -0,0 +1,29 @@ +# Unsafe Settings + + Some of the operator's configurable settings involve features that conflict with established industry norms. These options are disabled by default as they carry a certain amount of risk, but they can be enabled in the `unsafe` section of each resource spec. What follows is a brief overview of the issues surrounding their use. + +## The Golden Rule of Kubernetes + +A fundamental principle underlying all Kubernetes operation is repeatability. Any given configuration should always produce the same result regardless of when or where it is applied or by whom. Another way of saying this is that a cluster should only ever do something because somebody explicitly called for it to happen. Anything that has variable behavior introduces uncertainty into the environment, and this can lead to problems that are difficult to diagnose. + +A common example is the use of image tags. These operate like pointers with many of the same concerns. The image they refer to can change without warning, and that can cause trouble. + +Consider a container spec that uses `nginx:latest`. What exactly will this deploy? Some version of nginx, presumably, but which version? What if it's not the version expected by the rest of the system? What if it's incompatible with other things in the cluster? Maybe everything works fine today, but what if tomorrow the container is moved to a different node? This tears down the old one and launches a new one. What if `latest` has changed to something new that breaks everything? There's no way to detect this beforehand. + +It is for these reasons and others that such practices are discouraged. A better approach given the above scenario is to use explicit image hashes. Instead of `nginx:latest`, one could use `nginx@sha256:447a8665...`. This uniquely identifies a particular version and package of nginx. It will never be anything else. All of the questions raised above become irrelevant. It is known what version will be deployed. It is known it will be the expected version. It is known new containers won't use anything else. It is safe. + +## Falcon's Unsafe Options + +Only some of the resources provided by the operator have unsafe properties. Each keeps them in slightly different places: + +* `spec.unsafe` for FalconContainer +* `spec.node.unsafe` for FalconNodeSensor + +Any options that go against recommended practices can be found here. Presently, that includes settings that affect the selection of Falcon sensor versions, which brings all of the issues of image tags described above. Details on these settings can be found in the respective resource documents. + +## More Information + +The issues around unsafe settings can be quite involved. The following are other resources that go into greater depth: + +* [Attack of the Mutant Tags! Or Why Tag Mutability is a Real Security Threat](https://sysdig.com/blog/toctou-tag-mutability/) +* [How to Ensure Consistent Kubernetes Container Versions](https://www.gremlin.com/blog/kubernetes-container-image-version-uniformity) diff --git a/docs/deployment/openshift/resources/container/README.md b/docs/deployment/openshift/resources/container/README.md index 9bdec325..3e9c65ff 100644 --- a/docs/deployment/openshift/resources/container/README.md +++ b/docs/deployment/openshift/resources/container/README.md @@ -87,6 +87,17 @@ spec: | falcon.tags | (optional) Configure Falcon Sensor Grouping Tags; comma-delimited | | falcon.trace | (optional) Configure Falcon Sensor Trace Logging Level (none, err, warn, info, debug) | +#### Unsafe Settings +The following settings provide an alternative means to select which version of Falcon sensor is deployed. Their use is not recommended. Instead, an explicit SHA256 hash should be configured using the `image` property above. + +See `docs/UNSAFE.md` for more details. + +| Spec | Default Value | Description | +| :- | :- | :- | +| unsafe.autoUpdate | `off` | Automatically updates a deployed Falcon sensor as new versions are released. This has no effect if a specific image or version has been requested. Valid settings are: +| unsafe.updatePolicy | _none_ | If set, applies the named Linux sensor update policy, configured in Falcon UI, to select which version of Falcon sensor to install. The policy must be enabled and must match the CPU architecture of the cluster (AMD64 or ARM64). | + +#### Status Conditions | Status | Description | | :---------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- | | conditions.["NamespaceReady"] | Displays the most recent reconciliation operation for the Namespace used by the Falcon Container Sensor (Created, Updated, Deleted) | diff --git a/docs/deployment/openshift/resources/node/README.md b/docs/deployment/openshift/resources/node/README.md index 96277f72..22694621 100644 --- a/docs/deployment/openshift/resources/node/README.md +++ b/docs/deployment/openshift/resources/node/README.md @@ -81,6 +81,16 @@ spec: | falcon.tags | (optional) Sensor grouping tags are optional, user-defined identifiers that can used to group and filter hosts. Allowed characters: all alphanumerics, '/', '-', and '_'. | | falcon.trace | (optional) Set sensor trace level. | +#### Unsafe Settings +The following settings provide an alternative means to select which version of Falcon sensor is deployed. Their use is not recommended. Instead, an explicit SHA256 hash should be configured using the `node.image` property above. + +See `docs/UNSAFE.md` for more details. + +| Spec | Default Value | Description | +| :- | :- | :- | +| node.unsafe.autoUpdate | `off` | Automatically updates a deployed Falcon sensor as new versions are released. This has no effect if a specific image or version has been requested. Valid settings are: +| node.unsafe.updatePolicy | _none_ | If set, applies the named Linux sensor update policy, configured in Falcon UI, to select which version of Falcon sensor to install. The policy must be enabled and must match the CPU architecture of the cluster (AMD64 or ARM64). | + > [!IMPORTANT] > All arguments are optional, but successful deployment requires either **client_id and falcon_secret or the Falcon cid and image**. When deploying using the CrowdStrike Falcon API, the container image and CID will be fetched from CrowdStrike Falcon API. While in the latter case, the CID and image location is explicitly specified by the user. diff --git a/docs/resources/container/README.md b/docs/resources/container/README.md index 07aff8a7..afc9e5e7 100644 --- a/docs/resources/container/README.md +++ b/docs/resources/container/README.md @@ -87,6 +87,17 @@ spec: | falcon.tags | (optional) Configure Falcon Sensor Grouping Tags; comma-delimited | | falcon.trace | (optional) Configure Falcon Sensor Trace Logging Level (none, err, warn, info, debug) | +#### Unsafe Settings +The following settings provide an alternative means to select which version of Falcon sensor is deployed. Their use is not recommended. Instead, an explicit SHA256 hash should be configured using the `image` property above. + +See `docs/UNSAFE.md` for more details. + +| Spec | Default Value | Description | +| :- | :- | :- | +| unsafe.autoUpdate | `off` | Automatically updates a deployed Falcon sensor as new versions are released. This has no effect if a specific image or version has been requested. Valid settings are: +| unsafe.updatePolicy | _none_ | If set, applies the named Linux sensor update policy, configured in Falcon UI, to select which version of Falcon sensor to install. The policy must be enabled and must match the CPU architecture of the cluster (AMD64 or ARM64). | + +#### Status Conditions | Status | Description | | :---------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- | | conditions.["NamespaceReady"] | Displays the most recent reconciliation operation for the Namespace used by the Falcon Container Sensor (Created, Updated, Deleted) | diff --git a/docs/resources/node/README.md b/docs/resources/node/README.md index aa3da3ac..6d007aa4 100644 --- a/docs/resources/node/README.md +++ b/docs/resources/node/README.md @@ -81,6 +81,16 @@ spec: | falcon.tags | (optional) Sensor grouping tags are optional, user-defined identifiers that can used to group and filter hosts. Allowed characters: all alphanumerics, '/', '-', and '_'. | | falcon.trace | (optional) Set sensor trace level. | +#### Unsafe Settings +The following settings provide an alternative means to select which version of Falcon sensor is deployed. Their use is not recommended. Instead, an explicit SHA256 hash should be configured using the `node.image` property above. + +See `docs/UNSAFE.md` for more details. + +| Spec | Default Value | Description | +| :- | :- | :- | +| node.unsafe.autoUpdate | `off` | Automatically updates a deployed Falcon sensor as new versions are released. This has no effect if a specific image or version has been requested. Valid settings are: +| node.unsafe.updatePolicy | _none_ | If set, applies the named Linux sensor update policy, configured in Falcon UI, to select which version of Falcon sensor to install. The policy must be enabled and must match the CPU architecture of the cluster (AMD64 or ARM64). | + > [!IMPORTANT] > All arguments are optional, but successful deployment requires either **client_id and falcon_secret or the Falcon cid and image**. When deploying using the CrowdStrike Falcon API, the container image and CID will be fetched from CrowdStrike Falcon API. While in the latter case, the CID and image location is explicitly specified by the user. diff --git a/docs/src/resources/container.md.tmpl b/docs/src/resources/container.md.tmpl index 8ef5d8ce..3c09bd4d 100644 --- a/docs/src/resources/container.md.tmpl +++ b/docs/src/resources/container.md.tmpl @@ -87,6 +87,17 @@ spec: | falcon.tags | (optional) Configure Falcon Sensor Grouping Tags; comma-delimited | | falcon.trace | (optional) Configure Falcon Sensor Trace Logging Level (none, err, warn, info, debug) | +#### Unsafe Settings +The following settings provide an alternative means to select which version of Falcon sensor is deployed. Their use is not recommended. Instead, an explicit SHA256 hash should be configured using the `image` property above. + +See `docs/UNSAFE.md` for more details. + +| Spec | Default Value | Description | +| :- | :- | :- | +| unsafe.autoUpdate | `off` | Automatically updates a deployed Falcon sensor as new versions are released. This has no effect if a specific image or version has been requested. Valid settings are: +| unsafe.updatePolicy | _none_ | If set, applies the named Linux sensor update policy, configured in Falcon UI, to select which version of Falcon sensor to install. The policy must be enabled and must match the CPU architecture of the cluster (AMD64 or ARM64). | + +#### Status Conditions | Status | Description | | :---------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- | | conditions.["NamespaceReady"] | Displays the most recent reconciliation operation for the Namespace used by the Falcon Container Sensor (Created, Updated, Deleted) | diff --git a/docs/src/resources/node.md.tmpl b/docs/src/resources/node.md.tmpl index 1eeca64e..ebe37506 100644 --- a/docs/src/resources/node.md.tmpl +++ b/docs/src/resources/node.md.tmpl @@ -81,6 +81,16 @@ spec: | falcon.tags | (optional) Sensor grouping tags are optional, user-defined identifiers that can used to group and filter hosts. Allowed characters: all alphanumerics, '/', '-', and '_'. | | falcon.trace | (optional) Set sensor trace level. | +#### Unsafe Settings +The following settings provide an alternative means to select which version of Falcon sensor is deployed. Their use is not recommended. Instead, an explicit SHA256 hash should be configured using the `node.image` property above. + +See `docs/UNSAFE.md` for more details. + +| Spec | Default Value | Description | +| :- | :- | :- | +| node.unsafe.autoUpdate | `off` | Automatically updates a deployed Falcon sensor as new versions are released. This has no effect if a specific image or version has been requested. Valid settings are: +| node.unsafe.updatePolicy | _none_ | If set, applies the named Linux sensor update policy, configured in Falcon UI, to select which version of Falcon sensor to install. The policy must be enabled and must match the CPU architecture of the cluster (AMD64 or ARM64). | + > [!IMPORTANT] > All arguments are optional, but successful deployment requires either **client_id and falcon_secret or the Falcon cid and image**. When deploying using the CrowdStrike Falcon API, the container image and CID will be fetched from CrowdStrike Falcon API. While in the latter case, the CID and image location is explicitly specified by the user. diff --git a/internal/apitest/apitest.go b/internal/apitest/apitest.go index ad6f443c..f2b0d307 100644 --- a/internal/apitest/apitest.go +++ b/internal/apitest/apitest.go @@ -8,23 +8,25 @@ import ( "github.com/stretchr/testify/mock" ) -type Test struct { +type Test[T any] struct { expectedOutputs []any goTest *testing.T inputs []any m *mock.Mock mockCalls []*mock.Call name string + runnerArgs T } -func NewTest(name string) *Test { - test := Test{ - name: name, +func NewTest[T any](name string, runnerArgs T) *Test[T] { + test := Test[T]{ + name: name, + runnerArgs: runnerArgs, } return &test } -func (test Test) AssertExpectations(outputs ...any) { +func (test Test[T]) AssertExpectations(outputs ...any) { test.m.AssertExpectations(test.goTest) for i, expectedValue := range test.expectedOutputs { @@ -32,24 +34,24 @@ func (test Test) AssertExpectations(outputs ...any) { } } -func (test *Test) ExpectOutputs(outputs ...any) *Test { +func (test *Test[T]) ExpectOutputs(outputs ...any) *Test[T] { test.expectedOutputs = outputs return test } -func (test Test) GetInput(index int) any { +func (test Test[T]) GetInput(index int) any { return test.inputs[index] } -func (test Test) GetStringPointerInput(index int) *string { +func (test Test[T]) GetStringPointerInput(index int) *string { return test.GetInput(index).(*string) } -func (test Test) GetMock() *mock.Mock { +func (test Test[T]) GetMock() *mock.Mock { return test.m } -func (test Test) Run(goTest *testing.T, runner func(Test)) { +func (test Test[T]) Run(goTest *testing.T, runner func(Test[T], T)) { test.m = &mock.Mock{} for _, call := range test.mockCalls { call.Parent = test.m @@ -58,16 +60,16 @@ func (test Test) Run(goTest *testing.T, runner func(Test)) { goTest.Run(test.name, func(goTest *testing.T) { test.goTest = goTest - runner(test) + runner(test, test.runnerArgs) }) } -func (test *Test) WithMockCall(call *mock.Mock) *Test { +func (test *Test[T]) WithMockCall(call *mock.Mock) *Test[T] { test.mockCalls = append(test.mockCalls, call.ExpectedCalls...) return test } -func (test *Test) WithInputs(inputs ...any) *Test { +func (test *Test[T]) WithInputs(inputs ...any) *Test[T] { test.inputs = inputs return test } diff --git a/internal/controller/admission/image_push.go b/internal/controller/admission/image_push.go index e65e0416..8d2dbd3c 100644 --- a/internal/controller/admission/image_push.go +++ b/internal/controller/admission/image_push.go @@ -10,13 +10,13 @@ import ( "k8s.io/apimachinery/pkg/types" falconv1alpha1 "github.com/crowdstrike/falcon-operator/api/falcon/v1alpha1" - "github.com/crowdstrike/falcon-operator/internal/controller/common/sensor" "github.com/crowdstrike/falcon-operator/internal/controller/image" "github.com/crowdstrike/falcon-operator/pkg/aws" "github.com/crowdstrike/falcon-operator/pkg/common" "github.com/crowdstrike/falcon-operator/pkg/gcp" "github.com/crowdstrike/falcon-operator/pkg/k8s_utils" "github.com/crowdstrike/falcon-operator/pkg/registry/auth" + "github.com/crowdstrike/falcon-operator/pkg/registry/falcon_registry" "github.com/crowdstrike/falcon-operator/pkg/registry/pushtoken" "github.com/crowdstrike/gofalcon/falcon" "github.com/go-logr/logr" @@ -193,13 +193,13 @@ func (r *FalconAdmissionReconciler) setImageTag(ctx context.Context, falconAdmis return *falconAdmission.Status.Sensor, r.Client.Status().Update(ctx, falconAdmission) } - apiConfig := r.falconApiConfig(ctx, falconAdmission) - imageRepo, err := sensor.NewImageRepository(ctx, apiConfig) + // Otherwise, get the newest version matching the requested version string + registry, err := falcon_registry.NewFalconRegistry(ctx, r.falconApiConfig(ctx, falconAdmission)) if err != nil { return "", err } - tag, err := imageRepo.GetPreferredImage(ctx, falcon.KacSensor, falconAdmission.Spec.Version, falconAdmission.Spec.Unsafe.UpdatePolicy) + tag, err := registry.LastContainerTag(ctx, falcon.KacSensor, falconAdmission.Spec.Version) if err == nil { falconAdmission.Status.Sensor = common.ImageVersion(tag) } @@ -223,9 +223,12 @@ func (r *FalconAdmissionReconciler) imageNamespace(falconAdmission *falconv1alph } func (r *FalconAdmissionReconciler) falconApiConfig(ctx context.Context, falconAdmission *falconv1alpha1.FalconAdmission) *falcon.ApiConfig { + if falconAdmission.Spec.FalconAPI == nil { + return nil + } + cfg := falconAdmission.Spec.FalconAPI.ApiConfig() cfg.Context = ctx - return cfg } diff --git a/internal/controller/admission/image_push_test.go b/internal/controller/admission/image_push_test.go new file mode 100644 index 00000000..9f0aaee3 --- /dev/null +++ b/internal/controller/admission/image_push_test.go @@ -0,0 +1,41 @@ +package controllers + +import ( + "testing" + + falconv1alpha1 "github.com/crowdstrike/falcon-operator/api/falcon/v1alpha1" + "github.com/stretchr/testify/assert" +) + +func TestVersionLock_WithDifferentVersion(t *testing.T) { + reconciler := &FalconAdmissionReconciler{} + admission := &falconv1alpha1.FalconAdmission{} + admission.Status.Sensor = stringPointer("some sensor") + admission.Spec.Version = stringPointer("different version") + assert.False(t, reconciler.versionLock(admission)) +} + +func TestVersionLock_WithLatestVersion(t *testing.T) { + reconciler := &FalconAdmissionReconciler{} + admission := &falconv1alpha1.FalconAdmission{} + admission.Status.Sensor = stringPointer("some sensor") + assert.True(t, reconciler.versionLock(admission)) +} + +func TestVersionLock_WithNoCurrentSensor(t *testing.T) { + reconciler := &FalconAdmissionReconciler{} + admission := &falconv1alpha1.FalconAdmission{} + assert.False(t, reconciler.versionLock(admission)) +} + +func TestVersionLock_WithSameVersion(t *testing.T) { + reconciler := &FalconAdmissionReconciler{} + admission := &falconv1alpha1.FalconAdmission{} + admission.Status.Sensor = stringPointer("some sensor") + admission.Spec.Version = admission.Status.Sensor + assert.True(t, reconciler.versionLock(admission)) +} + +func stringPointer(s string) *string { + return &s +} diff --git a/internal/controller/common/sensor/images.go b/internal/controller/common/sensor/images.go index 84c84f3e..a4bc7c18 100644 --- a/internal/controller/common/sensor/images.go +++ b/internal/controller/common/sensor/images.go @@ -2,20 +2,33 @@ package sensor import ( "context" + "errors" "fmt" + "runtime" "strings" "github.com/crowdstrike/falcon-operator/pkg/registry/falcon_registry" "github.com/crowdstrike/gofalcon/falcon" "github.com/crowdstrike/gofalcon/falcon/client/sensor_update_policies" + "github.com/crowdstrike/gofalcon/falcon/models" "github.com/go-logr/logr" "github.com/go-openapi/swag" "sigs.k8s.io/controller-runtime/pkg/log" ) +const amd64 = "amd64" +const arm64 = "arm64" +const arm64Platform = "LinuxArm64" + +var ( + errInvalidSensorVersion = errors.New("invalid sensor version") + errSensorVersionNotFound = errors.New("sensor version not found") +) + type ImageRepository struct { - api sensorUpdatePoliciesAPI - tags tagRegistry + api sensorUpdatePoliciesAPI + getSystemArchitecture func() string + tags tagRegistry } func NewImageRepository(ctx context.Context, apiConfig *falcon.ApiConfig) (ImageRepository, error) { @@ -30,13 +43,15 @@ func NewImageRepository(ctx context.Context, apiConfig *falcon.ApiConfig) (Image } return ImageRepository{ - api: apiClient.SensorUpdatePolicies, - tags: registry, + api: apiClient.SensorUpdatePolicies, + getSystemArchitecture: func() string { return runtime.GOARCH }, + tags: registry, }, nil } func (images ImageRepository) GetPreferredImage(ctx context.Context, sensorType falcon.SensorType, versionSpec *string, updatePolicySpec *string) (string, error) { logger := log.FromContext(ctx). + WithValues("architecture", images.getSystemArchitecture()). WithValues("sensorType", sensorType) version, err := images.getPreferredSensorVersion(versionSpec, updatePolicySpec, logger) @@ -80,7 +95,11 @@ func (images ImageRepository) findSensorVersionByUpdatePolicy(updatePolicy strin } version, err := images.getSensorVersionForPolicy(policyID) - if err != nil { + if err == errInvalidSensorVersion { + return "", fmt.Errorf("update-policy with ID %s has an invalid sensor version", policyID) + } else if err == errSensorVersionNotFound { + return "", fmt.Errorf("update-policy with ID %s contains no version for system architecture %s", policyID, images.getSystemArchitecture()) + } else if err != nil { return "", err } @@ -116,6 +135,17 @@ func (images ImageRepository) getPreferredSensorVersion(versionSpec *string, upd return nil, nil } +func (images ImageRepository) getSensorVersionForCurrentRuntimeArchitecture(policy *models.SensorUpdatePolicyV2) (string, error) { + switch images.getSystemArchitecture() { + case amd64: + return trimVersion(policy.Settings.SensorVersion) + case arm64: + return getARM64Variant(policy) + } + + return "", errSensorVersionNotFound +} + func (images ImageRepository) getSensorVersionForPolicy(policyID string) (string, error) { params := sensor_update_policies.NewGetSensorUpdatePoliciesV2Params().WithIds([]string{policyID}) response, err := images.api.GetSensorUpdatePoliciesV2(params) @@ -133,12 +163,17 @@ func (images ImageRepository) getSensorVersionForPolicy(policyID string) (string return "", fmt.Errorf("update-policy with ID %s is disabled", policyID) } - parts := strings.Split(*policy.Settings.SensorVersion, ".") - if len(parts) != 3 { - return "", fmt.Errorf("update-policy with ID %s has an invalid sensor version", policyID) + return images.getSensorVersionForCurrentRuntimeArchitecture(policy) +} + +func getARM64Variant(policy *models.SensorUpdatePolicyV2) (string, error) { + for _, variant := range policy.Settings.Variants { + if *variant.Platform == arm64Platform { + return trimVersion(variant.SensorVersion) + } } - return strings.Join(parts[0:2], "."), nil + return "", errSensorVersionNotFound } func getNonZeroValuesInSlice[T any](input []T) []T { @@ -153,6 +188,24 @@ func getNonZeroValuesInSlice[T any](input []T) []T { return output } +func trimVersion(version *string) (string, error) { + if version == nil { + return "", errSensorVersionNotFound + } + + trimmed := strings.TrimSpace(*version) + if trimmed == "" { + return "", errSensorVersionNotFound + } + + parts := strings.Split(trimmed, ".") + if len(parts) != 3 { + return "", errInvalidSensorVersion + } + + return strings.Join(parts[0:2], "."), nil +} + type falconFilter struct { clauses []string } diff --git a/internal/controller/common/sensor/images_test.go b/internal/controller/common/sensor/images_test.go index d59ad6a5..f40ceb22 100644 --- a/internal/controller/common/sensor/images_test.go +++ b/internal/controller/common/sensor/images_test.go @@ -16,11 +16,12 @@ import ( func TestGetPreferredImage(t *testing.T) { ctx := context.Background() - runner := func(t apitest.Test) { + runner := func(t apitest.Test[string], architecture string) { m := &mockFalcon{Mock: *t.GetMock()} images := ImageRepository{ - api: m, - tags: m, + api: m, + getSystemArchitecture: func() string { return architecture }, + tags: m, } image, err := images.GetPreferredImage( @@ -36,82 +37,124 @@ func TestGetPreferredImage(t *testing.T) { noUpdatePolicyRequested := (*string)(nil) noVersionRequested := (*string)(nil) + const policyDoesNotExist = false + const policyExists = true + + const excludeArmVersion = false + const includeArmVersion = true + const policyDisabled = false const policyEnabled = true - apitest.NewTest("latestVersion"). + apitest.NewTest("latestVersion", arm64). WithInputs(falcon.SidecarSensor, noVersionRequested, noUpdatePolicyRequested). ExpectOutputs("someImageTag", noError). WithMockCall(newLastContainerTagCall(ctx, falcon.SidecarSensor, noVersionRequested, "someImageTag", noError)). Run(t, runner) - apitest.NewTest("latestNodeSensorVersion"). + apitest.NewTest("latestNodeSensorVersion", arm64). WithInputs(falcon.NodeSensor, noVersionRequested, noUpdatePolicyRequested). ExpectOutputs("someNodeImageTag", noError). WithMockCall(newLastNodeTagCall(ctx, noVersionRequested, "someNodeImageTag", noError)). Run(t, runner) - apitest.NewTest("specificVersion"). + apitest.NewTest("specificVersion", arm64). WithInputs(falcon.SidecarSensor, stringPointer("someSpecificVersion"), noUpdatePolicyRequested). ExpectOutputs("imageByVersion", noError). WithMockCall(newLastContainerTagCall(ctx, falcon.SidecarSensor, stringPointer("someSpecificVersion"), "imageByVersion", noError)). Run(t, runner) - apitest.NewTest("versionByPolicy"). + apitest.NewTest("amdVersionByPolicy", amd64). + WithInputs(falcon.SidecarSensor, noVersionRequested, stringPointer("somePolicyName")). + ExpectOutputs("imageByPolicy", noError). + WithMockCall(newQuerySensorUpdatePoliciesCall("somePolicyName", "somePolicyID", noError)). + WithMockCall(newGetSensorUpdatePoliciesCall("somePolicyID", policyExists, includeArmVersion, stringPointer("1.2.3"), policyEnabled, noError)). + WithMockCall(newLastContainerTagCall(ctx, falcon.SidecarSensor, stringPointer("1.2"), "imageByPolicy", noError)). + Run(t, runner) + + apitest.NewTest("armVersionByPolicy", arm64). WithInputs(falcon.SidecarSensor, noVersionRequested, stringPointer("somePolicyName")). ExpectOutputs("imageByPolicy", noError). WithMockCall(newQuerySensorUpdatePoliciesCall("somePolicyName", "somePolicyID", noError)). - WithMockCall(newGetSensorUpdatePoliciesCall("somePolicyID", "1.2.3", policyEnabled, noError)). + WithMockCall(newGetSensorUpdatePoliciesCall("somePolicyID", policyExists, includeArmVersion, stringPointer("1.2.3"), policyEnabled, noError)). WithMockCall(newLastContainerTagCall(ctx, falcon.SidecarSensor, stringPointer("1.2"), "imageByPolicy", noError)). Run(t, runner) - apitest.NewTest("querySensorUpdatePoliciesFails"). + apitest.NewTest("querySensorUpdatePoliciesFails", arm64). WithInputs(falcon.SidecarSensor, noVersionRequested, stringPointer("somePolicyName")). ExpectOutputs("", assert.AnError). WithMockCall(newQuerySensorUpdatePoliciesCall("somePolicyName", "", assert.AnError)). Run(t, runner) - apitest.NewTest("getSensorUpdatePoliciesFails"). + apitest.NewTest("getSensorUpdatePoliciesFails", arm64). WithInputs(falcon.SidecarSensor, noVersionRequested, stringPointer("somePolicyName")). ExpectOutputs("", assert.AnError). WithMockCall(newQuerySensorUpdatePoliciesCall("somePolicyName", "somePolicyID", noError)). - WithMockCall(newGetSensorUpdatePoliciesCall("somePolicyID", "", policyDisabled, assert.AnError)). + WithMockCall(newGetSensorUpdatePoliciesCall("somePolicyID", policyExists, includeArmVersion, nil, policyDisabled, assert.AnError)). Run(t, runner) - apitest.NewTest("policyNameNotFound"). + apitest.NewTest("policyNameNotFound", arm64). WithInputs(falcon.SidecarSensor, noVersionRequested, stringPointer("somePolicyName")). ExpectOutputs("", errors.New("update-policy somePolicyName not found")). WithMockCall(newQuerySensorUpdatePoliciesCall("somePolicyName", "", noError)). Run(t, runner) - apitest.NewTest("policyIDNotFound"). + apitest.NewTest("policyIDNotFound", arm64). WithInputs(falcon.SidecarSensor, noVersionRequested, stringPointer("somePolicyName")). ExpectOutputs("", errors.New("update-policy with ID somePolicyID not found")). WithMockCall(newQuerySensorUpdatePoliciesCall("somePolicyName", "somePolicyID", noError)). - WithMockCall(newGetSensorUpdatePoliciesCall("somePolicyID", "", policyDisabled, noError)). + WithMockCall(newGetSensorUpdatePoliciesCall("somePolicyID", policyDoesNotExist, includeArmVersion, nil, policyDisabled, noError)). Run(t, runner) - apitest.NewTest("policyDisabled"). + apitest.NewTest("policyDisabled", arm64). WithInputs(falcon.SidecarSensor, noVersionRequested, stringPointer("somePolicyName")). ExpectOutputs("", errors.New("update-policy with ID somePolicyID is disabled")). WithMockCall(newQuerySensorUpdatePoliciesCall("somePolicyName", "somePolicyID", noError)). - WithMockCall(newGetSensorUpdatePoliciesCall("somePolicyID", "1.2.3", policyDisabled, noError)). + WithMockCall(newGetSensorUpdatePoliciesCall("somePolicyID", policyExists, includeArmVersion, stringPointer("1.2.3"), policyDisabled, noError)). Run(t, runner) - apitest.NewTest("invalidSensorVersion"). + apitest.NewTest("nilSensorVersion", arm64). + WithInputs(falcon.SidecarSensor, noVersionRequested, stringPointer("somePolicyName")). + ExpectOutputs("", errors.New("update-policy with ID somePolicyID contains no version for system architecture arm64")). + WithMockCall(newQuerySensorUpdatePoliciesCall("somePolicyName", "somePolicyID", noError)). + WithMockCall(newGetSensorUpdatePoliciesCall("somePolicyID", policyExists, includeArmVersion, nil, policyEnabled, noError)). + Run(t, runner) + + apitest.NewTest("blankSensorVersion", arm64). + WithInputs(falcon.SidecarSensor, noVersionRequested, stringPointer("somePolicyName")). + ExpectOutputs("", errors.New("update-policy with ID somePolicyID contains no version for system architecture arm64")). + WithMockCall(newQuerySensorUpdatePoliciesCall("somePolicyName", "somePolicyID", noError)). + WithMockCall(newGetSensorUpdatePoliciesCall("somePolicyID", policyExists, includeArmVersion, stringPointer(""), policyEnabled, noError)). + Run(t, runner) + + apitest.NewTest("invalidSensorVersion", arm64). WithInputs(falcon.SidecarSensor, noVersionRequested, stringPointer("somePolicyName")). ExpectOutputs("", errors.New("update-policy with ID somePolicyID has an invalid sensor version")). WithMockCall(newQuerySensorUpdatePoliciesCall("somePolicyName", "somePolicyID", noError)). - WithMockCall(newGetSensorUpdatePoliciesCall("somePolicyID", "1.2", policyEnabled, noError)). + WithMockCall(newGetSensorUpdatePoliciesCall("somePolicyID", policyExists, includeArmVersion, stringPointer("1.2"), policyEnabled, noError)). Run(t, runner) - apitest.NewTest("lastContainerTagFails"). + apitest.NewTest("unconfiguredArmVariantNotFound", arm64). + WithInputs(falcon.SidecarSensor, noVersionRequested, stringPointer("somePolicyName")). + ExpectOutputs("", errors.New("update-policy with ID somePolicyID contains no version for system architecture arm64")). + WithMockCall(newQuerySensorUpdatePoliciesCall("somePolicyName", "somePolicyID", noError)). + WithMockCall(newGetSensorUpdatePoliciesCall("somePolicyID", policyExists, excludeArmVersion, stringPointer("1.2.3"), policyEnabled, noError)). + Run(t, runner) + + apitest.NewTest("unknownArchitectureVariantNotFound", "unknownArchitecture"). + WithInputs(falcon.SidecarSensor, noVersionRequested, stringPointer("somePolicyName")). + ExpectOutputs("", errors.New("update-policy with ID somePolicyID contains no version for system architecture unknownArchitecture")). + WithMockCall(newQuerySensorUpdatePoliciesCall("somePolicyName", "somePolicyID", noError)). + WithMockCall(newGetSensorUpdatePoliciesCall("somePolicyID", policyExists, includeArmVersion, stringPointer("1.2.3"), policyEnabled, noError)). + Run(t, runner) + + apitest.NewTest("lastContainerTagFails", arm64). WithInputs(falcon.SidecarSensor, noVersionRequested, noUpdatePolicyRequested). ExpectOutputs("", assert.AnError). WithMockCall(newLastContainerTagCall(ctx, falcon.SidecarSensor, noVersionRequested, "", assert.AnError)). Run(t, runner) - apitest.NewTest("lastNodeTagFails"). + apitest.NewTest("lastNodeTagFails", arm64). WithInputs(falcon.NodeSensor, noVersionRequested, noUpdatePolicyRequested). ExpectOutputs("", assert.AnError). WithMockCall(newLastNodeTagCall(ctx, noVersionRequested, "", assert.AnError)). @@ -142,19 +185,28 @@ func (m *mockFalcon) QuerySensorUpdatePolicies(params *sensor_update_policies.Qu return args.Get(0).(*sensor_update_policies.QuerySensorUpdatePoliciesOK), args.Error(1) } -func newGetSensorUpdatePoliciesCall(policyID string, expectedVersion string, expectedStatus bool, expectedError error) *mock.Mock { +func newGetSensorUpdatePoliciesCall(policyID string, policyExists bool, includeArmVersion bool, expectedVersion *string, expectedStatus bool, expectedError error) *mock.Mock { params := sensor_update_policies.NewGetSensorUpdatePoliciesV2Params().WithIds([]string{policyID}) payload := &models.SensorUpdateRespV2{} - if expectedVersion != "" { + if policyExists { payload.Resources = []*models.SensorUpdatePolicyV2{ { Enabled: &expectedStatus, Settings: &models.SensorUpdateSettingsRespV2{ - SensorVersion: stringPointer(expectedVersion), + SensorVersion: expectedVersion, }, }, } + + if includeArmVersion { + payload.Resources[0].Settings.Variants = []*models.SensorUpdateBuildRespV1{ + { + Platform: stringPointer(arm64Platform), + SensorVersion: expectedVersion, + }, + } + } } m := &mock.Mock{} diff --git a/internal/controller/common/sensorversion/falcon-cloud-query.go b/internal/controller/common/sensorversion/falcon-cloud-query.go new file mode 100644 index 00000000..675134b0 --- /dev/null +++ b/internal/controller/common/sensorversion/falcon-cloud-query.go @@ -0,0 +1,36 @@ +package sensorversion + +import ( + "context" + + "github.com/crowdstrike/falcon-operator/pkg/registry/falcon_registry" + "github.com/crowdstrike/gofalcon/falcon" +) + +func NewFalconCloudQuery(sensorType falcon.SensorType, apiConfig *falcon.ApiConfig) SensorVersionQuery { + return func(ctx context.Context) (string, error) { + return getLatestSensorVersion(ctx, sensorType, apiConfig) + } +} + +func getLatestSensorVersion(ctx context.Context, sensorType falcon.SensorType, apiConfig *falcon.ApiConfig) (string, error) { + if sensorType == falcon.NodeSensor { + return getLatestSensorNodeVersion(ctx, apiConfig) + } + + registry, err := falcon_registry.NewFalconRegistry(ctx, apiConfig) + if err != nil { + return "", err + } + + return registry.LastContainerTag(ctx, sensorType, nil) +} + +func getLatestSensorNodeVersion(ctx context.Context, apiConfig *falcon.ApiConfig) (string, error) { + registry, err := falcon_registry.NewFalconRegistry(ctx, apiConfig) + if err != nil { + return "", err + } + + return registry.LastNodeTag(ctx, nil) +} diff --git a/internal/controller/common/sensorversion/tracker.go b/internal/controller/common/sensorversion/tracker.go new file mode 100644 index 00000000..3e579f57 --- /dev/null +++ b/internal/controller/common/sensorversion/tracker.go @@ -0,0 +1,168 @@ +package sensorversion + +import ( + "context" + "time" + + "github.com/go-logr/logr" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +type Handler func(context.Context, types.NamespacedName) error +type SensorVersionQuery func(context.Context) (string, error) + +type Tracker struct { + activeTracks map[types.NamespacedName]*track + ctx context.Context + logger logr.Logger + pollingInterval time.Duration + trackUpdates chan track +} + +type track struct { + forceHandler bool + getSensorVersion SensorVersionQuery + handler Handler + name types.NamespacedName + priorVersion string +} + +func NewTracker(ctx context.Context, pollingInterval time.Duration) Tracker { + return Tracker{ + activeTracks: make(map[types.NamespacedName]*track), + ctx: ctx, + logger: log.FromContext(ctx).WithName("sensor-version-tracker"), + pollingInterval: pollingInterval, + trackUpdates: make(chan track), + } +} + +func (tracker Tracker) StartTracking() { + const backoffInterval = time.Second * 5 + + for { + err := tracker.TrackChanges() + if err == nil { + break + } + + tracker.logger.Error(err, "change-tracking failed") + time.Sleep(backoffInterval) + } +} + +func (tracker Tracker) StopTracking(name types.NamespacedName) { + tracker.trackUpdates <- track{ + name: name, + } +} + +func (tracker Tracker) Track(name types.NamespacedName, getSensorVersion SensorVersionQuery, handler Handler, forceHandler bool) { + tracker.trackUpdates <- track{ + forceHandler: forceHandler, + getSensorVersion: getSensorVersion, + handler: handler, + name: name, + } +} + +func (tracker Tracker) TrackChanges() error { + tracker.logDebug("started tracking changes") + + timer := time.NewTimer(0) + + for { + select { + case <-tracker.ctx.Done(): + tracker.logDebug("stopped tracking changes") + return nil + + case update := <-tracker.trackUpdates: + if update.getSensorVersion != nil && update.handler != nil { + if err := tracker.updateTrack(update); err != nil { + return err + } + } else { + if _, exists := tracker.activeTracks[update.name]; exists { + delete(tracker.activeTracks, update.name) + tracker.logDebug("deleted track", "namespace", update.name.Namespace, "name", update.name.Name) + } + } + + case <-timer.C: + if err := tracker.runPollingCycle(); err != nil { + return err + } + + timer.Reset(tracker.pollingInterval) + tracker.logDebug("waiting for next polling cycle", "interval", tracker.pollingInterval.String()) + } + } +} + +func NewTestTracker() (Tracker, func()) { + ctx, cancel := context.WithCancel(context.Background()) + tracker := NewTracker(ctx, time.Hour) + go tracker.StartTracking() + return tracker, cancel +} + +func (tracker Tracker) logDebug(msg string, keysAndValues ...any) { + tracker.logger.V(1).Info(msg, keysAndValues...) +} + +func (tracker Tracker) runPollingCycle() error { + tracker.logDebug("started polling cycle") + + for name, trk := range tracker.activeTracks { + latestVersion, err := trk.getSensorVersion(tracker.ctx) + if err != nil { + return err + } + tracker.logDebug("latest available sensor version", "namespace", name.Namespace, "name", name.Name, "version", latestVersion) + + if latestVersion != trk.priorVersion || trk.forceHandler { + if latestVersion != trk.priorVersion { + tracker.logDebug("sensor version changed, calling handler", "namespace", name.Namespace, "name", name.Name, "priorVersion", trk.priorVersion, "newVersion", latestVersion) + } else { + tracker.logDebug("sensor version unchanged, but calling handler anyway", "namespace", name.Namespace, "name", name.Name, "latestAvailableVersion", latestVersion) + } + + if err := trk.handler(tracker.ctx, name); err != nil { + return err + } + } + + trk.priorVersion = latestVersion + } + + return nil +} + +func (tracker Tracker) updateTrack(update track) error { + trk, exists := tracker.activeTracks[update.name] + if exists { + trk.forceHandler = update.forceHandler + trk.getSensorVersion = update.getSensorVersion + trk.handler = update.handler + tracker.logDebug("updated track", "namespace", update.name.Namespace, "name", update.name.Name, "forceHandler", update.forceHandler) + return nil + } + + initialVersion, err := update.getSensorVersion(tracker.ctx) + if err != nil { + return err + } + + tracker.activeTracks[update.name] = &track{ + forceHandler: update.forceHandler, + getSensorVersion: update.getSensorVersion, + handler: update.handler, + name: update.name, + priorVersion: initialVersion, + } + + tracker.logDebug("added track", "namespace", update.name.Namespace, "name", update.name.Name, "initialVersion", initialVersion, "forceHandler", update.forceHandler) + return nil +} diff --git a/internal/controller/common/sensorversion/tracker_test.go b/internal/controller/common/sensorversion/tracker_test.go new file mode 100644 index 00000000..fe3d0976 --- /dev/null +++ b/internal/controller/common/sensorversion/tracker_test.go @@ -0,0 +1,176 @@ +package sensorversion + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/types" +) + +const noPollingInterval = 0 + +func TestTracker_WhenGettingSensorVersionFails_TrackChangesFailsWithSameError(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + handler := func(_ context.Context, _ types.NamespacedName) error { + require.Fail(t, "handler unexpectedly called") + return nil + } + + expectedError := errors.New("some error") + alwaysFails := func(_ context.Context) (string, error) { + return "", expectedError + } + + tracker := NewTracker(ctx, noPollingInterval) + + done := make(chan any) + go func() { + defer close(done) + + actualError := tracker.TrackChanges() + assert.Equal(t, expectedError, actualError, "wrong error returned from TrackChanges()") + }() + + name := types.NamespacedName{ + Namespace: "someNamespace", + Name: "someName", + } + tracker.Track(name, alwaysFails, handler, false) + + select { + case <-done: + return + + case <-time.After(time.Second): + require.Fail(t, "TrackChanges() never returned") + } +} + +func TestTracker_WhenHandlerFails_TrackChangesFailsWithSameError(t *testing.T) { + expectedContext, cancel := context.WithCancel(context.Background()) + defer cancel() + + getSensorVersion := newIncrementingSensorVersionGenerator(t, expectedContext) + + expectedName := types.NamespacedName{ + Namespace: "someNamespace", + Name: "someName", + } + + expectedError := errors.New("some error") + handler := func(actualContext context.Context, actualName types.NamespacedName) error { + assert.Same(t, expectedContext, actualContext, "wrong context passed to handler") + assert.Equal(t, expectedName, actualName, "wrong name passed to handler") + return expectedError + } + + tracker := NewTracker(expectedContext, noPollingInterval) + + done := make(chan any) + go func() { + defer close(done) + + actualError := tracker.TrackChanges() + assert.Equal(t, expectedError, actualError, "wrong error returned from TrackChanges()") + }() + + tracker.Track(expectedName, getSensorVersion, handler, false) + + select { + case <-done: + return + + case <-time.After(time.Second): + require.Fail(t, "TrackChanges() never returned") + } +} + +func TestTracker_WhenSensorVersionChanges_CallsHandler(t *testing.T) { + runHandlerTest(t, func(ctx context.Context, tracker Tracker, name types.NamespacedName, handler Handler) { + getSensorVersion := newIncrementingSensorVersionGenerator(t, ctx) + tracker.Track(name, getSensorVersion, handler, false) + }) +} + +func TestTracker_WhenSensorVersionDoesNotChangeButIsForced_CallsHandler(t *testing.T) { + runHandlerTest(t, func(ctx context.Context, tracker Tracker, name types.NamespacedName, handler Handler) { + getSensorVersion := newConstantSensorVersionGenerator(t, ctx) + tracker.Track(name, getSensorVersion, handler, true) + }) +} + +func TestTracker_WhenTrackUpdatedWithForcedHandler_CallsHandler(t *testing.T) { + runHandlerTest(t, func(ctx context.Context, tracker Tracker, name types.NamespacedName, handler Handler) { + getSensorVersion := newConstantSensorVersionGenerator(t, ctx) + tracker.Track(name, getSensorVersion, handler, false) + tracker.Track(name, getSensorVersion, handler, true) + }) +} + +func newConstantSensorVersionGenerator(t *testing.T, expectedContext context.Context) SensorVersionQuery { + const fixedVersion = "v1.1.1" + + return func(actualContext context.Context) (string, error) { + assert.Same(t, expectedContext, actualContext, "wrong context passed to getSensorVersion()") + return fixedVersion, nil + } // +} + +func newIncrementingSensorVersionGenerator(t *testing.T, expectedContext context.Context) SensorVersionQuery { + lastVersion := 0 + + return func(actualContext context.Context) (string, error) { + assert.Same(t, expectedContext, actualContext, "wrong context passed to getSensorVersion()") + + lastVersion++ + return fmt.Sprintf("v%d.%d.%d", lastVersion, lastVersion, lastVersion), nil + } +} + +func runHandlerTest(t *testing.T, runner func(ctx context.Context, tracker Tracker, name types.NamespacedName, handler Handler)) { + expectedContext, cancel := context.WithCancel(context.Background()) + defer cancel() + + expectedName := types.NamespacedName{ + Namespace: "someNamespace", + Name: "someName", + } + + done := make(chan any) + channelOpen := true + handler := func(actualContext context.Context, actualName types.NamespacedName) error { + assert.Same(t, expectedContext, actualContext, "wrong context passed to handler") + assert.Equal(t, expectedName, actualName, "wrong name passed to handler") + + if channelOpen { + close(done) + channelOpen = false + } + + return nil + } + + tracker := NewTracker(expectedContext, noPollingInterval) + + go func() { + err := tracker.TrackChanges() + require.NoError(t, err, "TrackChanges() unexpectedly failed") + }() + + runner(expectedContext, tracker, expectedName, handler) + + select { + case <-time.After(time.Second): + require.Fail(t, "handler never called") + + case <-done: + break + } +} diff --git a/internal/controller/common/utils.go b/internal/controller/common/utils.go index 29f189d8..e0e1d072 100644 --- a/internal/controller/common/utils.go +++ b/internal/controller/common/utils.go @@ -18,6 +18,10 @@ import ( "k8s.io/client-go/util/retry" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/source" ) var ErrNoWebhookServicePodReady = errors.New("no webhook service pod found in a Ready state") @@ -290,6 +294,21 @@ func GetOpenShiftNamespaceNamesSort(ctx context.Context, cli client.Client) ([]s return nsList, nil } +func NewReconcileTrigger(c controller.Controller) (func(client.Object), error) { + channel := make(chan event.GenericEvent) + err := c.Watch( + &source.Channel{Source: channel}, + &handler.EnqueueRequestForObject{}, + ) + if err != nil { + return nil, err + } + + return func(obj client.Object) { + channel <- event.GenericEvent{Object: obj} + }, nil +} + func oLogMessage(kind, obj string) string { return fmt.Sprintf("%s.%s", kind, obj) } diff --git a/internal/controller/falcon_container/falconcontainer_controller.go b/internal/controller/falcon_container/falconcontainer_controller.go index 2f73c16d..6cc4db9b 100644 --- a/internal/controller/falcon_container/falconcontainer_controller.go +++ b/internal/controller/falcon_container/falconcontainer_controller.go @@ -8,9 +8,11 @@ import ( falconv1alpha1 "github.com/crowdstrike/falcon-operator/api/falcon/v1alpha1" k8sutils "github.com/crowdstrike/falcon-operator/internal/controller/common" + "github.com/crowdstrike/falcon-operator/internal/controller/common/sensorversion" "github.com/crowdstrike/falcon-operator/pkg/aws" "github.com/crowdstrike/falcon-operator/pkg/common" "github.com/crowdstrike/falcon-operator/version" + "github.com/crowdstrike/gofalcon/falcon" "github.com/go-logr/logr" arv1 "k8s.io/api/admissionregistration/v1" appsv1 "k8s.io/api/apps/v1" @@ -20,6 +22,7 @@ import ( "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/rest" "k8s.io/client-go/util/retry" ctrl "sigs.k8s.io/controller-runtime" @@ -30,14 +33,16 @@ import ( // FalconContainerReconciler reconciles a FalconContainer object type FalconContainerReconciler struct { client.Client - Log logr.Logger - Scheme *runtime.Scheme - RestConfig *rest.Config + Log logr.Logger + Scheme *runtime.Scheme + RestConfig *rest.Config + reconcileObject func(client.Object) + tracker sensorversion.Tracker } // SetupWithManager sets up the controller with the Manager. -func (r *FalconContainerReconciler) SetupWithManager(mgr ctrl.Manager) error { - return ctrl.NewControllerManagedBy(mgr). +func (r *FalconContainerReconciler) SetupWithManager(mgr ctrl.Manager, tracker sensorversion.Tracker) error { + containerController, err := ctrl.NewControllerManagedBy(mgr). For(&falconv1alpha1.FalconContainer{}). Owns(&appsv1.Deployment{}). Owns(&corev1.Namespace{}). @@ -47,7 +52,18 @@ func (r *FalconContainerReconciler) SetupWithManager(mgr ctrl.Manager) error { Owns(&corev1.ServiceAccount{}). Owns(&rbacv1.ClusterRoleBinding{}). Owns(&arv1.MutatingWebhookConfiguration{}). - Complete(r) + Build(r) + if err != nil { + return err + } + + r.reconcileObject, err = k8sutils.NewReconcileTrigger(containerController) + if err != nil { + return err + } + + r.tracker = tracker + return nil } //+kubebuilder:rbac:groups=falcon.crowdstrike.com,resources=falconcontainers,verbs=get;list;watch;create;update;patch;delete @@ -77,6 +93,8 @@ func (r *FalconContainerReconciler) Reconcile(ctx context.Context, req ctrl.Requ if err := r.Get(ctx, req.NamespacedName, falconContainer); err != nil { if errors.IsNotFound(err) { + r.tracker.StopTracking(req.NamespacedName) + // Request object not found, could have been deleted after reconcile request. // Owned objects are automatically garbage collected. For additional cleanup logic use finalizers. // Return and don't requeue @@ -134,6 +152,13 @@ func (r *FalconContainerReconciler) Reconcile(ctx context.Context, req ctrl.Requ return ctrl.Result{}, fmt.Errorf("failed to reconcile namespace: %v", err) } + if shouldTrackSensorVersions(falconContainer) { + getSensorVersion := sensorversion.NewFalconCloudQuery(falcon.SidecarSensor, r.falconApiConfig(ctx, falconContainer)) + r.tracker.Track(req.NamespacedName, getSensorVersion, r.reconcileObjectWithName, falconContainer.Spec.Unsafe.IsAutoUpdatingForced()) + } else { + r.tracker.StopTracking(req.NamespacedName) + } + // Image being set will override other image based settings if falconContainer.Spec.Image != nil && *falconContainer.Spec.Image != "" { if _, err := r.setImageTag(ctx, falconContainer); err != nil { @@ -293,11 +318,7 @@ func (r *FalconContainerReconciler) Reconcile(ctx context.Context, req ctrl.Requ metav1.ConditionTrue, falconv1alpha1.ReasonInstallSucceeded, "FalconContainer installation completed") - if err != nil { - return ctrl.Result{}, err - } - - return ctrl.Result{}, nil + return ctrl.Result{}, err } func (r *FalconContainerReconciler) StatusUpdate(ctx context.Context, req ctrl.Request, log logr.Logger, falconContainer *falconv1alpha1.FalconContainer, condType string, status metav1.ConditionStatus, reason string, message string) error { @@ -324,3 +345,19 @@ func (r *FalconContainerReconciler) StatusUpdate(ctx context.Context, req ctrl.R return nil } + +func (r *FalconContainerReconciler) reconcileObjectWithName(ctx context.Context, name types.NamespacedName) error { + obj := &falconv1alpha1.FalconContainer{} + err := r.Get(ctx, name, obj) + if err != nil { + return err + } + + log.FromContext(ctx).Info("reconciling FalconContainer object", "namespace", obj.Namespace, "name", obj.Name) + r.reconcileObject(obj) + return nil +} + +func shouldTrackSensorVersions(obj *falconv1alpha1.FalconContainer) bool { + return obj.Spec.FalconAPI != nil && obj.Spec.Unsafe.IsAutoUpdating() +} diff --git a/internal/controller/falcon_container/falconcontainer_controller_test.go b/internal/controller/falcon_container/falconcontainer_controller_test.go index 70344af2..7cd7e152 100644 --- a/internal/controller/falcon_container/falconcontainer_controller_test.go +++ b/internal/controller/falcon_container/falconcontainer_controller_test.go @@ -7,6 +7,7 @@ import ( falconv1alpha1 "github.com/crowdstrike/falcon-operator/api/falcon/v1alpha1" k8sutils "github.com/crowdstrike/falcon-operator/internal/controller/common" + "github.com/crowdstrike/falcon-operator/internal/controller/common/sensorversion" "github.com/crowdstrike/falcon-operator/pkg/common" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -84,9 +85,13 @@ var _ = Describe("FalconContainer controller", func() { }, time.Minute, time.Second).Should(Succeed()) By("Reconciling the custom resource created") + tracker, cancel := sensorversion.NewTestTracker() + defer cancel() + falconContainerReconciler := &FalconContainerReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), + Client: k8sClient, + Scheme: k8sClient.Scheme(), + tracker: tracker, } _, err = falconContainerReconciler.Reconcile(ctx, reconcile.Request{ diff --git a/internal/controller/falcon_container/image_push.go b/internal/controller/falcon_container/image_push.go index e7f62138..bf07a409 100644 --- a/internal/controller/falcon_container/image_push.go +++ b/internal/controller/falcon_container/image_push.go @@ -232,9 +232,12 @@ func (r *FalconContainerReconciler) imageNamespace(falconContainer *falconv1alph } func (r *FalconContainerReconciler) falconApiConfig(ctx context.Context, falconContainer *falconv1alpha1.FalconContainer) *falcon.ApiConfig { + if falconContainer.Spec.FalconAPI == nil { + return nil + } + cfg := falconContainer.Spec.FalconAPI.ApiConfig() cfg.Context = ctx - return cfg } @@ -243,5 +246,9 @@ func (r *FalconContainerReconciler) imageMirroringEnabled(falconContainer *falco } func (r *FalconContainerReconciler) versionLock(falconContainer *falconv1alpha1.FalconContainer) bool { - return (falconContainer.Spec.Version != nil && falconContainer.Status.Sensor != nil && strings.Contains(*falconContainer.Status.Sensor, *falconContainer.Spec.Version)) || (falconContainer.Spec.Version == nil && falconContainer.Status.Sensor != nil) + if falconContainer.Status.Sensor == nil || falconContainer.Spec.Unsafe.HasUpdatePolicy() || falconContainer.Spec.Unsafe.IsAutoUpdating() { + return false + } + + return falconContainer.Spec.Version == nil || strings.Contains(*falconContainer.Status.Sensor, *falconContainer.Spec.Version) } diff --git a/internal/controller/falcon_container/image_push_test.go b/internal/controller/falcon_container/image_push_test.go new file mode 100644 index 00000000..d1487971 --- /dev/null +++ b/internal/controller/falcon_container/image_push_test.go @@ -0,0 +1,81 @@ +package falcon + +import ( + "testing" + + falconv1alpha1 "github.com/crowdstrike/falcon-operator/api/falcon/v1alpha1" + "github.com/stretchr/testify/assert" +) + +func TestVersionLock_WithAutoUpdateDisabled(t *testing.T) { + reconciler := &FalconContainerReconciler{} + container := &falconv1alpha1.FalconContainer{} + container.Status.Sensor = stringPointer("some sensor") + container.Spec.Unsafe.AutoUpdate = stringPointer(falconv1alpha1.Off) + assert.True(t, reconciler.versionLock(container)) +} + +func TestVersionLock_WithForcedAutoUpdate(t *testing.T) { + reconciler := &FalconContainerReconciler{} + container := &falconv1alpha1.FalconContainer{} + container.Status.Sensor = stringPointer("some sensor") + container.Spec.Unsafe.AutoUpdate = stringPointer(falconv1alpha1.Force) + assert.False(t, reconciler.versionLock(container)) +} + +func TestVersionLock_WithNormalAutoUpdate(t *testing.T) { + reconciler := &FalconContainerReconciler{} + container := &falconv1alpha1.FalconContainer{} + container.Status.Sensor = stringPointer("some sensor") + container.Spec.Unsafe.AutoUpdate = stringPointer(falconv1alpha1.Normal) + assert.False(t, reconciler.versionLock(container)) +} + +func TestVersionLock_WithBlankUpdatePolicy(t *testing.T) { + reconciler := &FalconContainerReconciler{} + container := &falconv1alpha1.FalconContainer{} + container.Status.Sensor = stringPointer("some sensor") + container.Spec.Unsafe.UpdatePolicy = stringPointer("") + assert.True(t, reconciler.versionLock(container)) +} + +func TestVersionLock_WithDifferentVersion(t *testing.T) { + reconciler := &FalconContainerReconciler{} + container := &falconv1alpha1.FalconContainer{} + container.Status.Sensor = stringPointer("some sensor") + container.Spec.Version = stringPointer("different version") + assert.False(t, reconciler.versionLock(container)) +} + +func TestVersionLock_WithLatestVersion(t *testing.T) { + reconciler := &FalconContainerReconciler{} + container := &falconv1alpha1.FalconContainer{} + container.Status.Sensor = stringPointer("some sensor") + assert.True(t, reconciler.versionLock(container)) +} + +func TestVersionLock_WithNoCurrentSensor(t *testing.T) { + reconciler := &FalconContainerReconciler{} + container := &falconv1alpha1.FalconContainer{} + assert.False(t, reconciler.versionLock(container)) +} + +func TestVersionLock_WithSameVersion(t *testing.T) { + reconciler := &FalconContainerReconciler{} + container := &falconv1alpha1.FalconContainer{} + container.Status.Sensor = stringPointer("some sensor") + container.Spec.Version = container.Status.Sensor + assert.True(t, reconciler.versionLock(container)) +} + +func TestVersionLock_WithUpdatePolicy(t *testing.T) { + reconciler := &FalconContainerReconciler{} + container := &falconv1alpha1.FalconContainer{} + container.Status.Sensor = stringPointer("some sensor") + container.Spec.Unsafe.UpdatePolicy = stringPointer("some policy") + assert.False(t, reconciler.versionLock(container)) +} + +func stringPointer(s string) *string { + return &s +} diff --git a/internal/controller/falcon_node/falconnodesensor_controller.go b/internal/controller/falcon_node/falconnodesensor_controller.go index 6effd25c..5fb0fefd 100644 --- a/internal/controller/falcon_node/falconnodesensor_controller.go +++ b/internal/controller/falcon_node/falconnodesensor_controller.go @@ -7,10 +7,12 @@ import ( falconv1alpha1 "github.com/crowdstrike/falcon-operator/api/falcon/v1alpha1" "github.com/crowdstrike/falcon-operator/internal/controller/assets" k8sutils "github.com/crowdstrike/falcon-operator/internal/controller/common" + "github.com/crowdstrike/falcon-operator/internal/controller/common/sensorversion" "github.com/crowdstrike/falcon-operator/pkg/common" "github.com/crowdstrike/falcon-operator/pkg/k8s_utils" "github.com/crowdstrike/falcon-operator/pkg/node" "github.com/crowdstrike/falcon-operator/version" + "github.com/crowdstrike/gofalcon/falcon" "github.com/go-logr/logr" "github.com/operator-framework/operator-lib/proxy" appsv1 "k8s.io/api/apps/v1" @@ -34,18 +36,31 @@ import ( // FalconNodeSensorReconciler reconciles a FalconNodeSensor object type FalconNodeSensorReconciler struct { client.Client - Log logr.Logger - Scheme *runtime.Scheme + Log logr.Logger + Scheme *runtime.Scheme + reconcileObject func(client.Object) + tracker sensorversion.Tracker } // SetupWithManager sets up the controller with the Manager. -func (r *FalconNodeSensorReconciler) SetupWithManager(mgr ctrl.Manager) error { - return ctrl.NewControllerManagedBy(mgr). +func (r *FalconNodeSensorReconciler) SetupWithManager(mgr ctrl.Manager, tracker sensorversion.Tracker) error { + nodeSensorController, err := ctrl.NewControllerManagedBy(mgr). For(&falconv1alpha1.FalconNodeSensor{}). Owns(&corev1.ConfigMap{}). Owns(&appsv1.DaemonSet{}). Owns(&corev1.Secret{}). - Complete(r) + Build(r) + if err != nil { + return err + } + + r.reconcileObject, err = k8sutils.NewReconcileTrigger(nodeSensorController) + if err != nil { + return err + } + + r.tracker = tracker + return nil } // +kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch;delete;deletecollection @@ -78,6 +93,8 @@ func (r *FalconNodeSensorReconciler) Reconcile(ctx context.Context, req ctrl.Req err := r.Get(ctx, req.NamespacedName, nodesensor) if err != nil { if errors.IsNotFound(err) { + r.tracker.StopTracking(req.NamespacedName) + // Request object not found, could have been deleted after reconcile request. // Owned objects are automatically garbage collected. For additional cleanup logic use finalizers. // Return and don't requeue @@ -169,6 +186,13 @@ func (r *FalconNodeSensorReconciler) Reconcile(ctx context.Context, req ctrl.Req return ctrl.Result{}, err } + if shouldTrackSensorVersions(nodesensor) { + getSensorVersion := sensorversion.NewFalconCloudQuery(falcon.NodeSensor, nodesensor.Spec.FalconAPI.ApiConfig()) + r.tracker.Track(req.NamespacedName, getSensorVersion, r.reconcileObjectWithName, nodesensor.Spec.Node.Unsafe.IsAutoUpdatingForced()) + } else { + r.tracker.StopTracking(req.NamespacedName) + } + sensorConf, updated, err := r.handleConfigMaps(ctx, config, nodesensor, logger) if err != nil { err = r.conditionsUpdate(falconv1alpha1.ConditionFailed, @@ -1016,3 +1040,19 @@ func (r *FalconNodeSensorReconciler) finalizeDaemonset(ctx context.Context, imag logger.Info("Successfully finalized daemonset") return nil } + +func (r *FalconNodeSensorReconciler) reconcileObjectWithName(ctx context.Context, name types.NamespacedName) error { + obj := &falconv1alpha1.FalconNodeSensor{} + err := r.Get(ctx, name, obj) + if err != nil { + return err + } + + clog.FromContext(ctx).Info("reconciling FalconNodeSensor object", "namespace", obj.Namespace, "name", obj.Name) + r.reconcileObject(obj) + return nil +} + +func shouldTrackSensorVersions(obj *falconv1alpha1.FalconNodeSensor) bool { + return obj.Spec.FalconAPI != nil && obj.Spec.Node.Unsafe.IsAutoUpdating() +} diff --git a/internal/controller/falcon_node/falconnodesensor_controller_test.go b/internal/controller/falcon_node/falconnodesensor_controller_test.go index 9685469c..5f5c062d 100644 --- a/internal/controller/falcon_node/falconnodesensor_controller_test.go +++ b/internal/controller/falcon_node/falconnodesensor_controller_test.go @@ -6,6 +6,7 @@ import ( "time" falconv1alpha1 "github.com/crowdstrike/falcon-operator/api/falcon/v1alpha1" + "github.com/crowdstrike/falcon-operator/internal/controller/common/sensorversion" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" appsv1 "k8s.io/api/apps/v1" @@ -80,9 +81,13 @@ var _ = Describe("FalconNodeSensor controller", func() { }, time.Minute, time.Second).Should(Succeed()) By("Reconciling the custom resource created") + tracker, cancel := sensorversion.NewTestTracker() + defer cancel() + falconNodeReconciler := &FalconNodeSensorReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), + Client: k8sClient, + Scheme: k8sClient.Scheme(), + tracker: tracker, } _, err = falconNodeReconciler.Reconcile(ctx, reconcile.Request{ diff --git a/pkg/node/config_cache.go b/pkg/node/config_cache.go index b90129df..17be4c17 100644 --- a/pkg/node/config_cache.go +++ b/pkg/node/config_cache.go @@ -130,7 +130,11 @@ func getFalconImage(ctx context.Context, nodesensor *falconv1alpha1.FalconNodeSe } func versionLock(nodesensor *falconv1alpha1.FalconNodeSensor) bool { - return (nodesensor.Spec.Node.Version != nil && nodesensor.Status.Sensor != nil && strings.Contains(*nodesensor.Status.Sensor, *nodesensor.Spec.Node.Version)) || (nodesensor.Spec.Node.Version == nil && nodesensor.Status.Sensor != nil) + if nodesensor.Status.Sensor == nil || nodesensor.Spec.Node.Unsafe.HasUpdatePolicy() || nodesensor.Spec.Node.Unsafe.IsAutoUpdating() { + return false + } + + return nodesensor.Spec.Node.Version == nil || strings.Contains(*nodesensor.Status.Sensor, *nodesensor.Spec.Node.Version) } func ConfigCacheTest(cid string, imageUri string, nodeTest *falconv1alpha1.FalconNodeSensor) *ConfigCache { diff --git a/pkg/node/config_cache_test.go b/pkg/node/config_cache_test.go index 498381f3..90f23e62 100644 --- a/pkg/node/config_cache_test.go +++ b/pkg/node/config_cache_test.go @@ -10,6 +10,7 @@ import ( falconv1alpha1 "github.com/crowdstrike/falcon-operator/api/falcon/v1alpha1" "github.com/go-logr/logr" "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" ) var falconNode = falconv1alpha1.FalconNodeSensor{} @@ -199,6 +200,66 @@ func TestGetFalconImage(t *testing.T) { } } +func TestVersionLock_WithAutoUpdateDisabled(t *testing.T) { + admission := &falconv1alpha1.FalconNodeSensor{} + admission.Status.Sensor = stringPointer("some sensor") + admission.Spec.Node.Unsafe.AutoUpdate = stringPointer(falconv1alpha1.Off) + assert.True(t, versionLock(admission)) +} + +func TestVersionLock_WithForcedAutoUpdate(t *testing.T) { + admission := &falconv1alpha1.FalconNodeSensor{} + admission.Status.Sensor = stringPointer("some sensor") + admission.Spec.Node.Unsafe.AutoUpdate = stringPointer(falconv1alpha1.Force) + assert.False(t, versionLock(admission)) +} + +func TestVersionLock_WithNormalAutoUpdate(t *testing.T) { + admission := &falconv1alpha1.FalconNodeSensor{} + admission.Status.Sensor = stringPointer("some sensor") + admission.Spec.Node.Unsafe.AutoUpdate = stringPointer(falconv1alpha1.Normal) + assert.False(t, versionLock(admission)) +} + +func TestVersionLock_WithBlankUpdatePolicy(t *testing.T) { + sensor := &falconv1alpha1.FalconNodeSensor{} + sensor.Status.Sensor = stringPointer("some sensor") + sensor.Spec.Node.Unsafe.UpdatePolicy = stringPointer("") + assert.True(t, versionLock(sensor)) +} + +func TestVersionLock_WithDifferentVersion(t *testing.T) { + sensor := &falconv1alpha1.FalconNodeSensor{} + sensor.Status.Sensor = stringPointer("some sensor") + sensor.Spec.Node.Version = stringPointer("different version") + assert.False(t, versionLock(sensor)) +} + +func TestVersionLock_WithLatestVersion(t *testing.T) { + sensor := &falconv1alpha1.FalconNodeSensor{} + sensor.Status.Sensor = stringPointer("some sensor") + assert.True(t, versionLock(sensor)) +} + +func TestVersionLock_WithNoCurrentSensor(t *testing.T) { + sensor := &falconv1alpha1.FalconNodeSensor{} + assert.False(t, versionLock(sensor)) +} + +func TestVersionLock_WithSameVersion(t *testing.T) { + sensor := &falconv1alpha1.FalconNodeSensor{} + sensor.Status.Sensor = stringPointer("some sensor") + sensor.Spec.Node.Version = sensor.Status.Sensor + assert.True(t, versionLock(sensor)) +} + +func TestVersionLock_WithUpdatePolicy(t *testing.T) { + sensor := &falconv1alpha1.FalconNodeSensor{} + sensor.Status.Sensor = stringPointer("some sensor") + sensor.Spec.Node.Unsafe.UpdatePolicy = stringPointer("some policy") + assert.False(t, versionLock(sensor)) +} + func newTestFalconAPI(cid *string) *falconv1alpha1.FalconAPI { return &falconv1alpha1.FalconAPI{ ClientId: "testID", @@ -208,3 +269,7 @@ func newTestFalconAPI(cid *string) *falconv1alpha1.FalconAPI { HostOverride: strings.TrimSpace(os.Getenv("FALCON_API_HOST")), } } + +func stringPointer(s string) *string { + return &s +}