1use chrono::Duration;
13use std::path::Path;
14
15use crate::error::Result;
16use crate::plan::{PlanArtifact, StalenessCheck};
17use crate::state::StateStore;
18
19use super::chunked::ChunkSource;
20use super::summary::ApplyContext;
21
22pub fn run_apply_command(plan_file: &str, force: bool, parallel: bool, resume: bool) -> Result<()> {
24 if plan_file.ends_with(".yaml") || plan_file.ends_with(".yml") {
28 return super::run::run_waves(plan_file, force, parallel, resume);
29 }
30 if parallel || resume {
31 log::warn!(
32 "--parallel-export-processes / --resume apply only to wave-ordered config execution; ignored for a sealed plan artifact"
33 );
34 }
35
36 let artifact = PlanArtifact::from_file(plan_file)?;
38
39 artifact.verify_integrity()?;
45
46 reject_unrecoverable_inline_url(&artifact)?;
52
53 let mut force_bypassed: Vec<String> = Vec::new();
57
58 let warn_threshold = Duration::hours(1);
60 let error_threshold = Duration::hours(24);
61 match artifact.staleness(warn_threshold, error_threshold) {
62 StalenessCheck::Fresh => {}
63 StalenessCheck::StaleWarn(age) => {
64 log::warn!(
65 "plan '{}' is {} minutes old — consider regenerating with `rivet plan`",
66 artifact.export_name,
67 age.num_minutes()
68 );
69 }
70 StalenessCheck::StaleError(age) => {
71 let age_phrase = if age.num_hours() >= 48 {
76 format!(
77 "{} days old (created {})",
78 age.num_days(),
79 artifact.created_at.format("%Y-%m-%d")
80 )
81 } else {
82 format!("{} hours old", age.num_hours())
83 };
84 if !force {
85 anyhow::bail!(
86 "plan '{}' is {} (limit: 24 h). Regenerate with `rivet plan` or pass --force to override.",
87 artifact.export_name,
88 age_phrase,
89 );
90 }
91 force_bypassed.push("staleness".into());
92 log::warn!(
93 "plan '{}': ignoring staleness ({}) because --force was passed",
94 artifact.export_name,
95 age_phrase,
96 );
97 }
98 }
99
100 let plan_dir = Path::new(plan_file)
114 .parent()
115 .unwrap_or_else(|| Path::new("."));
116 let state_dir = match artifact
117 .config_path
118 .as_deref()
119 .map(Path::new)
120 .and_then(Path::parent)
121 {
122 Some(dir) if dir.exists() => dir.to_path_buf(),
123 Some(dir) => {
124 log::warn!(
125 "plan '{}': original config dir '{}' no longer exists; opening state next to plan file instead. \
126 Cursors and manifest history from the original run will not be visible.",
127 artifact.export_name,
128 dir.display(),
129 );
130 plan_dir.to_path_buf()
131 }
132 None => {
133 log::warn!(
134 "plan '{}': artifact has no recorded config path (pre-0.7.5 plan?). \
135 Opening state next to the plan file; this may diverge from the state \
136 used by `rivet run` for the same config.",
137 artifact.export_name,
138 );
139 plan_dir.to_path_buf()
140 }
141 };
142 let state_path = state_dir.join(".rivet_state.db");
143 let state = StateStore::open(state_path.to_str().unwrap_or(".rivet_state.db"))?;
144
145 if artifact.computed.cursor_snapshot.is_some() {
153 let current = state.get(&artifact.export_name)?.last_cursor_value;
154 if !artifact.cursor_matches(current.as_deref()) {
155 if !force {
156 anyhow::bail!(
157 "plan '{}': cursor has drifted since plan was generated \
158 (plan snapshot: {:?}, current: {:?}). \
159 Regenerate with `rivet plan` or pass --force to skip this check.",
160 artifact.export_name,
161 artifact.computed.cursor_snapshot,
162 current,
163 );
164 }
165 force_bypassed.push("cursor_drift".into());
166 log::warn!(
167 "plan '{}': cursor has drifted (plan snapshot: {:?}, current: {:?}) — \
168 proceeding because --force was passed",
169 artifact.export_name,
170 artifact.computed.cursor_snapshot,
171 current,
172 );
173 }
174 }
175
176 let chunk_source = if artifact.computed.chunk_ranges.is_empty() {
178 ChunkSource::Detect
179 } else {
180 ChunkSource::Precomputed(artifact.computed.chunk_ranges.clone())
181 };
182
183 let apply_context = ApplyContext {
186 plan_id: artifact.plan_id.clone(),
187 forced: force,
188 force_bypassed,
189 };
190 let plan = artifact.resolved_plan.clone();
191 super::run_export_job_with_chunk_source(
192 &plan,
193 &state,
194 chunk_source,
195 plan_file,
196 Some(apply_context),
197 )
198}
199
200fn reject_unrecoverable_inline_url(artifact: &PlanArtifact) -> Result<()> {
211 let source = &artifact.resolved_plan.source;
212
213 let url_redacted = source
214 .url
215 .as_deref()
216 .is_some_and(|u| u.contains("REDACTED@"));
217 if !url_redacted {
218 return Ok(());
219 }
220
221 let has_recovery = source.url_env.is_some()
225 || source.url_file.is_some()
226 || source.password_env.is_some()
227 || (source.host.is_some() && source.user.is_some());
228 if has_recovery {
229 return Ok(());
230 }
231
232 anyhow::bail!(
233 "plan '{}': source credentials were stripped from this artifact and cannot be \
234 recovered at apply time — the plan was created from an inline `url:` config, whose \
235 password is never persisted.\n \
236 Fix: re-plan from a config that uses `url_env: <VAR>` (or `url_file:`) so `rivet apply` \
237 can resolve credentials, e.g.\n \
238 source:\n type: {}\n url_env: DATABASE_URL\n \
239 then `export DATABASE_URL=...` before running apply.",
240 artifact.export_name,
241 match artifact.resolved_plan.source.source_type {
242 crate::config::SourceType::Postgres => "postgres",
243 crate::config::SourceType::Mysql => "mysql",
244 crate::config::SourceType::Mssql => "mssql",
245 crate::config::SourceType::Mongo => "mongo",
246 },
247 )
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use crate::config::{
254 CompressionType, DestinationConfig, DestinationType, FormatType, MetaColumns, SourceConfig,
255 SourceType,
256 };
257 use crate::plan::{
258 ComputedPlanData, ExtractionStrategy, PlanArtifact, PlanDiagnostics, ResolvedRunPlan,
259 };
260 use crate::tuning::SourceTuning;
261 use chrono::{Duration, Utc};
262
263 fn unreachable_plan() -> ResolvedRunPlan {
264 ResolvedRunPlan {
265 export_name: "orders".into(),
266 base_query: "SELECT 1".into(),
267 strategy: ExtractionStrategy::Snapshot,
268 format: FormatType::Parquet,
269 compression: CompressionType::Zstd,
270 compression_level: None,
271 max_file_size_bytes: None,
272 skip_empty: false,
273 meta_columns: MetaColumns::default(),
274 destination: DestinationConfig {
275 destination_type: DestinationType::Local,
276 path: Some("/tmp/rivet_apply_test".into()),
277 ..Default::default()
278 },
279 quality: None,
280 tuning: SourceTuning::from_config(None),
281 tuning_profile_label: "balanced".into(),
282 validate: false,
283 reconcile: false,
284 resume: false,
285 source: SourceConfig {
293 source_type: SourceType::Postgres,
294 url: Some("postgresql://127.0.0.2:9999/nonexistent".into()),
295 url_env: None,
296 url_file: None,
297 host: None,
298 port: None,
299 user: None,
300 password: None,
301 password_env: None,
302 database: None,
303 environment: None,
304 tuning: None,
305 tls: None,
306 mongo: None,
307 },
308 column_overrides: Default::default(),
309 verify: crate::config::VerifyMode::Size,
310 schema_drift_policy: Default::default(),
311 shape_drift_warn_factor: 0.0,
312 parquet: None,
313 }
314 }
315
316 fn fresh_artifact() -> PlanArtifact {
317 PlanArtifact::new(
318 "orders".into(),
319 "full".into(),
320 String::new(),
321 unreachable_plan(),
322 ComputedPlanData {
323 chunk_ranges: vec![],
324 chunk_count: 0,
325 cursor_snapshot: None,
326 row_estimate: None,
327 },
328 PlanDiagnostics {
329 verdict: "Efficient".into(),
330 warnings: vec![],
331 recommended_profile: "balanced".into(),
332 strategy_rationale: String::new(),
333 },
334 )
335 }
336
337 fn write_artifact(dir: &tempfile::TempDir, artifact: &PlanArtifact) -> String {
338 let path = dir.path().join("plan.json");
339 let json = artifact.to_json_pretty().expect("serialize");
340 std::fs::write(&path, json).expect("write plan.json");
341 path.to_str().unwrap().to_string()
342 }
343
344 #[test]
349 fn redacted_url_gate_requires_a_recovery_path() {
350 let set = |url: Option<&str>,
351 url_env: Option<&str>,
352 password_env: Option<&str>,
353 host: Option<&str>,
354 user: Option<&str>| {
355 let mut a = fresh_artifact();
356 let s = &mut a.resolved_plan.source;
357 s.url = url.map(str::to_string);
358 s.url_env = url_env.map(str::to_string);
359 s.url_file = None;
360 s.password_env = password_env.map(str::to_string);
361 s.host = host.map(str::to_string);
362 s.user = user.map(str::to_string);
363 a
364 };
365 let redacted = Some("postgresql://REDACTED@h/db");
366
367 reject_unrecoverable_inline_url(&set(
369 Some("postgresql://u:p@h/db"),
370 None,
371 None,
372 None,
373 None,
374 ))
375 .expect("plain url is appliable");
376 let err = reject_unrecoverable_inline_url(&set(redacted, None, None, None, None))
378 .expect_err("stripped credentials with no recovery must refuse");
379 assert!(err.to_string().contains("cannot be"), "actionable: {err:#}");
380 reject_unrecoverable_inline_url(&set(redacted, Some("DB_URL"), None, None, None))
382 .expect("url_env recovers");
383 reject_unrecoverable_inline_url(&set(redacted, None, Some("PGPASS"), None, None))
384 .expect("password_env recovers");
385 reject_unrecoverable_inline_url(&set(redacted, None, None, Some("h"), Some("u")))
386 .expect("host+user recovers");
387 reject_unrecoverable_inline_url(&set(redacted, None, None, Some("h"), None))
389 .expect_err("host without user cannot rebuild the connection");
390 }
391
392 #[test]
395 fn stale_error_without_force_is_rejected() {
396 let mut artifact = fresh_artifact();
397 artifact.created_at = Utc::now() - Duration::hours(25);
398 let dir = tempfile::TempDir::new().unwrap();
399 let path = write_artifact(&dir, &artifact);
400
401 let err = run_apply_command(&path, false, false, false).unwrap_err();
402 let msg = format!("{err:#}");
403 assert!(
404 msg.contains("hours old") || msg.contains("24 h"),
405 "expected staleness error: {msg}"
406 );
407 }
408
409 #[test]
412 fn cursor_drift_detected_no_prior_state() {
413 let mut artifact = fresh_artifact();
414 artifact.computed.cursor_snapshot = Some("2025-06-01T00:00:00Z".into());
416 let dir = tempfile::TempDir::new().unwrap();
417 let path = write_artifact(&dir, &artifact);
418
419 let err = run_apply_command(&path, false, false, false).unwrap_err();
420 let msg = format!("{err:#}");
421 assert!(
422 msg.contains("drifted") || msg.contains("cursor"),
423 "expected cursor drift error: {msg}"
424 );
425 }
426
427 #[test]
430 fn missing_plan_file_returns_error() {
431 let err = run_apply_command("/tmp/rivet_nonexistent_xyzxyz.json", false, false, false)
432 .unwrap_err();
433 let msg = format!("{err:#}");
434 assert!(
435 msg.contains("cannot read") || msg.contains("No such file"),
436 "expected file-not-found: {msg}"
437 );
438 }
439
440 #[test]
441 fn corrupt_plan_file_returns_parse_error() {
442 let dir = tempfile::TempDir::new().unwrap();
443 let path = dir.path().join("plan.json");
444 std::fs::write(&path, b"not valid json at all").unwrap();
445 let err = run_apply_command(path.to_str().unwrap(), false, false, false).unwrap_err();
446 let msg = format!("{err:#}");
447 assert!(
448 msg.contains("invalid plan") || msg.contains("JSON") || msg.contains("expected"),
449 "expected parse error: {msg}"
450 );
451 }
452
453 #[test]
460 fn apply_rejects_tampered_base_query() {
461 let artifact = fresh_artifact();
462 let dir = tempfile::TempDir::new().unwrap();
463 let path = write_artifact(&dir, &artifact);
464
465 let json = std::fs::read_to_string(&path).unwrap();
469 assert!(
470 json.contains("SELECT 1"),
471 "fixture must embed the planned base_query"
472 );
473 let tampered = json.replace("SELECT 1", "SELECT * FROM secrets");
474 std::fs::write(&path, &tampered).unwrap();
475
476 let err = run_apply_command(&path, false, false, false).unwrap_err();
477 let msg = format!("{err:#}");
478 assert!(
479 msg.contains("integrity check failed") && msg.contains("modified after planning"),
480 "tampered plan must be rejected at the integrity gate, got: {msg}"
481 );
482
483 let err_forced = run_apply_command(&path, true, false, false).unwrap_err();
485 let msg_forced = format!("{err_forced:#}");
486 assert!(
487 msg_forced.contains("integrity check failed"),
488 "--force must not bypass the integrity gate, got: {msg_forced}"
489 );
490 }
491
492 }