# Soju Operator Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Build a multi-tenant Kubernetes operator that manages soju IRC bouncer instances on OpenShift via a `SojuBouncer` CRD. **Architecture:** Single CRD (`SojuBouncer`) reconciles into Deployment, ConfigMap, Service, Route, cert-manager Certificate, and per-tenant PostgreSQL database/role. Operator holds master DB credentials; tenants only see scoped credentials. Finalizer handles DB cleanup on CR deletion. **Tech Stack:** Rust 2024 edition, kube 3.0, k8s-openapi 0.27, k8s-crds-cert-manager, tokio-postgres, OpenShift Routes (hand-defined types) --- ### Task 1: Project scaffolding **Files:** - Create: `Cargo.toml` - Create: `src/main.rs` - Create: `src/lib.rs` - Create: `src/crdgen.rs` **Step 1: Initialize the Rust project** Create `Cargo.toml`: ```toml [package] name = "soju-operator" version = "0.1.0" edition = "2024" [[bin]] name = "soju-operator" path = "src/main.rs" [[bin]] name = "crdgen" path = "src/crdgen.rs" [dependencies] kube = { version = "3", features = ["runtime", "client", "derive"] } k8s-openapi = { version = "0.27", features = ["latest"] } k8s-crds-cert-manager = "1.19" schemars = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } tokio-postgres = "0.7" futures = "0.3" thiserror = "2" anyhow = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } [dev-dependencies] tower-test = "0.4" http = "1" hyper = "1" ``` Create `src/lib.rs`: ```rust pub mod types; pub mod controller; pub mod resources; pub mod db; pub use types::SojuBouncer; ``` Create `src/main.rs`: ```rust fn main() { println!("soju-operator"); } ``` Create `src/crdgen.rs`: ```rust fn main() { println!("crdgen placeholder"); } ``` **Step 2: Verify it compiles** Run: `cargo check` Expected: compiles (will fail because modules don't exist yet) Create empty module files so it compiles: - `src/types.rs` (empty) - `src/controller.rs` (empty) - `src/resources.rs` (empty) - `src/db.rs` (empty) Run: `cargo check` Expected: PASS **Step 3: Commit** ```bash git init git add Cargo.toml src/ git commit -m "scaffold: initialize soju-operator project Signed-off-by: Josephine Pfeiffer " ``` --- ### Task 2: CRD types **Files:** - Create: `src/types.rs` - Modify: `src/crdgen.rs` **Step 1: Write tests for CRD types** Add to `src/types.rs`: ```rust #[cfg(test)] mod tests { use super::*; use kube::CustomResourceExt; #[test] fn crd_generates_valid_schema() { let crd = SojuBouncer::crd(); let name = crd.metadata.name.unwrap(); assert_eq!(name, "sojubouncers.irc.josie.cloud"); } #[test] fn default_spec_has_sane_values() { let spec = SojuBouncerSpec::default(); assert!(spec.listeners.is_empty()); assert!(spec.tls.is_none()); assert!(spec.route.is_none()); assert!(spec.resources.is_none()); } } ``` **Step 2: Run tests to verify they fail** Run: `cargo test --lib types` Expected: FAIL -- `SojuBouncer` not defined **Step 3: Implement CRD types** ```rust use kube::CustomResource; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; #[derive(CustomResource, Serialize, Deserialize, Default, Debug, Clone, JsonSchema)] #[kube( group = "irc.josie.cloud", version = "v1alpha1", kind = "SojuBouncer", namespaced, status = "SojuBouncerStatus", shortname = "soju", printcolumn = r#"{"name":"Hostname", "type":"string", "jsonPath":".spec.hostname"}"#, printcolumn = r#"{"name":"Ready", "type":"string", "jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"# )] pub struct SojuBouncerSpec { pub hostname: String, #[serde(default)] pub title: Option, #[serde(default)] pub listeners: Vec, #[serde(default)] pub tls: Option, #[serde(default)] pub route: Option, #[serde(default)] pub resources: Option, } #[derive(Serialize, Deserialize, Default, Debug, Clone, JsonSchema)] pub struct Listener { pub address: String, #[serde(default)] pub tls: bool, } #[derive(Serialize, Deserialize, Default, Debug, Clone, JsonSchema)] pub struct TlsSpec { pub issuer_ref: IssuerRef, pub dns_names: Vec, } #[derive(Serialize, Deserialize, Default, Debug, Clone, JsonSchema)] pub struct IssuerRef { pub name: String, pub kind: String, } #[derive(Serialize, Deserialize, Default, Debug, Clone, JsonSchema)] pub struct RouteSpec { pub host: String, } #[derive(Serialize, Deserialize, Default, Debug, Clone, JsonSchema)] pub struct ResourceRequirements { #[serde(default)] pub requests: Option, #[serde(default)] pub limits: Option, } #[derive(Serialize, Deserialize, Default, Debug, Clone, JsonSchema)] pub struct ResourceList { #[serde(default)] pub memory: Option, #[serde(default)] pub cpu: Option, } #[derive(Serialize, Deserialize, Default, Debug, Clone, JsonSchema)] pub struct SojuBouncerStatus { #[serde(default)] pub conditions: Vec, #[serde(default)] pub observed_generation: Option, } #[derive(Serialize, Deserialize, Default, Debug, Clone, JsonSchema)] pub struct Condition { #[serde(rename = "type")] pub type_: String, pub status: String, pub reason: String, pub message: String, pub last_transition_time: String, } ``` **Step 4: Run tests** Run: `cargo test --lib types` Expected: PASS **Step 5: Wire up crdgen** `src/crdgen.rs`: ```rust use kube::CustomResourceExt; use soju_operator::SojuBouncer; fn main() { print!("{}", serde_yaml::to_string(&SojuBouncer::crd()).unwrap()); } ``` Run: `cargo run --bin crdgen` Expected: prints valid CRD YAML **Step 6: Commit** ```bash git add src/types.rs src/crdgen.rs git commit -m "feat: define SojuBouncer CRD types Signed-off-by: Josephine Pfeiffer " ``` --- ### Task 3: Resource builders -- ConfigMap **Files:** - Create: `src/resources/mod.rs` - Create: `src/resources/configmap.rs` - Modify: `src/lib.rs` - Modify: `src/resources.rs` -> becomes `src/resources/mod.rs` **Step 1: Write failing test** In `src/resources/configmap.rs`: ```rust #[cfg(test)] mod tests { use super::*; use crate::types::*; fn test_bouncer() -> SojuBouncer { let mut b = SojuBouncer::new("test-bouncer", SojuBouncerSpec { hostname: "irc.example.com".to_string(), title: Some("Test Bouncer".to_string()), listeners: vec![ Listener { address: ":6697".to_string(), tls: true }, Listener { address: ":6667".to_string(), tls: false }, ], tls: Some(TlsSpec { issuer_ref: IssuerRef { name: "letsencrypt-prod".to_string(), kind: "ClusterIssuer".to_string(), }, dns_names: vec!["irc.example.com".to_string()], }), ..Default::default() }); b.metadata.namespace = Some("test-ns".to_string()); b.metadata.uid = Some("test-uid".to_string()); b } #[test] fn generates_soju_config_with_listeners() { let db_uri = "host=db.svc port=5432 user=test password=secret dbname=soju_test sslmode=disable"; let cm = build_configmap(&test_bouncer(), db_uri); let data = cm.data.unwrap(); let config = &data["config"]; assert!(config.contains("listen ircs://0.0.0.0:6697")); assert!(config.contains("listen irc+insecure://0.0.0.0:6667")); assert!(config.contains("hostname irc.example.com")); assert!(config.contains("title \"Test Bouncer\"")); assert!(config.contains("tls /etc/soju/tls/tls.crt /etc/soju/tls/tls.key")); assert!(config.contains("db postgres")); } #[test] fn configmap_has_owner_reference() { let cm = build_configmap(&test_bouncer(), "host=localhost dbname=test"); let orefs = cm.metadata.owner_references.unwrap(); assert_eq!(orefs.len(), 1); assert_eq!(orefs[0].name, "test-bouncer"); } #[test] fn configmap_named_correctly() { let cm = build_configmap(&test_bouncer(), "host=localhost dbname=test"); assert_eq!(cm.metadata.name.unwrap(), "test-bouncer-config"); } } ``` **Step 2: Run test to verify failure** Run: `cargo test --lib resources::configmap` Expected: FAIL **Step 3: Implement** ```rust use k8s_openapi::api::core::v1::ConfigMap; use kube::api::ObjectMeta; use kube::Resource; use crate::types::{SojuBouncer, Listener}; pub fn build_configmap(bouncer: &SojuBouncer, db_uri: &str) -> ConfigMap { let name = bouncer.metadata.name.clone().unwrap(); let ns = bouncer.metadata.namespace.clone().unwrap(); let oref = bouncer.controller_owner_ref(&()).unwrap(); let config = generate_soju_config(&bouncer.spec, db_uri); ConfigMap { metadata: ObjectMeta { name: Some(format!("{name}-config")), namespace: Some(ns), owner_references: Some(vec![oref]), ..Default::default() }, data: Some([("config".to_string(), config)].into()), ..Default::default() } } fn generate_soju_config(spec: &crate::types::SojuBouncerSpec, db_uri: &str) -> String { let mut lines = Vec::new(); lines.push(format!("hostname {}", spec.hostname)); if let Some(title) = &spec.title { lines.push(format!("title \"{title}\"")); } if spec.tls.is_some() { lines.push("tls /etc/soju/tls/tls.crt /etc/soju/tls/tls.key".to_string()); } for listener in &spec.listeners { lines.push(format!("listen {}", listener_uri(listener))); } lines.push(format!("db postgres \"{db_uri}\"")); lines.push("message-store db".to_string()); lines.push("auth internal".to_string()); lines.join("\n") } fn listener_uri(listener: &Listener) -> String { let addr = &listener.address; let host_port = if addr.starts_with(':') { format!("0.0.0.0{addr}") } else { addr.clone() }; if listener.tls { format!("ircs://{host_port}") } else { format!("irc+insecure://{host_port}") } } ``` **Step 4: Run tests** Run: `cargo test --lib resources::configmap` Expected: PASS **Step 5: Commit** ```bash git add src/resources/ git commit -m "feat: add ConfigMap builder for soju config generation Signed-off-by: Josephine Pfeiffer " ``` --- ### Task 4: Resource builders -- Deployment **Files:** - Create: `src/resources/deployment.rs` - Modify: `src/resources/mod.rs` **Step 1: Write failing test** In `src/resources/deployment.rs`: ```rust #[cfg(test)] mod tests { use super::*; use crate::types::*; use crate::resources::configmap::tests::test_bouncer; // reuse helper #[test] fn deployment_mounts_configmap() { let dep = build_deployment(&test_bouncer()); let spec = dep.spec.unwrap(); let pod_spec = spec.template.spec.unwrap(); let volumes = pod_spec.volumes.unwrap(); assert!(volumes.iter().any(|v| v.name == "config")); } #[test] fn deployment_mounts_tls_when_configured() { let dep = build_deployment(&test_bouncer()); let spec = dep.spec.unwrap(); let pod_spec = spec.template.spec.unwrap(); let volumes = pod_spec.volumes.unwrap(); assert!(volumes.iter().any(|v| v.name == "tls")); } #[test] fn deployment_has_owner_reference() { let dep = build_deployment(&test_bouncer()); let orefs = dep.metadata.owner_references.unwrap(); assert_eq!(orefs[0].name, "test-bouncer"); } #[test] fn deployment_exposes_listener_ports() { let dep = build_deployment(&test_bouncer()); let containers = dep.spec.unwrap().template.spec.unwrap().containers; let ports = containers[0].ports.as_ref().unwrap(); assert_eq!(ports.len(), 2); assert_eq!(ports[0].container_port, 6697); assert_eq!(ports[1].container_port, 6667); } } ``` **Step 2: Run tests to verify failure** Run: `cargo test --lib resources::deployment` Expected: FAIL **Step 3: Implement** Build a Deployment that: - Runs `codeberg.org/emersion/soju` image - Mounts ConfigMap at `/soju-config` (soju's default config path in the official image) - Mounts TLS Secret at `/etc/soju/tls/` if TLS is configured - Mounts DB credentials Secret as env var `SOJU_DB_URI` (or as file) - Exposes ports parsed from listener addresses - Sets resource requests/limits from CR spec - Uses `controller_owner_ref` for ownership Parse port from listener address strings (e.g. `:6697` -> `6697`). **Step 4: Run tests** Run: `cargo test --lib resources::deployment` Expected: PASS **Step 5: Commit** ```bash git add src/resources/deployment.rs git commit -m "feat: add Deployment builder with volume mounts Signed-off-by: Josephine Pfeiffer " ``` --- ### Task 5: Resource builders -- Service **Files:** - Create: `src/resources/service.rs` - Modify: `src/resources/mod.rs` **Step 1: Write failing test** Test that the Service: - Is named `{bouncer-name}` - Has correct selector labels - Has one port per listener - Has owner reference **Step 2: Run test to verify failure** Run: `cargo test --lib resources::service` Expected: FAIL **Step 3: Implement** Build a ClusterIP Service with ports matching the listeners. **Step 4: Run tests** Run: `cargo test --lib resources::service` Expected: PASS **Step 5: Commit** ```bash git add src/resources/service.rs git commit -m "feat: add Service builder Signed-off-by: Josephine Pfeiffer " ``` --- ### Task 6: Resource builders -- OpenShift Route **Files:** - Create: `src/resources/route.rs` - Modify: `src/resources/mod.rs` **Step 1: Write failing test** Test that the Route: - Uses TLS passthrough termination - Points at the correct Service - Has the correct host from CR spec - Has owner reference **Step 2: Run test to verify failure** Run: `cargo test --lib resources::route` Expected: FAIL **Step 3: Implement** Define OpenShift Route types manually (since no maintained crate exists): ```rust use kube::CustomResource; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; #[derive(CustomResource, Clone, Debug, Deserialize, Serialize, JsonSchema)] #[kube( group = "route.openshift.io", version = "v1", kind = "Route", plural = "routes", namespaced )] pub struct OcpRouteSpec { #[serde(skip_serializing_if = "Option::is_none")] pub host: Option, pub to: RouteTargetReference, #[serde(skip_serializing_if = "Option::is_none")] pub port: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tls: Option, #[serde(skip_serializing_if = "Option::is_none", rename = "wildcardPolicy")] pub wildcard_policy: Option, } #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] pub struct RouteTargetReference { pub kind: String, pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub weight: Option, } #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] pub struct RoutePort { #[serde(rename = "targetPort")] pub target_port: String, } #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] pub struct RouteTlsConfig { pub termination: String, #[serde(skip_serializing_if = "Option::is_none", rename = "insecureEdgeTerminationPolicy")] pub insecure_edge_termination_policy: Option, } ``` Build function creates a Route with passthrough TLS pointing at the Service. **Step 4: Run tests** Run: `cargo test --lib resources::route` Expected: PASS **Step 5: Commit** ```bash git add src/resources/route.rs git commit -m "feat: add OpenShift Route builder with passthrough TLS Signed-off-by: Josephine Pfeiffer " ``` --- ### Task 7: Resource builders -- cert-manager Certificate **Files:** - Create: `src/resources/certificate.rs` - Modify: `src/resources/mod.rs` **Step 1: Write failing test** Test that the Certificate: - Uses the issuer ref from CR spec - Has correct DNS names - Secret name is `{bouncer-name}-tls` - Has owner reference **Step 2: Run test to verify failure** Run: `cargo test --lib resources::certificate` Expected: FAIL **Step 3: Implement** Use `k8s-crds-cert-manager` types to build a Certificate CR. **Step 4: Run tests** Run: `cargo test --lib resources::certificate` Expected: PASS **Step 5: Commit** ```bash git add src/resources/certificate.rs git commit -m "feat: add cert-manager Certificate builder Signed-off-by: Josephine Pfeiffer " ``` --- ### Task 8: Database provisioning **Files:** - Create: `src/db.rs` **Step 1: Write failing tests** Test the SQL generation functions (pure logic, no actual DB connection needed): ```rust #[cfg(test)] mod tests { use super::*; #[test] fn create_role_sql_escapes_name() { let sql = create_role_sql("my-bouncer"); assert!(sql.contains("\"soju_my_bouncer\"")); } #[test] fn create_database_sql_sets_owner() { let sql = create_database_sql("my-bouncer"); assert!(sql.contains("OWNER \"soju_my_bouncer\"")); } #[test] fn drop_database_sql() { let sql = drop_database_sql("my-bouncer"); assert!(sql.contains("DROP DATABASE IF EXISTS \"soju_my_bouncer\"")); } #[test] fn tenant_connection_string() { let uri = build_tenant_uri("db.svc", 5432, "my-bouncer", "generated-password"); assert!(uri.contains("user=soju_my_bouncer")); assert!(uri.contains("dbname=soju_my_bouncer")); } } ``` **Step 2: Run tests to verify failure** Run: `cargo test --lib db` Expected: FAIL **Step 3: Implement pure SQL generation functions** ```rust pub fn create_role_sql(bouncer_name: &str) -> String { let role = tenant_role_name(bouncer_name); // Password will be set via ALTER ROLE separately format!("CREATE ROLE \"{role}\" WITH LOGIN") } pub fn create_database_sql(bouncer_name: &str) -> String { let role = tenant_role_name(bouncer_name); let db = tenant_db_name(bouncer_name); format!("CREATE DATABASE \"{db}\" OWNER \"{role}\"") } pub fn drop_database_sql(bouncer_name: &str) -> String { let db = tenant_db_name(bouncer_name); format!("DROP DATABASE IF EXISTS \"{db}\"") } pub fn drop_role_sql(bouncer_name: &str) -> String { let role = tenant_role_name(bouncer_name); format!("DROP ROLE IF EXISTS \"{role}\"") } pub fn terminate_connections_sql(bouncer_name: &str) -> String { let db = tenant_db_name(bouncer_name); format!( "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{db}'" ) } pub fn build_tenant_uri(host: &str, port: u16, bouncer_name: &str, password: &str) -> String { let role = tenant_role_name(bouncer_name); let db = tenant_db_name(bouncer_name); format!("host={host} port={port} user={role} password={password} dbname={db} sslmode=disable") } fn tenant_role_name(bouncer_name: &str) -> String { format!("soju_{}", bouncer_name.replace('-', "_")) } fn tenant_db_name(bouncer_name: &str) -> String { format!("soju_{}", bouncer_name.replace('-', "_")) } ``` Then implement the async functions that actually connect and execute: ```rust pub async fn provision_tenant_db( client: &tokio_postgres::Client, bouncer_name: &str, password: &str, ) -> Result<(), Box> { // Check if role exists, create if not // Check if database exists, create if not // Set password on role } pub async fn deprovision_tenant_db( client: &tokio_postgres::Client, bouncer_name: &str, ) -> Result<(), Box> { // Terminate connections // Drop database // Drop role } ``` **Step 4: Run tests** Run: `cargo test --lib db` Expected: PASS **Step 5: Commit** ```bash git add src/db.rs git commit -m "feat: add database provisioning and deprovisioning logic Signed-off-by: Josephine Pfeiffer " ``` --- ### Task 9: Secret builder for tenant DB credentials **Files:** - Create: `src/resources/secret.rs` - Modify: `src/resources/mod.rs` **Step 1: Write failing test** Test that the Secret: - Named `{bouncer-name}-db` - Contains key `uri` with the tenant connection string - Has owner reference **Step 2: Run test, verify failure** **Step 3: Implement** Build a Secret containing the scoped PostgreSQL connection string. **Step 4: Run tests, verify pass** **Step 5: Commit** ```bash git add src/resources/secret.rs git commit -m "feat: add Secret builder for tenant DB credentials Signed-off-by: Josephine Pfeiffer " ``` --- ### Task 10: Reconciler **Files:** - Create: `src/controller.rs` - Modify: `src/lib.rs` **Step 1: Write failing test** Use tower-test mock client pattern. Test that reconciling a SojuBouncer CR results in the correct sequence of API calls (server-side apply for each owned resource). **Step 2: Run test, verify failure** **Step 3: Implement** ```rust use std::sync::Arc; use std::time::Duration; use kube::api::{Api, Patch, PatchParams}; use kube::runtime::controller::Action; use kube::runtime::finalizer::{finalizer, Event}; use kube::{Client, Resource}; use crate::types::{SojuBouncer, SojuBouncerStatus, Condition}; use crate::resources; use crate::db; static FINALIZER_NAME: &str = "irc.josie.cloud/db-cleanup"; static FIELD_MANAGER: &str = "soju-operator"; pub struct Context { pub client: Client, pub db_host: String, pub db_port: u16, pub db_master_uri: String, } pub async fn reconcile( bouncer: Arc, ctx: Arc, ) -> Result { let ns = bouncer.metadata.namespace.as_deref().unwrap_or("default"); let api = Api::::namespaced(ctx.client.clone(), ns); finalizer(&api, FINALIZER_NAME, bouncer, |event| async { match event { Event::Apply(bouncer) => reconcile_apply(&bouncer, ctx.clone()).await, Event::Cleanup(bouncer) => reconcile_cleanup(&bouncer, ctx.clone()).await, } }) .await .map_err(|e| Error::Finalizer(Box::new(e))) } pub fn error_policy( _bouncer: Arc, error: &Error, _ctx: Arc, ) -> Action { tracing::warn!(%error, "reconcile failed"); Action::requeue(Duration::from_secs(60)) } ``` `reconcile_apply` calls each builder in order, applies with server-side apply, and updates status. `reconcile_cleanup` calls `db::deprovision_tenant_db` then returns `Action::await_change()`. **Step 4: Run tests, verify pass** **Step 5: Commit** ```bash git add src/controller.rs git commit -m "feat: add reconciler with finalizer-based lifecycle Signed-off-by: Josephine Pfeiffer " ``` --- ### Task 11: Main entry point **Files:** - Modify: `src/main.rs` **Step 1: Implement main** ```rust use std::sync::Arc; use futures::StreamExt; use kube::{Client, api::Api}; use kube::runtime::controller::Controller; use kube::runtime::watcher; use soju_operator::controller::{self, Context}; use soju_operator::SojuBouncer; #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::from_default_env() .add_directive("soju_operator=info".parse()?), ) .init(); let client = Client::try_default().await?; let db_master_uri = std::env::var("SOJU_DB_MASTER_URI") .expect("SOJU_DB_MASTER_URI must be set"); let db_host = std::env::var("SOJU_DB_HOST") .expect("SOJU_DB_HOST must be set"); let db_port: u16 = std::env::var("SOJU_DB_PORT") .unwrap_or_else(|_| "5432".to_string()) .parse()?; let ctx = Arc::new(Context { client: client.clone(), db_host, db_port, db_master_uri, }); let bouncers = Api::::all(client.clone()); Controller::new(bouncers, watcher::Config::default()) .shutdown_on_signal() .run(controller::reconcile, controller::error_policy, ctx) .for_each(|res| async move { match res { Ok(o) => tracing::info!("reconciled {:?}", o), Err(e) => tracing::warn!("reconcile failed: {:?}", e), } }) .await; Ok(()) } ``` **Step 2: Verify it compiles** Run: `cargo check` Expected: PASS **Step 3: Commit** ```bash git add src/main.rs git commit -m "feat: wire up controller main entry point Signed-off-by: Josephine Pfeiffer " ``` --- ### Task 12: Deployment manifests **Files:** - Create: `deploy/crd.yaml` (generated) - Create: `deploy/operator.yaml` - Create: `deploy/rbac.yaml` **Step 1: Generate CRD** Run: `cargo run --bin crdgen > deploy/crd.yaml` **Step 2: Write operator Deployment manifest** Deployment in the `irc-josie-cloud` namespace, referencing a Secret for `SOJU_DB_MASTER_URI`, `SOJU_DB_HOST`, `SOJU_DB_PORT`. **Step 3: Write RBAC manifests** ServiceAccount, ClusterRole, ClusterRoleBinding with permissions for: - `irc.josie.cloud` group: sojubouncers (all verbs + status) - core: configmaps, secrets, services (get, list, create, update, patch, delete) - apps: deployments (get, list, create, update, patch, delete) - `route.openshift.io`: routes (get, list, create, update, patch, delete) - `cert-manager.io`: certificates (get, list, create, update, patch, delete) **Step 4: Commit** ```bash git add deploy/ git commit -m "feat: add deployment manifests and RBAC Signed-off-by: Josephine Pfeiffer " ``` --- ### Task 13: BuildConfig for OCP builds **Files:** - Create: `Containerfile` - Create: `deploy/buildconfig.yaml` **Step 1: Write Containerfile** Multi-stage build: - Stage 1: `rust:latest` -- build the operator binary - Stage 2: `registry.access.redhat.com/ubi9-minimal` -- copy in the binary **Step 2: Write BuildConfig** OCP BuildConfig using binary source strategy. **Step 3: Commit** ```bash git add Containerfile deploy/buildconfig.yaml git commit -m "feat: add Containerfile and OCP BuildConfig Signed-off-by: Josephine Pfeiffer " ``` --- ### Task 14: Integration smoke test **Step 1: Apply CRD to cluster** Run: `oc apply -f deploy/crd.yaml` **Step 2: Apply RBAC and operator deployment** Run: `oc apply -f deploy/rbac.yaml -f deploy/operator.yaml` **Step 3: Create a test SojuBouncer CR** ```yaml apiVersion: irc.josie.cloud/v1alpha1 kind: SojuBouncer metadata: name: smoke-test namespace: irc-josie-cloud spec: hostname: smoke.irc.josie.cloud listeners: - address: ":6697" tls: true tls: issuerRef: name: letsencrypt-prod kind: ClusterIssuer dnsNames: - smoke.irc.josie.cloud route: host: smoke.irc.josie.cloud ``` Run: `oc apply -f` the above **Step 4: Verify resources created** Run: `oc get deployment,configmap,service,route,certificate -l app.kubernetes.io/managed-by=soju-operator` **Step 5: Delete CR and verify cleanup** Run: `oc delete sojubouncer smoke-test` Verify: tenant DB and role are dropped, all k8s resources are gone.