Skip to content

Commit 5095ff7

Browse files
committed
feat(operator): implement basic fact operator
At this point the operator is a PoC, meant only as a small experiment to see if it is even possible to implement. TODO: * Expose more configuration options. * Implement some sort of test that will ensure the operator works and deploys fact correctly.
1 parent 26b1c8b commit 5095ff7

11 files changed

Lines changed: 1390 additions & 34 deletions

File tree

Cargo.lock

Lines changed: 948 additions & 31 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ members = [
44
"fact",
55
"fact-api",
66
"fact-ebpf",
7+
"fact-operator",
78
]
89
default-members = ["fact"]
910

Containerfile

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,11 @@ FROM builder AS build
4242
ARG FACT_VERSION
4343
RUN --mount=type=cache,target=/root/.cargo/registry \
4444
--mount=type=cache,target=/app/target \
45-
cargo build --release && \
46-
cp target/release/fact fact
45+
cargo build --release --all && \
46+
cp target/release/fact fact && \
47+
cp target/release/fact-operator fact-operator
4748

48-
FROM ubi-micro-base
49+
FROM ubi-micro-base AS fact
4950

5051
ARG FACT_VERSION
5152
LABEL name="fact" \
@@ -64,3 +65,23 @@ COPY LICENSE-APACHE LICENSE-MIT LICENSE-GPL2 /licenses/
6465
RUN update-crypto-policies --set DEFAULT:PQ
6566

6667
ENTRYPOINT ["fact"]
68+
69+
FROM ubi-micro-base AS fact-operator
70+
71+
ARG FACT_VERSION
72+
LABEL name="fact-operator" \
73+
vendor="StackRox" \
74+
maintainer="support@stackrox.com" \
75+
summary="File activity operator" \
76+
description="This image supports an operator for deploying the file activity daemonset" \
77+
io.stackrox.fact-operator.version="${FACT_VERSION}"
78+
79+
COPY --from=package_installer /out/ /
80+
81+
COPY --from=build /app/fact-operator /usr/local/bin
82+
83+
COPY LICENSE-APACHE LICENSE-MIT LICENSE-GPL2 /licenses/
84+
85+
RUN update-crypto-policies --set DEFAULT:PQ
86+
87+
ENTRYPOINT ["fact-operator"]

Makefile

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,21 @@ image:
1717
-f Containerfile \
1818
--build-arg FACT_VERSION=$(FACT_VERSION) \
1919
--build-arg RUST_VERSION=$(RUST_VERSION) \
20+
--target fact \
2021
-t $(FACT_IMAGE_NAME) \
2122
$(CURDIR)
2223

24+
operator:
25+
$(DOCKER) build \
26+
-f Containerfile \
27+
--build-arg FACT_VERSION=$(FACT_VERSION) \
28+
--build-arg RUST_VERSION=$(RUST_VERSION) \
29+
--target fact-operator \
30+
-t $(FACT_OPERATOR_NAME) \
31+
$(CURDIR)
32+
33+
images: image operator
34+
2335
licenses:THIRD_PARTY_LICENSES.html
2436

2537
THIRD_PARTY_LICENSES.html:Cargo.lock

constants.mk

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ RUST_VERSION ?= stable
33
FACT_TAG ?= $(shell git describe --always --tags --abbrev=10 --dirty)
44
FACT_VERSION ?= $(FACT_TAG)
55
FACT_REGISTRY ?= quay.io/stackrox-io/fact
6+
FACT_OPERATOR_REGISTRY ?= quay.io/stackrox-io/fact-operator
67
FACT_IMAGE_NAME ?= $(FACT_REGISTRY):$(FACT_TAG)
8+
FACT_OPERATOR_REGISTRY ?= quay.io/stackrox-io/fact-operator
9+
FACT_OPERATOR_NAME ?= $(FACT_OPERATOR_REGISTRY):$(FACT_TAG)
710

811
CLANG_FMT ?= $(shell which clang-format)
912

fact-operator/Cargo.toml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
[package]
2+
name = "fact-operator"
3+
version = "0.1.0"
4+
edition = "2024"
5+
license.workspace = true
6+
7+
[dependencies]
8+
anyhow = { workspace = true }
9+
env_logger = { workspace = true }
10+
futures = { version = "0.3.32", default-features = false }
11+
kube = { version = "4.0.0", default-features = true, features = ["client", "openssl-tls", "derive", "runtime"] }
12+
k8s-openapi = { version = "0.28.0", features = ["latest", "schemars"] }
13+
log = { workspace = true }
14+
schemars = { version = "1" }
15+
serde = { workspace = true }
16+
serde_json = { workspace = true }
17+
tokio = { workspace = true }

