03-03T18:49:10.820Z"}
{"parentUuid":"936d57c4-a159-418c-8975-b889074da637","isSidechain":false,"userType":"external","cwd":"/home/josie/development/personal/soju-operator","sessionId":"6aab76e0-34b7-41ad-8632-4c3705d47bfc","version":"2.1.63","gitBranch":"HEAD","slug":"delightful-watching-hamming","type":"progress","data":{"type":"hook_progress","hookEvent":"PreToolUse","hookName":"PreToolUse:Bash","command":"python3 ${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.py"},"toolUseID":"toolu_019zjjU8syaWtpyfnQ5R8SzJ","parentToolUseID":"toolu_019zjjU8syaWtpyfnQ5R8SzJ","uuid":"00cd937b-0adc-4a7f-bba4-9a917c04a3cc","timestamp":"2026-03-03T18:49:10.822Z"}
{"parentUuid":"00cd937b-0adc-4a7f-bba4-9a917c04a3cc","isSidechain":false,"userType":"external","cwd":"/home/josie/development/personal/soju-operator","sessionId":"6aab76e0-34b7-41ad-8632-4c3705d47bfc","version":"2.1.63","gitBranch":"HEAD","slug":"delightful-watching-hamming","type":"progress","data":{"type":"bash_progress","output":"cnpg-system                                        cnpg-controller-manager-64c6f8cbbc-lrmm2                          0/1     CrashLoopBackOff    509 (2m2s ago)    35d\n---\n","fullOutput":"cnpg-system                                        cnpg-controller-manager-64c6f8cbbc-lrmm2                          0/1     CrashLoopBackOff    509 (2m2s ago)    35d\n---\n","elapsedTimeSeconds":3,"totalLines":3,"totalBytes":0,"taskId":"brednkwc3"},"toolUseID":"bash-progress-0","parentToolUseID":"toolu_019zjjU8syaWtpyfnQ5R8SzJ","uuid":"4e10a3f0-3144-4896-918a-3cd351e2b038","timestamp":"2026-03-03T18:49:48.215Z"}
{"parentUuid":"4e10a3f0-3144-4896-918a-3cd351e2b038","isSidechain":false,"userType":"external","cwd":"/home/josie/development/personal/soju-operator","sessionId":"6aab76e0-34b7-41ad-8632-4c3705d47bfc","version":"2.1.63","gitBranch":"HEAD","slug":"delightful-watching-hamming","type":"progress","data":{"type":"bash_progress","output":"cnpg-system                                        cnpg-controller-manager-64c6f8cbbc-lrmm2                          0/1     CrashLoopBackOff    509 (2m2s ago)    35d\n---\n","fullOutput":"cnpg-system                                        cnpg-controller-manager-64c6f8cbbc-lrmm2                          0/1     CrashLoopBackOff    509 (2m2s ago)    35d\n---\n","elapsedTimeSeconds":4,"totalLines":3,"totalBytes":0,"taskId":"brednkwc3"},"toolUseID":"bash-progress-1","parentToolUseID":"toolu_019zjjU8syaWtpyfnQ5R8SzJ","uuid":"f44cd350-cfa5-44a0-91af-86302a3fcaeb","timestamp":"2026-03-03T18:49:49.213Z"}
{"parentUuid":"f44cd350-cfa5-44a0-91af-86302a3fcaeb","isSidechain":false,"userType":"external","cwd":"/home/josie/development/personal/soju-operator","sessionId":"6aab76e0-34b7-41ad-8632-4c3705d47bfc","version":"2.1.63","gitBranch":"HEAD","slug":"delightful-watching-hamming","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Bash","command":"python3 ${CLAUDE_PLUGIN_ROOT}/hooks/posttooluse.py"},"parentToolUseID":"toolu_019zjjU8syaWtpyfnQ5R8SzJ","toolUseID":"toolu_019zjjU8syaWtpyfnQ5R8SzJ","timestamp":"2026-03-03T18:49:50.097Z","uuid":"b4b741d3-489d-496a-b2d2-96c7539940c8"}
{"parentUuid":"936d57c4-a159-418c-8975-b889074da637","isSidechain":false,"userType":"external","cwd":"/home/josie/development/personal/soju-operator","sessionId":"6aab76e0-34b7-41ad-8632-4c3705d47bfc","version":"2.1.63","gitBranch":"HEAD","slug":"delightful-watching-hamming","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_019zjjU8syaWtpyfnQ5R8SzJ","type":"tool_result","content":"cnpg-system                                        cnpg-controller-manager-64c6f8cbbc-lrmm2                          0/1     CrashLoopBackOff    509 (2m2s ago)    35d\n---","is_error":false}]},"uuid":"953a271d-4ece-4b38-912d-d8aad73a4421","timestamp":"2026-03-03T18:49:50.096Z","toolUseResult":{"stdout":"cnpg-system                                        cnpg-controller-manager-64c6f8cbbc-lrmm2                          0/1     CrashLoopBackOff    509 (2m2s ago)    35d\n---","stderr":"","interrupted":false,"isImage":false,"returnCo           true\n    ConfigMapName:           openshift-service-ca.crt\n    Optional:                false\nQoS Class:                   Burstable\nNode-Selectors:              <none>\nTolerations:                 node.kubernetes.io/memory-pressure:NoSchedule op=Exists\n                             node.kubernetes.io/not-ready:NoExecute op=Exists for 300s\n                             node.kubernetes.io/unreachable:NoExecute op=Exists for 300s\nEvents:\n  Type     Reason            Age   From               Message\n  ----     ------            ----  ----               -------\n  Warning  FailedScheduling  114s  default-scheduler  0/1 nodes are available: pod has unbound immediate PersistentVolumeClaims. preemption: 0/1 nodes are available: 1 Preemption is not helpful for scheduling.","is_error":false}]},"uuid":"8010ed16-0b37-40f8-88a4-59aa6937f341","timestamp":"2026-03-03T18:40:59.902Z","toolUseResult":{"stdout":"    Type:      ConfigMap (a volume populated by a ConfigMap)\n    Name:      workspace258a76f3e81341ab-route\n    Optional:  false\n  kube-api-access-gwtnb:\n    Type:                    Projected (a volume that contains injected data from multiple sources)\n    TokenExpirationSeconds:  3607\n    ConfigMapName:           kube-root-ca.crt\n    Optional:                false\n    DownwardAPI:             true\n    ConfigMapName:           openshift-service-ca.crt\n    Optional:                false\nQoS Class:                   Burstable\nNode-Selectors:              <none>\nTolerations:                 node.kubernetes.io/memory-pressure:NoSchedule op=Exists\n                             node.kubernetes.io/not-ready:NoExecute op=Exists for 300s\n                             node.kubernetes.io/unreachable:NoExecute op=Exists for 300s\nEvents:\n  Type     Reason            Age   From               Message\n  ----     ------            ----  ----               -------\n  Warning  FailedScheduling  114s  default-scheduler  0/1 nodes are available: pod has unbound immediate PersistentVolumeClaims. preemption: 0/1 nodes are available: 1 Preemption is not helpful for scheduling.","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false},"sourceToolAssistantUUID":"780b71a3-33ba-4da5-8524-7a483e726167"}
{"parentUuid":"8010ed16-0b37-40f8-88a4-59aa6937f341","isSidechain":false,"userType":"external","cwd":"/home/josie/development/personal/neoche","sessionId":"e94eabfe-2eb5-4f06-a764-c671d6d0f8e6","version":"2.1.63","gitBranch":"HEAD","slug":"noble-waddling-lemon","message":{"model":"claude-opus-4-6","id":"msg_012vAWBJUTmtf6ixccSQbL9U","type":"message","role":"assistant","content":[{"type":"text","text":"PVC is not binding. Let me check:"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":456,"cache_read_input_tokens":157681,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":456},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CYgdt6HLcAbDtfNgDpaEf","type":"assistant","uuid":"db62cbaa-0903-4335-b7ef-fc2b3c231a3e","timestamp":"2026-03-03T18:41:03.825Z"}
{"parentUuid":"db62cbaa-0903-4335-b7ef-fc2b3c231a3e","isSidechain":false,"userType":"external","cwd":"/home/josie/development/personal/neoche","sessionId":"e94eabfe-2eb5-4f06-a764-c671d6d0f8e6","version":"2.1.63","gitBranch":"HEAD","slug":"noble-waddling-lemon","message":{"model":"claude-opus-4-6","id":"msg_012vAWBJUTmtf6ixccSQbL9U","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01S4QRykFN8gcSrFXFVCwksf","name":"Bash","input":{"command":"oc get pvc -n josie-devspaces 2>&1","description":"Check PVCs"},"caller":{"type":"direct"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":456,"cache_read_input_tokens":157681,"output_tokens":99,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":456,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[],"speed":"standard"}},"requestId":"req_011CYgdt6HLcAbDtfNgDpaEf","type":"assistant","uuid":"d07081eb-7dfa-4516-b3e7-ce38e40c6207","timestamp":"2026-03-03T18:41:04.319Z"}
{"parentUuid":"d07081eb-7dfa-4516-b3e7-ce38e40c6207","isSidechain":false,"userType":"external","cwd":"/home/josie/development/personal/neoche","sessionId":"e94eabfe-2eb5-4f06-a764-c671d6d0f8e6","version":"2.1.63","gitBranch":"HEAD","slug":"noble-waddling-lemon","type":"progress","data":{"type":"hook_progress","hookEvent":"PreToolUse","hookName":"PreToolUse:Bash","command":"python3 ${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.py"},"toolUseID":"toolu_01S4QRykFN8gcSrFXFVCwksf","parentToolUseID":"toolu_01S4QRykFN8gcSrFXFVCwksf","uuid":"9bc56d0a-d0a2-4e20-ac57-31c9367cdd58","timestamp":"2026-03-03T18:41:04.322Z"}
{"parentUuid":"9bc56d0a-d0a2-4e20-ac57-31c9367cdd58","isSidechain":false,"userType":"external","cwd":"/home/josie/development/personal/neoche","sessionId":"e94eabfe-2eb5-4f06-a764-c671d6d0f8e6","version":"2.1.63","gitBranch":"HEAD","slug":"noble-waddling-lemon","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Bash","command":"python3 ${CLAUDE_PLUGIN_ROOT}/hooks/posttooluse.py"},"parentToolUseID":"toolu_01S4QRykFN8gcSrFXFVCwksf","toolUseID":"toolu_01S4QRykFN8gcSrFXFVCwksf","timestamp":"2026-03-03T18:41:13.414Z","uuid":"6f2ea81a-b81d-40cf-9d06-e79410760683"}
{"parentUuid":"d07081eb-7dfa-4516-b3e7-ce38e40c6207","isSidechain":false,"userType":"external","cwd":"/home/josie/development/personal/neoche","sessionId":"e94eabfe-2eb5-4f06-a764-c671d6d0f8e6","version":"2.1.63","gitBranch":"HEAD","slug":"noble-waddling-lemon","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01S4QRykFN8gcSrFXFVCwksf","type":"tool_result","content":"NAME                 STATUS    VOLUME   CAPACITY   ACCESS MODES   STORAGECLASS   VOLUMEATTRIBUTESCLASS   AGE\nclaim-devworkspace   Pending                                                     <unset>                 3m58s","is_error":false}]},"uuid":"2dfe531e-c5ac-49a4-a634-1aaead24f454","timestamp":"2026-03-03T18:41:13.413Z","toolUseResult":{"stdout":"NAME                 STATUS    VOLUME   CAPACITY   ACCESS MODES   STORAGECLASS   VOLUMEATTRIBUTESCLASS   AGE\nclaim-devworkspace   Pending                                                     <unset>                 3m58s","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false},"sourceToolAssistantUUID":"d07081eb-7dfa-4516-b3e7-ce38e40c6207"}
{"parentUuid":"2dfe531e-c5ac-49a4-a634-1aaead24f454","isSidechain":false,"userType":"external","cwd":"/home/josie/development/personal/neoche","sessionId":"e94eabfe-2eb5-4f06-a764-c671d6d0f8e6","version":"2.1.63","gitBranch":"HEAD","slug":"noble-waddling-lemon","message":{"model":"claude-opus-4-6","id":"msg_013jd5vjf7fbSKRp6XXC5SaG","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_012omGbaQ55yLiVnWnhjv5Vj","name":"Bash","input":{"command":"oc describe pvc claim-devworkspace -n josie-devspaces 2>&1 | tail -15","description":"Check PVC details"},"caller":{"type":"direct"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":274,"cache_read_input_tokens":158137,"output_tokens":98,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":274,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[],"speed":"standard"}},"requestId":"req_011CYgdu5rZz1qzJxBDHqvsD","type":"assistant","uuid":"6158ddb5-f065-41ed-8ea1-68a321f3ed05","timestamp":"2026-03-03T18:41:17.373Z"}
{"parentUuid":"6158ddb5-f065-41ed-8ea1-68a321f3ed05","isSidechain":false,"userType":"external","cwd":"/home/josie/development/personal/neoche","sessionId":"e94eabfe-2eb5-4f06-a764-c671d6d0f8e6","version":"2.1.63","gitBranch":"HEAD","slug":"noble-waddling-lemon","type":"progress","data":{"type":"hook_progress","hookEvent":"PreToolUse","hookName":"PreToolUse:Bash","command":"python3 ${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.py"},"toolUseID":"toolu_012omGbaQ55yLiVnWnhjv5Vj","palaude-opus-4-6","id":"msg_01WoKQwmoQYszbaSrLFTwtgb","type":"message","role":"assistant","content":[{"type":"text","text":"All 34 tests pass. I have now read every file in the project. Here is the full review.\n\n---\n\n# Soju-Operator Code Review\n\n## Overall Assessment\n\nThis is a well-structured Kubernetes operator. The code is clean, the module decomposition is sensible, tests cover the resource builders thoroughly, and the choice of kube.rs 3.0 with server-side apply and finalizers is solid. The review below focuses on the areas you asked about: correctness, security, error handling, idempotency, and bugs.\n\n---\n\n## 1. Critical Issues (Must Fix)\n\n### 1.1 SQL Injection in Password and Bouncer Name\n\n**File:** `/home/josie/development/personal/soju-operator/src/db.rs`, lines 11-13\n\n```rust\npub fn create_role_sql(bouncer_name: &str, password: &str) -> String {\n    let role = tenant_role_name(bouncer_name);\n    format!(\"CREATE ROLE \\\"{role}\\\" WITH LOGIN PASSWORD '{password}'\")\n}\n```\n\nThe password is interpolated directly into SQL via string formatting. If `generate_password` ever produces a value containing a single quote (it currently does not, since it formats a u64 as hex), or if any future caller passes an arbitrary password, this is a direct SQL injection vector. The bouncer name is user-supplied from the CRD's `metadata.name` -- while Kubernetes restricts this to DNS-subdomain characters, the principle of defense-in-depth applies.\n\nThe `terminate_connections_sql` function at line 34 similarly interpolates the database name into a string literal without escaping:\n\n```rust\nformat!(\n    \"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{db}'\"\n)\n```\n\n**Recommendation:** Escape single quotes in the password (`password.replace('\\'', \"''\")`) at minimum. Better yet, use parameterized queries for `terminate_connections_sql` since it hits `pg_stat_activity`. For DDL statements like CREATE ROLE and CREATE/DROP DATABASE, PostgreSQL does not support `$1` parameters, so quoting/escaping the identifier and literal is the correct approach. Consider adding a `quote_literal` helper.\n\n### 1.2 Insecure Password Generation\n\n**File:** `/home/josie/development/personal/soju-operator/src/db.rs`, lines 45-54\n\n```rust\npub fn generate_password() -> String {\n    use std::collections::hash_map::DefaultHasher;\n    use std::hash::{Hash, Hasher};\n    use std::time::SystemTime;\n\n    let mut hasher = DefaultHasher::new();\n    SystemTime::now().hash(&mut hasher);\n    std::process::id().hash(&mut hasher);\n    format!(\"{:x}\", hasher.finish())\n}\n```\n\nThis generates a 16-character hex string from a non-cryptographic hash of the current time and PID. `DefaultHasher` is SipHash, which is not designed for unpredictability. The output entropy is at most 64 bits from the hash, but the actual entropy is far lower because `SystemTime` resolution and PID ranges are predictable. An attacker on the same cluster who can observe process start times could feasibly reproduce the password.\n\n**Recommendation:** Use `rand::thread_rng()` with `Alphanumeric` sampling, or read from `/dev/urandom` directly. A 32-character alphanumeric password from a CSPRNG is standard practice for database credentials in operators. Example:\n\n```rust\nuse rand::Rng;\nuse rand::distributions::Alphanumeric;\n\npub fn generate_password() -> String {\n    rand::thread_rng()\n        .sample_iter(&Alphanumeric)\n        .take(32)\n        .map(char::from)\n        .collect()\n}\n```\n\n### 1.3 Password Regeneration on Every Reconcile When URI Parsing Fails\n\n**File:** `/home/josie/development/personal/soju-operator/src/controller.rs`, lines 213-241\n\n```rust\nasync fn get_or_create_password(...) -> Result<String, Error> {\n    // ...\n    match secret_api.get_opt(&secret_name).await.map_err(Error::Kube)? {\n        Some(existing) => {\n            // tries to extract password from existing secret\n            // ...\n            Ok(db::generate_password())  // falls through to NEW password\n        }\n        None => Ok(db::generate_password()),\n    }\n}\n```\n\nIf the secret exists but the URI format does not match the `extract_password_from_uri` parser (line 244-251), the function silently generates a **new** password. This new password is then used to call `provision_tenant_db`, but since the role already exists, the provisioning function skips the `CREATE ROLE` call (the `if !exists` guard at line 70 of db.rs). The result: the secret gets overwritten with a new password that does not match the actual PostgreSQL role password. The soju instance will fail to connect to its database.\n\nThis is the most dangerous bug in the operator. Every reconcile loop that hits this path will break the tenant.\n\n**Recommendation:** If the secret exists but the password cannot be extracted, this should be an error, not a fallback to a new password. Return `Err(Error::MissingField(\"could not extract password from existing db secret\"))`.\n\n### 1.4 Password in ConfigMap (Plaintext)\n\n**File:** `/home/josie/development/personal/soju-operator/src/controller.rs`, line 123\n\n```rust\nlet cm = build_configmap(bouncer, &tenant_uri);\n```\n\nAnd in `/home/josie/development/personal/soju-operator/src/resources/configmap.rs`, line 43:\n\n```rust\nlines.push(format!(\"db postgres \\\"{db_uri}\\\"\"));\n```\n\nThe full database connection URI, including the plaintext password, is written into the ConfigMap. ConfigMaps are not encrypted at rest and are visible to anyone with `get configmaps` permission in the namespace. The password is already stored in the Secret (`{name}-db`), and the Secret is already mounted into the pod at `/etc/soju/db/`.\n\n**Recommendation:** Soju's config should reference the URI from the mounted secret file path rather than embedding the credential directly. Change the config line to something like:\n\n```\ndb postgres /etc/soju/db/uri\n```\n\nIf soju supports reading the connection string from a file. If it does not, then at minimum document this as a known limitation and ensure RBAC restricts ConfigMap access.\n\n---\n\n## 2. Important Issues (Should Fix)\n\n### 2.1 No Status Update on Reconcile Failure\n\n**File:** `/home/josie/development/personal/soju-operator/src/controller.rs`\n\n`update_status` is only called on the success path (line 180). When any step of `reconcile_apply` fails, the error propagates up and the status is never updated to reflect the failure. The `error_policy` function (line 204) only logs and requeues -- it does not set a `Ready=False` condition.\n\n**Recommendation:** Either wrap the apply logic in a catch-all that updates status on failure, or update the `error_policy` to patch the status. A common pattern:\n\n```rust\npub async fn reconcile(bouncer: Arc<SojuBouncer>, ctx: Arc<Context>) -> Result<Action, Error> {\n    let result = reconcile_inner(&bouncer, &ctx).await;\n    if let Err(ref e) = result {\n        let _ = update_status(&ctx.client, &bouncer, false, &e.to_string()).await;\n    }\n    result\n}\n```\n\n### 2.2 New Database Connection Per Reconcile\n\n**File:** `/home/josie/development/personal/soju-operator/src/controller.rs`, line 90\n\n```rust\nlet db_client = connect_db(&ctx.db_master_uri).await?;\n```\n\nA new TCP connection and PostgreSQL session is created for every single reconciliation. Under load (many SojuBouncer resources or frequent requeues), this will exhaust connection limits on the PostgreSQL server.\n\n**Recommendation:** Use a connection pool (e.g., `deadpool-postgres` or `bb8-postgres`) stored in the `Context` struct. This also eliminates the spawned background task per connection that currently handles the connection future (line 294-298).\n\n### 2.3 Route Always Created, Even When Not Needed\n\n**File:** `/home/josie/development/personal/soju-operator/src/controller.rs`, lines 160-178\n\nThe Route is always created regardless of whether the spec includes a `route` field. If the user does not want an OpenShift Route (e.g., they are using a NodePort or LoadBalancer service directly for IRC), there is no way to opt out. This also means the operator will fail on non-OpenShift clusters because the `route.openshift.io/v1` API will not exist.\n\n**Recommendation:** Guard Route creation behind a check similar to how Certificate is guarded:\n\n```rust\nif bouncer.spec.route.is_some() {\n    // build and apply route\n}\n```\n\nOr, if Routes should be opt-out rather than opt-in, add a `route.enabled` field to the spec defaulting to `true`.\n\n### 2.4 `extract_password_from_uri` Parser Is Fragile\n\n**File:** `/home/josie/development/personal/soju-operator/src/controller.rs`, lines 244-251\n\n```rust\nfn extract_password_from_uri(uri: &str) -> Option<String> {\n    for part in uri.split_whitespace() {\n        if let Some(pw) = part.strip_prefix(\"password=\") {\n            return Some(pw.to_string());\n        }\n    }\n    None\n}\n```\n\nThis relies on the URI being in libpq key=value format with whitespace separators. If soju or any future code path changes to use a `postgresql://` URL format, this parser will silently fail and trigger the password-regeneration bug described in 1.3. The parser also does not handle quoted values (`password='has spaces'`).\n\n**Recommendation:** At minimum, add a test that round-trips through `build_tenant_uri` and `extract_password_from_uri` to ensure they stay in sync. Better: store the password as a separate key in the Secret rather than parsing it out of the URI.\n\n### 2.5 RBAC Is Cluster-Scoped but Operator Watches All Namespaces\n\n**File:** `/home/josie/development/personal/soju-operator/deploy/rbac.yaml`\n\nThe ClusterRole grants permissions across all namespaces for secrets, configmaps, services, deployments, routes, and certificates. Since the controller uses `Api::<SojuBouncer>::all(client.clone())` in main.rs (line 39), it watches all namespaces, which means it also needs cluster-wide permissions -- but this means the operator's service account can read/write Secrets in any namespace. If the operator is compromised, the blast radius is the entire cluster.\n\n**Recommendation:** If multi-namespace operation is truly required, document this tradeoff. If the operator only needs to manage bouncers in specific namespaces, scope it down with namespaced Roles and RoleBindings, or use label selectors in the watcher config.\n\n### 2.6 Deployment Image Has No Tag/Digest\n\n**File:** `/home/josie/development/personal/soju-operator/src/resources/deployment.rs`, line 15\n\n```rust\nconst SOJU_IMAGE: &str = \"codeberg.org/emersion/soju\";\n```\n\nNo image tag or digest is specified. Container runtimes default to `:latest`, which means the soju version deployed is unpredictable and will change on every pod restart when a new image is pushed upstream. This breaks reproducibility and could introduce breaking changes silently.\n\n**Recommendation:** Add an `image` or `image_tag` field to the CRD spec with a sensible default, or pin to a specific tag in the constant.\n\n---\n\n## 3. Suggestions (Nice to Have)\n\n### 3.1 `sslmode=disable` in Tenant URIs\n\n**File:** `/home/josie/development/personal/soju-operator/src/db.rs`, line 42\n\n```rust\nformat!(\"host={host} port={port} user={role} password={password} dbname={db} sslmode=disable\")\n```\n\nThe tenant connection URI hardcodes `sslmode=disable`. If the PostgreSQL server supports TLS (which it should in a production cluster), tenant soju instances will connect without encryption. Consider making this configurable or defaulting to `sslmode=require`.\n\n### 3.2 Missing `Cargo.lock` in Containerfile Copy\n\n**File:** `/home/josie/development/personal/soju-operator/Containerfile`, line 3\n\n```dockerfile\nCOPY Cargo.toml Cargo.lock ./\n```\n\nThe `COPY` lists `Cargo.lock`, which is correct for a binary crate. Just confirming it exists in the repository -- if it is gitignored, the build will fail.\n\n### 3.3 No Liveness or Readiness Probes on Soju Container\n\n**File:** `/home/josie/development/personal/soju-operator/src/resources/deployment.rs`\n\nThe soju container has no health probes defined. Kubernetes will not be able to detect if the soju process crashes or hangs after startup. Consider adding a TCP socket probe on the IRC listener port.\n\n### 3.4 `last_transition_time` Is Always Overwritten\n\n**File:** `/home/josie/development/personal/soju-operator/src/controller.rs`, lines 263-274\n\nThe `update_status` function always sets `last_transition_time` to `now`, even if the condition status did not change. Per Kubernetes API conventions, `lastTransitionTime` should only update when the condition's `status` field actually transitions (e.g., from `True` to `False`). This makes it impossible to determine when the resource actually became ready or not-ready.\n\n**Recommendation:** Read the current status first and preserve the existing `lastTransitionTime` if the condition status has not changed.\n\n### 3.5 No `securityContext` on Soju Pod\n\nThe generated Deployment does not set `securityContext`. Consider adding `runAsNonRoot: true` and dropping all capabilities, especially since this runs on OpenShift where SCCs may enforce these anyway.\n\n### 3.6 Consider Watching Certificates as Owned Resources\n\n**File:** `/home/josie/development/personal/soju-operator/src/main.rs`, lines 47-51\n\nThe controller watches Deployments, ConfigMaps, Services, and Secrets as owned resources, but does not watch Certificates. If a Certificate is deleted externally, the controller will not be notified until the next periodic requeue (every 5 minutes, per line 183).\n\n---\n\n## 4. What Was Done Well\n\n- Clean module decomposition: types, resources (one file per resource kind), db, controller, main.\n- Consistent use of owner references across all resources, ensuring garbage collection works.\n- Server-side apply with force and a dedicated field manager throughout -- correct pattern for declarative reconciliation.\n- The finalizer pattern for database cleanup is correctly implemented with `Event::Apply` / `Event::Cleanup` split.\n- The `provision_tenant_db` function is idempotent -- it checks for role/database existence before creating.\n- The `deprovision_tenant_db` function correctly terminates connections before dropping the database.\n- Tests cover all resource builders with a shared `testutil::test_bouncer()` fixture.\n- The CRD schema includes printer columns, shortnames, and status subresource.\n- The `crdgen` binary is a nice touch for generating the CRD YAML from code.\n\n---\n\n## 5. Summary of Priorities\n\n| Priority | Issue | Location |\n|----------|-------|----------|\n| Critical | Password regeneration on parse failure breaks tenants | `controller.rs:238` |\n| Critical | Plaintext password in ConfigMap | `configmap.rs:43` |\n| Critical | Insecure password generation (non-CSPRNG) | `db.rs:45-54` |\n| Critical | SQL injection risk in password interpolation | `db.rs:13` |\n| Important | No status update on reconcile failure | `controller.rs` |\n| Important | New DB connection per reconcile (no pool) | `controller.rs:90` |\n| Important | Route always created even on non-OpenShift | `controller.rs:160` |\n| Important | Fragile URI password parser | `controller.rs:244` |\n| Important | No image tag on soju container | `deployment.rs:15` |\n| Important | Cluster-wide Secret access | `deploy/rbac.yaml` |\n\nThe most urgent fix is the password-regeneration bug in `get_or_create_password`. If a reconcile runs and the URI parser fails for any reason, the operator will silently corrupt the tenant's database credentials. The SQL injection and weak password generation should be addressed before production use."}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1093,"cache_read_input_tokens":40980,"output_tokens":4115,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":1093,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[],"speed":"standard"}},"requestId":"req_011CYgdE8XiKVgssgrgnikMJ","type":"assistant","uuid":"22487de8-fe8a-4210-b466-bb620b448ed1","timestamp":"2026-03-03T18:33:55.328Z"}
