refactor: run database encryption as an operational worker - #981
refactor: run database encryption as an operational worker#981think-in-universe wants to merge 28 commits into
Conversation
|
Companion operational deployment PR: https://github.com/nearai/cvm-ansible-playbooks/pull/536 |
Review · Status🟩 CompletedIronLoop completed the review and posted it to GitHub. ResultRun detailsAutomatic trigger · attempt 1 of 3 · completed in 29m 45s |
Review: operational database-encryption workerReviewed against The shape is right: moving a destructive backfill out of the always-on admin plane into a one-off, scoped, resumable worker with a fail-closed schema inventory is a clear improvement over #968. The inventory in Findings below, most severe first.
|
| if run().await.is_err() { | ||
| println!( | ||
| "DATABASE_ENCRYPTION_WORKER_RESULT {}", | ||
| serde_json::json!({"status":"failed","error_class":"worker_failed"}) | ||
| ); | ||
| std::process::exit(1); | ||
| } |
There was a problem hiding this comment.
When run().await returns an error, the actual error message, error chain, and context are completely discarded — only a generic {"status":"failed","error_class":"worker_failed"} is printed. For a one-off worker that touches encrypted production data, the operator needs to see the real error to diagnose failures. The actual anyhow::Error (including its context chain from .context() calls throughout operational_migrate, run_job, etc.) should be serialized or at minimum printed to stderr before exiting.
Suggestion:
| if run().await.is_err() { | |
| println!( | |
| "DATABASE_ENCRYPTION_WORKER_RESULT {}", | |
| serde_json::json!({"status":"failed","error_class":"worker_failed"}) | |
| ); | |
| std::process::exit(1); | |
| } | |
| if let Err(err) = run().await { | |
| eprintln!("{err:#}"); | |
| println!( | |
| "DATABASE_ENCRYPTION_WORKER_RESULT {}", | |
| serde_json::json!({"status":"failed","error_class":"worker_failed","error": format!("{err:#}")}) | |
| ); | |
| std::process::exit(1); | |
| } |
| Command::Migrate { | ||
| scope, | ||
| batch_size, | ||
| max_rows, | ||
| resume, | ||
| operator, | ||
| } => { | ||
| let id = | ||
| operational_migrate(&state, scope, batch_size, max_rows, resume, &operator).await?; | ||
| print_success(Some(id)); | ||
| } |
There was a problem hiding this comment.
When operational_migrate returns an error, the job ID is lost because operational_migrate returns Result<Uuid> and the ? operator discards the ID on failure. Tracing into database_encryption.rs, operational_migrate creates a job record (setting status to 'queued') and then calls run_job directly. run_job sets the job status to 'running' at the start of run_locked_job, but if it errors mid-batch, only the spawned (spawn_job) path marks the job as 'failed' — the worker path via operational_migrate does not. This leaves the job stuck in 'running' status indefinitely. The operator has no job ID in the worker output to use with --resume, requiring a manual database query to find and recover the stuck job. Consider having operational_migrate either mark the job as failed on error or include the job ID in the error context.
Suggestion:
| Command::Migrate { | |
| scope, | |
| batch_size, | |
| max_rows, | |
| resume, | |
| operator, | |
| } => { | |
| let id = | |
| operational_migrate(&state, scope, batch_size, max_rows, resume, &operator).await?; | |
| print_success(Some(id)); | |
| } | |
| Command::Migrate { | |
| scope, | |
| batch_size, | |
| max_rows, | |
| resume, | |
| operator, | |
| } => { | |
| let id = | |
| operational_migrate(&state, scope, batch_size, max_rows, resume, &operator) | |
| .await | |
| .map_err(|err| { | |
| eprintln!("migration failed; check database_encryption_jobs for stuck 'running' jobs: {err:#}"); | |
| err | |
| })?; | |
| print_success(Some(id)); | |
| } |
| fn read_encryption_key() -> Result<String> { | ||
| if let Ok(key) = std::env::var("S3_ENCRYPTION_KEY") { | ||
| return Ok(key); | ||
| } | ||
| let path = std::env::var("S3_ENCRYPTION_KEY_FILE") | ||
| .context("S3_ENCRYPTION_KEY or S3_ENCRYPTION_KEY_FILE is required")?; | ||
| std::fs::read_to_string(path) | ||
| .context("failed to read S3_ENCRYPTION_KEY_FILE") | ||
| .map(|key| key.trim().to_string()) | ||
| } |
There was a problem hiding this comment.
The key-source precedence here is inverted compared to the canonical implementation in config::S3Config::from_env() (crates/config/src/types.rs lines 969-985): the config layer checks S3_ENCRYPTION_KEY_FILE first and falls back to S3_ENCRYPTION_KEY, whereas this worker checks the env var first and only tries the file path on failure. If both variables are set in the same deployment environment (e.g., an env var from legacy config plus a mounted secret file), the main API process and this migration worker will resolve different keys. The worker would then encrypt production data with a key the API cannot decrypt, silently corrupting records. This worker should reuse the existing config::S3Config::from_env() key-loading logic (or at minimum match its FILE-before-ENV precedence and empty-key validation) to guarantee both processes agree on the same encryption key.
Suggestion:
| fn read_encryption_key() -> Result<String> { | |
| if let Ok(key) = std::env::var("S3_ENCRYPTION_KEY") { | |
| return Ok(key); | |
| } | |
| let path = std::env::var("S3_ENCRYPTION_KEY_FILE") | |
| .context("S3_ENCRYPTION_KEY or S3_ENCRYPTION_KEY_FILE is required")?; | |
| std::fs::read_to_string(path) | |
| .context("failed to read S3_ENCRYPTION_KEY_FILE") | |
| .map(|key| key.trim().to_string()) | |
| } | |
| fn read_encryption_key() -> Result<String> { | |
| // Match the precedence used by config::S3Config::from_env(): | |
| // file-based secret first, env var as fallback. | |
| if let Ok(path) = std::env::var("S3_ENCRYPTION_KEY_FILE") { | |
| let key = std::fs::read_to_string(path) | |
| .context("failed to read S3_ENCRYPTION_KEY_FILE")? | |
| .trim() | |
| .to_string(); | |
| if key.is_empty() { | |
| anyhow::bail!("S3 encryption key cannot be empty"); | |
| } | |
| return Ok(key); | |
| } | |
| let key = std::env::var("S3_ENCRYPTION_KEY") | |
| .context("S3_ENCRYPTION_KEY or S3_ENCRYPTION_KEY_FILE is required")?; | |
| if key.is_empty() { | |
| anyhow::bail!("S3 encryption key cannot be empty"); | |
| } | |
| Ok(key) | |
| } |
There was a problem hiding this comment.
Review · Summary
Found three actionable rollout and operational issues.
Findings: 🔴 High 1 · 🟠 Medium 2
Code-specific findings are attached to the diff.
Validation
- ✅ Worker build — The database-encryption worker compiled successfully.
- ✅ Inventory policy unit test — The sensitive policy examples passed.
Review details
- Run:
e709f645-88c9-40f5-9d6b-ffb611d9c9bf - Attempts: 1
| admin_actor UUID REFERENCES users(id), | ||
| operator TEXT NOT NULL DEFAULT 'admin-api', |
There was a problem hiding this comment.
🔴 High · Add a new migration for the worker schema
V0075 is already applied by the prerequisite change. Changing its contents changes its recorded checksum, so existing databases will abort migration startup; they also will not receive the nullable admin_actor or operator schema required by the worker. Keep V0075 immutable and add a new ALTER TABLE migration.
| } | ||
|
|
||
| fn read_encryption_key() -> Result<String> { | ||
| if let Ok(key) = std::env::var("S3_ENCRYPTION_KEY") { |
There was a problem hiding this comment.
🟠 Medium · Match the API's encryption-key source precedence
The API configuration prefers S3_ENCRYPTION_KEY_FILE, while this worker returns S3_ENCRYPTION_KEY first. When both are supplied with different values, the worker verifies with the wrong key and can write envelopes the API cannot decrypt. Prefer the file source consistently or reject conflicting configuration.
| let verification = operational_verify(state, verification_entries).await?; | ||
| if verification["pass"] != true { | ||
| anyhow::bail!("post-migration verification failed"); | ||
| } |
There was a problem hiding this comment.
🟠 Medium · Keep capped migrations resumable
If max_rows is smaller than the remaining plaintext rows, the job reaches its cap and is marked completed, but this full-scope verification then necessarily fails. Completed jobs are rejected by --resume, so bounded runs emit a failed worker result and cannot continue through the advertised resume flow. Defer full verification for partial work or preserve a resumable partial status.
Summary
This is intentionally stacked on #968 and retains its database-encryption naming and job engine. The companion deployment PR creates and tears down an ephemeral CVM to run the worker.
Validation
Depends on #968.