fact-operator/src/lib.rs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
use std::{sync::Arc, time::Duration};
2+
3+
use futures::StreamExt;
4+
use k8s_openapi::api::{apps::v1::DaemonSet, core::v1::ConfigMap};
5+
use kube::{
6+
Api, Client, ResourceExt,
7+
api::{Patch, PatchParams},
8+
runtime::{Controller, controller::Action, watcher},
9+
};
10+
use log::{info, warn};
11+
12+
use crate::spec::Fact;
13+
14+
mod spec;
15+
16+
struct Context {
17+
client: Client,
18+
}
19+
20+
async fn reconcile(fact: Arc<Fact>, ctx: Arc<Context>) -> Result<Action, kube::Error> {
21+
info!("Starting reconciliation loop");
22+
let ns = fact.namespace().unwrap();
23+
24+
let cm = spec::build_configmap(&fact);
25+
let api = Api::<ConfigMap>::namespaced(ctx.client.clone(), &ns);
26+
api.patch(
27+
&format!("{}-config", fact.name_any()),
28+
&PatchParams::apply("fact-operator"),
29+
&Patch::Apply(cm),
30+
)
31+
.await?;
32+
33+
let ds = spec::build_daemonset(&fact);
34+
let api = Api::<DaemonSet>::namespaced(ctx.client.clone(), &ns);
35+
api.patch(
36+
&fact.name_any(),
37+
&PatchParams::apply("fact-operator"),
38+
&Patch::Apply(ds),
39+
)
40+
.await?;
41+
42+
info!("Reconciliation done");
43+
Ok(Action::requeue(Duration::from_secs(300)))
44+
}
45+
46+
fn error_policy(_obj: Arc<Fact>, _err: &kube::Error, _ctx: Arc<Context>) -> Action {
47+
Action::requeue(Duration::from_secs(60))
48+
}
49+
50+
pub async fn run() -> anyhow::Result<()> {
51+
env_logger::init();
52+
info!("Operator starting...");
53+
let client = Client::try_default().await?;
54+
let fact = Api::<Fact>::all(client.clone());
55+
56+
Controller::new(fact, watcher::Config::default())
57+
.owns(
58+
Api::<DaemonSet>::all(client.clone()),
59+
watcher::Config::default(),
60+
)
61+
.owns(
62+
Api::<ConfigMap>::all(client.clone()),
63+
watcher::Config::default(),
64+
)
65+
.run(reconcile, error_policy, Arc::new(Context { client }))
66+
.for_each(|res| async move {
67+
match res {
68+
Ok(o) => info!("reconciled: {o:?}"),
69+
Err(e) => warn!("reconciler failed: {e:?}"),
70+
}
71+
})
72+
.await;
73+
74+
info!("Operator done");
75+
Ok(())
76+
}

fact-operator/src/main.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
#[tokio::main]
2+
async fn main() -> anyhow::Result<()> {
3+
fact_operator::run().await
4+
}

