Skip to content

Commit 2adaad7

Browse files
authored
feat: support loadBalancerClass-scoped service handling (#200)
note: This patch is AI-generated, submitted by an external contributor
1 parent 1875a93 commit 2adaad7

File tree

7 files changed

+208
-27
lines changed

7 files changed

+208
-27
lines changed

Dockerfile

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
1-
FROM rust:latest AS build-env
2-
RUN apt update
3-
RUN apt install -y libssl-dev mold
1+
FROM docker.io/library/rust:1.89.0-slim-trixie AS build-env
2+
3+
RUN apt-get update
4+
RUN apt-get install -y --no-install-recommends build-essential pkg-config libssl-dev clang mold git
5+
46
WORKDIR /app
57
COPY . /app
8+
69
ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse
7-
ENV RUSTFLAGS="-C link-arg=-B/usr/bin/mold"
8-
# copy build artifact somewhere accessible so we can copy it in the next stage
9-
RUN --mount=type=cache,target=/root/.cargo \
10+
ENV RUSTFLAGS="-C link-arg=-fuse-ld=mold"
11+
12+
RUN --mount=type=cache,target=/usr/local/cargo/registry \
13+
--mount=type=cache,target=/usr/local/cargo/git \
1014
cargo build --release
1115

12-
FROM redhat/ubi9-micro:latest
13-
# RUN useradd -u 1001 chisel
14-
USER 1001
15-
COPY --from=build-env --chown=chisel /app/target/release/chisel-operator /usr/bin/chisel-operator
16+
FROM docker.io/library/debian:trixie-slim
17+
18+
RUN groupadd -r chisel && useradd -r -g chisel -d /chisel -s /usr/sbin/nologin chisel
19+
USER chisel
20+
21+
COPY --from=build-env --chown=chisel:chisel /app/target/release/chisel-operator /usr/bin/chisel-operator
1622
CMD ["chisel-operator"]

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,22 @@ helm install chisel-operator oci://ghcr.io/fyralabs/chisel-operator/chisel-opera
124124

125125
## Usage
126126

127+
### Restricting reconciliation to a LoadBalancer class
128+
129+
By default the operator watches every Kubernetes service of type `LoadBalancer`. If you only want it to manage services that target a specific [`loadBalancerClass`](https://kubernetes.io/docs/concepts/services-networking/service/#load-balancer-class), set the `LOAD_BALANCER_CLASS` environment variable (or the `loadBalancerClass` Helm value) to the class name you want the operator to handle. Only services whose `spec.loadBalancerClass` matches that string will be reconciled; other services will be ignored.
130+
131+
When deploying a filtered operator you must also add the same `loadBalancerClass` to each Service you expect it to control:
132+
133+
```yaml
134+
apiVersion: v1
135+
kind: Service
136+
metadata:
137+
name: whoami
138+
spec:
139+
type: LoadBalancer
140+
loadBalancerClass: my.chisel.class
141+
```
142+
127143
### Operator-managed exit nodes
128144
129145
This operator can automatically provision exit nodes on cloud providers.

charts/chisel-operator/templates/deployment.yaml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ spec:
3030
- name: {{ .Chart.Name }}
3131
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
3232
imagePullPolicy: {{ .Values.image.pullPolicy }}
33+
{{- with .Values.loadBalancerClass }}
34+
env:
35+
- name: LOAD_BALANCER_CLASS
36+
value: {{ . | quote }}
37+
{{- end }}
3338
resources:
3439
{{- toYaml .Values.resources | nindent 12 }}
3540

@@ -45,4 +50,3 @@ spec:
4550
tolerations:
4651
{{- toYaml . | nindent 8 }}
4752
{{- end }}
48-

charts/chisel-operator/values.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,5 +47,9 @@ tolerations: []
4747

4848
affinity: {}
4949

50+
# Optional load balancer class handled by the operator. When empty, all LoadBalancer
51+
# services are reconciled, matching the default behavior.
52+
loadBalancerClass: ""
53+
5054
# Create CRDs for Chisel Operator
5155
createCrds: true

site/src/content/docs/guides/exposing-a-service.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,30 @@ spec:
2626
As you can see, the type of this service is `LoadBalancer`, which is required for chisel-operator to pick up on the service.
2727
Note that Chisel Operator acts on all LoadBalancer services in the cluster by default.
2828

29+
## Limiting reconciliation to a LoadBalancer class
30+
31+
If you only want the operator to manage services that opt in to a specific load balancer class, you can set the `LOAD_BALANCER_CLASS` environment variable (or the `loadBalancerClass` Helm value) when deploying it.
32+
Once the operator is running with that filter, only services whose `spec.loadBalancerClass` matches the configured class name will be reconciled; other services are ignored.
33+
34+
To opt a service in, add the same `loadBalancerClass` to the Service spec:
35+
36+
```yaml
37+
apiVersion: v1
38+
kind: Service
39+
metadata:
40+
name: whoami
41+
spec:
42+
type: LoadBalancer
43+
loadBalancerClass: my.chisel.class
44+
selector:
45+
app: whoami
46+
ports:
47+
- port: 80
48+
targetPort: 80
49+
```
50+
51+
Leaving the value empty (or omitting the field) continues to expose the service through any unfiltered operator instance.
52+
2953
Additionally, there's also a commented out annotation, `chisel-operator.io/exit-node-name`.
3054
By default, Chisel Operator will automatically select a random, unused `ExitNode` on the cluster if a cloud provisioner or exit node annotation is not set.
3155
If you'd like to force the service to a particular exit node, you can uncomment out the annotation, setting it to the name of the `ExitNode` to target.

site/src/content/docs/guides/installation.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,12 @@ You can configure the helm chart values by creating a `values.yaml` file and pas
3232
helm install chisel-operator oci://ghcr.io/fyralabs/chisel-operator/chisel-operator -f values.yaml
3333
```
3434

35+
For example, to limit reconciliation to services that declare a custom load balancer class, set the `loadBalancerClass` value to the class name you want the operator to handle:
36+
37+
```yaml
38+
loadBalancerClass: my.chisel.class
39+
```
40+
41+
Make sure every Service you expect the operator to manage sets the same value in `spec.loadBalancerClass`.
42+
3543
See [the Helm chart directory](https://github.com/FyraLabs/chisel-operator/tree/main/charts/chisel-operator) for more information on the Helm chart.

src/daemon.rs

Lines changed: 135 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ use color_eyre::Result;
2626
use futures::{FutureExt, StreamExt};
2727
use k8s_openapi::api::{
2828
apps::v1::Deployment,
29-
core::v1::{LoadBalancerIngress, LoadBalancerStatus, Secret, Service, ServiceStatus},
29+
core::v1::{
30+
LoadBalancerIngress, LoadBalancerStatus, Secret, Service, ServiceSpec, ServiceStatus,
31+
},
3032
};
3133
use kube::{
3234
api::{Api, ListParams, Patch, PatchParams, ResourceExt},
@@ -41,7 +43,7 @@ use kube::{
4143
},
4244
Client, Resource,
4345
};
44-
use std::{collections::BTreeMap, sync::Arc};
46+
use std::{collections::BTreeMap, env, sync::Arc};
4547

4648
use std::time::Duration;
4749
use tracing::{debug, error, info, instrument, trace, warn};
@@ -71,12 +73,48 @@ pub const SVCS_FINALIZER: &str = "service.chisel-operator.io/finalizer";
7173
// .trace_id()
7274
// }
7375

76+
#[derive(Clone, Debug, Default)]
77+
pub struct OperatorConfig {
78+
pub load_balancer_class: Option<String>,
79+
}
80+
81+
impl OperatorConfig {
82+
fn from_env() -> Self {
83+
let load_balancer_class = env::var("LOAD_BALANCER_CLASS").ok().and_then(|value| {
84+
let trimmed = value.trim();
85+
if trimmed.is_empty() {
86+
None
87+
} else {
88+
Some(trimmed.to_string())
89+
}
90+
});
91+
92+
Self {
93+
load_balancer_class,
94+
}
95+
}
96+
}
97+
98+
fn service_matches_operator_class(spec: &ServiceSpec, operator_config: &OperatorConfig) -> bool {
99+
let lb_class = spec.load_balancer_class.as_deref();
100+
101+
if let Some(expected_class) = operator_config.load_balancer_class.as_deref() {
102+
return lb_class == Some(expected_class);
103+
}
104+
105+
match lb_class {
106+
None => true,
107+
Some(class_name) => class_name == OPERATOR_CLASS,
108+
}
109+
}
110+
74111
// this is actually used to pass clients around
75112
pub struct Context {
76113
pub client: Client,
77114
// Let's implement a lock here to prevent multiple reconciles assigning the same exit node
78115
// to multiple services implicitly (#143)
79116
pub exit_node_lock: Arc<tokio::sync::Mutex<Option<(std::time::Instant, String)>>>,
117+
pub operator_config: OperatorConfig,
80118
}
81119

82120
/// Parses the `query` string to extract the namespace and name.
@@ -395,20 +433,32 @@ async fn reconcile_svcs(obj: Arc<Service>, ctx: Arc<Context>) -> Result<Action,
395433
// Return if service is not LoadBalancer or if the loadBalancerClass is not blank or set to $OPERATOR_CLASS
396434

397435
// todo: is there anything different need to be done for OpenShift? We use vanilla k8s and k3s/rke2 so we don't know
398-
if obj
399-
.spec
400-
.as_ref()
401-
.filter(|spec| spec.type_ == Some("LoadBalancer".to_string()))
402-
.is_none()
403-
|| obj
404-
.spec
405-
.as_ref()
406-
.filter(|spec| {
407-
spec.load_balancer_class.is_none()
408-
|| spec.load_balancer_class == Some(OPERATOR_CLASS.to_string())
409-
})
410-
.is_none()
411-
{
436+
let Some(spec) = obj.spec.as_ref() else {
437+
return Ok(Action::await_change());
438+
};
439+
440+
if spec.type_.as_deref() != Some("LoadBalancer") {
441+
return Ok(Action::await_change());
442+
}
443+
444+
let operator_config = &ctx.operator_config;
445+
if !service_matches_operator_class(spec, operator_config) {
446+
let lb_class = spec.load_balancer_class.as_deref();
447+
if let Some(expected_class) = operator_config.load_balancer_class.as_deref() {
448+
trace!(
449+
service = obj.name_any(),
450+
?lb_class,
451+
expected = expected_class,
452+
"Skipping service due to loadBalancerClass filter"
453+
);
454+
} else if let Some(class_name) = lb_class {
455+
trace!(
456+
service = obj.name_any(),
457+
loadBalancerClass = class_name,
458+
"Skipping service due to mismatched loadBalancerClass"
459+
);
460+
}
461+
412462
return Ok(Action::await_change());
413463
}
414464

@@ -812,6 +862,16 @@ pub async fn run() -> color_eyre::Result<()> {
812862
let mut reconcilers = vec![];
813863

814864
let lock = Arc::new(tokio::sync::Mutex::new(None));
865+
let operator_config = OperatorConfig::from_env();
866+
867+
if let Some(class_name) = operator_config.load_balancer_class.as_deref() {
868+
info!(
869+
loadBalancerClass = class_name,
870+
"Filtering LoadBalancer services by class"
871+
);
872+
} else {
873+
info!("No loadBalancerClass filter configured; reconciling services without restriction");
874+
}
815875

816876
info!("Starting reconcilers...");
817877

@@ -841,6 +901,7 @@ pub async fn run() -> color_eyre::Result<()> {
841901
Arc::new(Context {
842902
client: client.clone(),
843903
exit_node_lock: lock.clone(),
904+
operator_config: operator_config.clone(),
844905
}),
845906
)
846907
.for_each(|_| futures::future::ready(()))
@@ -869,6 +930,7 @@ pub async fn run() -> color_eyre::Result<()> {
869930
Arc::new(Context {
870931
client,
871932
exit_node_lock: lock,
933+
operator_config,
872934
}),
873935
)
874936
.for_each(|_| futures::future::ready(()))
@@ -879,3 +941,60 @@ pub async fn run() -> color_eyre::Result<()> {
879941

880942
Ok(())
881943
}
944+
945+
#[cfg(test)]
946+
mod tests {
947+
use super::*;
948+
949+
fn make_service_spec(class: Option<&str>) -> ServiceSpec {
950+
ServiceSpec {
951+
type_: Some("LoadBalancer".to_string()),
952+
load_balancer_class: class.map(|c| c.to_string()),
953+
..Default::default()
954+
}
955+
}
956+
957+
#[test]
958+
fn helper_accepts_unclassified_services_without_filter() {
959+
let spec = make_service_spec(None);
960+
let config = OperatorConfig::default();
961+
962+
assert!(service_matches_operator_class(&spec, &config));
963+
}
964+
965+
#[test]
966+
fn helper_accepts_operator_class_without_filter() {
967+
let spec = make_service_spec(Some(OPERATOR_CLASS));
968+
let config = OperatorConfig::default();
969+
970+
assert!(service_matches_operator_class(&spec, &config));
971+
}
972+
973+
#[test]
974+
fn helper_rejects_other_class_without_filter() {
975+
let spec = make_service_spec(Some("other.class"));
976+
let config = OperatorConfig::default();
977+
978+
assert!(!service_matches_operator_class(&spec, &config));
979+
}
980+
981+
#[test]
982+
fn helper_accepts_matching_explicit_filter() {
983+
let spec = make_service_spec(Some("custom.class"));
984+
let config = OperatorConfig {
985+
load_balancer_class: Some("custom.class".to_string()),
986+
};
987+
988+
assert!(service_matches_operator_class(&spec, &config));
989+
}
990+
991+
#[test]
992+
fn helper_rejects_when_filter_set_and_class_missing() {
993+
let spec = make_service_spec(None);
994+
let config = OperatorConfig {
995+
load_balancer_class: Some("custom.class".to_string()),
996+
};
997+
998+
assert!(!service_matches_operator_class(&spec, &config));
999+
}
1000+
}

0 commit comments

Comments
 (0)