fact-operator/src/spec.rs

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
use std::collections::BTreeMap;
2+
3+
use k8s_openapi::{
4+
api::{
5+
apps::v1::{DaemonSet, DaemonSetSpec},
6+
core::v1::{
7+
Capabilities, ConfigMap, ConfigMapVolumeSource, Container, ContainerPort, EnvVar,
8+
HostPathVolumeSource, PodSpec, PodTemplateSpec, SecurityContext, Volume, VolumeMount,
9+
},
10+
},
11+
apimachinery::pkg::apis::meta::v1::LabelSelector,
12+
};
13+
use kube::{CustomResource, Resource, ResourceExt, api::ObjectMeta};
14+
use schemars::JsonSchema;
15+
use serde::{Deserialize, Serialize};
16+
17+
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, CustomResource)]
18+
#[serde(rename_all = "camelCase")]
19+
#[kube(
20+
group = "fact.stackrox.io",
21+
version = "v1alpha1",
22+
kind = "Fact",
23+
namespaced
24+
)]
25+
pub(crate) struct FactSpec {
26+
pub image: String,
27+
#[serde(default = "default_log_level")]
28+
pub log_level: String,
29+
#[serde(default = "default_rate_limit")]
30+
pub rate_limit: u64,
31+
}
32+
33+
fn default_log_level() -> String {
34+
String::from("info")
35+
}
36+
37+
fn default_rate_limit() -> u64 {
38+
0
39+
}
40+
41+
pub(crate) fn build_configmap(fact: &Fact) -> ConfigMap {
42+
let data = BTreeMap::from([(
43+
"fact.yml".into(),
44+
format!("rate_limit: {}", fact.spec.rate_limit),
45+
)]);
46+
ConfigMap {
47+
data: Some(data),
48+
metadata: ObjectMeta {
49+
name: Some(format!("{}-config", fact.name_any())),
50+
namespace: fact.namespace(),
51+
owner_references: fact.controller_owner_ref(&()).map(|r| vec![r]),
52+
..Default::default()
53+
},
54+
..Default::default()
55+
}
56+
}
57+
58+
pub(crate) fn build_daemonset(fact: &Fact) -> DaemonSet {
59+
let spec = &fact.spec;
60+
let name = fact.name_any();
61+
let namespace = fact.namespace();
62+
let labels = BTreeMap::from([("app".into(), "fact".into())]);
63+
64+
let metadata = ObjectMeta {
65+
labels: Some(labels.clone()),
66+
name: Some(name),
67+
namespace,
68+
owner_references: fact.controller_owner_ref(&()).map(|r| vec![r]),
69+
..Default::default()
70+
};
71+
72+
let container = Container {
73+
name: "fact".into(),
74+
image: Some(spec.image.clone()),
75+
image_pull_policy: Some("IfNotPresent".into()),
76+
args: Some(vec![
77+
"--paths".into(),
78+
"/etc:/bin:/sbin:/usr/bin:/usr/sbin".into(),
79+
]),
80+
ports: Some(vec![ContainerPort {
81+
container_port: 9000,
82+
name: Some("monitoring".into()),
83+
..Default::default()
84+
}]),
85+
env: Some(vec![
86+
EnvVar {
87+
name: "FACT_LOGLEVEL".into(),
88+
value: Some(spec.log_level.clone()),
89+
..Default::default()
90+
},
91+
EnvVar {
92+
name: "FACT_HOST_MOUNT".into(),
93+
value: Some("/host".into()),
94+
..Default::default()
95+
},
96+
]),
97+
security_context: Some(SecurityContext {
98+
capabilities: Some(Capabilities {
99+
drop: Some(vec!["NET_RAW".into()]),
100+
..Default::default()
101+
}),
102+
privileged: Some(true),
103+
read_only_root_filesystem: Some(true),
104+
..Default::default()
105+
}),
106+
volume_mounts: Some(vec![
107+
VolumeMount {
108+
name: "root-ro".into(),
109+
mount_path: "/host".into(),
110+
read_only: Some(true),
111+
mount_propagation: Some("HostToContainer".into()),
112+
..Default::default()
113+
},
114+
VolumeMount {
115+
mount_path: "/etc/stackrox".into(),
116+
name: "fact-config".into(),
117+
read_only: Some(true),
118+
..Default::default()
119+
},
120+
]),
121+
..Default::default()
122+
};
123+
124+
let volumes = vec![
125+
Volume {
126+
host_path: Some(HostPathVolumeSource {
127+
path: "/".into(),
128+
..Default::default()
129+
}),
130+
name: "root-ro".into(),
131+
..Default::default()
132+
},
133+
Volume {
134+
name: "fact-config".into(),
135+
config_map: Some(ConfigMapVolumeSource {
136+
name: format!("{}-config", fact.name_any()),
137+
..Default::default()
138+
}),
139+
..Default::default()
140+
},
141+
];
142+
143+
let spec = DaemonSetSpec {
144+
selector: LabelSelector {
145+
match_labels: Some(labels.clone()),
146+
..Default::default()
147+
},
148+
template: PodTemplateSpec {
149+
metadata: Some(ObjectMeta {
150+
labels: Some(labels),
151+
..Default::default()
152+
}),
153+
spec: Some(PodSpec {
154+
containers: vec![container],
155+
volumes: Some(volumes),
156+
..Default::default()
157+
}),
158+
},
159+
..Default::default()
160+
};
161+
162+
DaemonSet {
163+
metadata,
164+
spec: Some(spec),
165+
..Default::default()
166+
}
167+
}

k8s/example-cr.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
apiVersion: fact.stackrox.io/v1alpha1
2+
kind: Fact
3+
metadata:
4+
name: fact
5+
namespace: default
6+
spec:
7+
image: quay.io/stackrox-io/fact:0.3.0
8+
logLevel: debug
9+
rateLimit: 60000

0 commit comments

Comments
 (0)