1use crate::colors;
2use crate::config::{CURRENT_RUNTIME_SCHEMA_VERSION, Config};
3use anyhow::{Context, Result, bail};
4use std::collections::BTreeSet;
5use std::future::Future;
6use std::path::{Path, PathBuf};
7use std::pin::Pin;
8
9const UPDATE_CACHE_FILE: &str = "update-check.json";
10
11pub async fn handle_migrate(config: &Config, dry_run: bool) -> Result<()> {
12 let schema_version = config.schema_version;
13 if schema_version > CURRENT_RUNTIME_SCHEMA_VERSION {
14 bail!(
15 "runtime schema {} is newer than this shine supports ({})",
16 schema_version,
17 CURRENT_RUNTIME_SCHEMA_VERSION
18 );
19 }
20
21 let steps = pending_steps(schema_version);
22 if steps.is_empty() {
23 let mut migrated_local_state = false;
24 for path in config_migration_paths(config) {
25 if config_needs_migration(&path).await? {
26 if dry_run {
27 println!(
28 "[dry-run] migrate GPG recipient configuration in {}",
29 path.display()
30 );
31 } else {
32 migrate_config_gpg_recipient(&path).await?;
33 println!("Migrated GPG recipient configuration in {}", path.display());
34 }
35 migrated_local_state = true;
36 }
37 }
38 if let Some(path) = find_workspace_from_current_dir()
39 && workspace_needs_migration(&path).await?
40 {
41 if dry_run {
42 println!("[dry-run] migrate workspace format in {}", path.display());
43 } else {
44 migrate_workspace_gpg_recipient(&path).await?;
45 println!("Migrated workspace format in {}", path.display());
46 }
47 migrated_local_state = true;
48 }
49 if migrated_local_state {
50 return Ok(());
51 }
52 if !dry_run && config.last_cleared_schema_version != Some(CURRENT_RUNTIME_SCHEMA_VERSION) {
53 let mut updated = config.clone();
54 updated.last_cleared_schema_version = Some(CURRENT_RUNTIME_SCHEMA_VERSION);
55 updated.save().await?;
56 }
57 println!(
58 "{}",
59 colors::green(&format!(
60 "Runtime config schema is already current ({CURRENT_RUNTIME_SCHEMA_VERSION})."
61 ))
62 );
63 return Ok(());
64 }
65
66 println!("{}", colors::bold("Migrating old runtime state"));
67 crate::config::print_presets_note(config);
68
69 for step in &steps {
70 println!("{}", colors::dim(&format!("schema {}", step.to_version)));
71 for action in actions_for_step(config, step) {
72 if dry_run {
73 println!(" [dry-run] {}", action.description);
74 } else {
75 println!(" {}", action.description);
76 (action.apply).await?;
77 }
78 }
79 }
80
81 if dry_run {
82 println!();
83 println!(
84 "{}",
85 colors::dim("Dry run only. Run `shine state migrate` to apply these changes.")
86 );
87 return Ok(());
88 }
89
90 let mut updated = config.clone();
91 if let Some(recipients) = gpg_recipients_from_config(config.config_path()).await? {
92 updated.gpg_recipients = recipients;
93 }
94 updated.legacy_gpg_key_id = None;
95 updated.schema_version = CURRENT_RUNTIME_SCHEMA_VERSION;
96 updated.last_cleared_schema_version = Some(CURRENT_RUNTIME_SCHEMA_VERSION);
97 updated.save().await?;
98
99 println!();
100 println!(
101 "{}",
102 colors::green(&format!(
103 "Runtime config schema is now {CURRENT_RUNTIME_SCHEMA_VERSION}."
104 ))
105 );
106 Ok(())
107}
108
109pub fn pending_schema_warning(schema_version: u32) -> Option<String> {
110 if schema_version >= CURRENT_RUNTIME_SCHEMA_VERSION {
111 return None;
112 }
113
114 Some(format!(
115 "Runtime config schema is behind: {schema_version} -> {CURRENT_RUNTIME_SCHEMA_VERSION}. Run `shine state migrate --dry-run` to inspect cleanup, then `shine state migrate`."
116 ))
117}
118
119struct CleanupStep {
120 to_version: u32,
121}
122
123struct CleanupAction<'a> {
124 description: String,
125 apply: Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>,
126}
127
128fn pending_steps(schema_version: u32) -> Vec<CleanupStep> {
129 ((schema_version + 1)..=CURRENT_RUNTIME_SCHEMA_VERSION)
130 .map(|to_version| CleanupStep { to_version })
131 .collect()
132}
133
134fn actions_for_step<'a>(config: &'a Config, step: &CleanupStep) -> Vec<CleanupAction<'a>> {
135 match step.to_version {
136 1 => {
137 let path = config.shine_dir().join(UPDATE_CACHE_FILE);
138 vec![CleanupAction {
139 description: format!("remove stale update cache {}", path.display()),
140 apply: Box::pin(async move {
141 match tokio::fs::remove_file(&path).await {
142 Ok(()) => Ok(()),
143 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
144 Err(e) => Err(e).with_context(|| format!("removing {}", path.display())),
145 }
146 }),
147 }]
148 }
149 2 => migration_actions(config),
150 _ => Vec::new(),
151 }
152}
153
154fn migration_actions<'a>(config: &'a Config) -> Vec<CleanupAction<'a>> {
155 let mut actions = Vec::new();
156 for path in config_migration_paths(config) {
157 let description = format!("migrate GPG recipient configuration in {}", path.display());
158 actions.push(CleanupAction {
159 description,
160 apply: Box::pin(async move { migrate_config_gpg_recipient(&path).await }),
161 });
162 }
163 if let Some(path) = find_workspace_from_current_dir() {
164 let description = format!(
165 "migrate GPG workspace recipient configuration in {}",
166 path.display()
167 );
168 actions.push(CleanupAction {
169 description,
170 apply: Box::pin(
171 async move { migrate_workspace_gpg_recipient(&path).await.map(|_| ()) },
172 ),
173 });
174 }
175 actions
176}
177
178fn config_migration_paths(config: &Config) -> Vec<PathBuf> {
179 let mut paths = BTreeSet::from([config.config_path().to_path_buf()]);
180 if let Ok(current_dir) = std::env::current_dir()
181 && let Some(project) = crate::config::find_project_config(¤t_dir)
182 {
183 paths.insert(project.path);
184 }
185 paths.into_iter().filter(|path| path.is_file()).collect()
186}
187
188fn find_workspace_from_current_dir() -> Option<PathBuf> {
189 let current_dir = std::env::current_dir().ok()?;
190 current_dir
191 .ancestors()
192 .map(|dir| dir.join("shine.workspace.toml"))
193 .find(|path| path.is_file())
194}
195
196async fn migrate_config_gpg_recipient(path: &Path) -> Result<()> {
197 migrate_recipient_key(path, |document| {
198 migrate_key(document, "gpg_key_id", "gpg_recipients")
199 })
200 .await
201 .map(|_| ())
202}
203
204async fn migrate_workspace_gpg_recipient(path: &Path) -> Result<bool> {
205 migrate_recipient_key(path, |document| {
206 let version = document
207 .as_table()
208 .get("version")
209 .and_then(toml_edit::Item::as_integer)
210 .unwrap_or(1);
211 if version > 2 {
212 bail!("workspace version {version} is newer than this shine supports (2)");
213 }
214 let mut changed = false;
215 let encryption = document["env"]["encryption"].as_table_mut();
216 if let Some(table) = encryption {
217 changed |= migrate_table_key(table, "recipient", "gpg_recipients")?;
218 }
219 if version < 2 {
220 document["version"] = toml_edit::value(2);
221 changed = true;
222 }
223 Ok(changed)
224 })
225 .await
226}
227
228async fn migrate_recipient_key(
229 path: &Path,
230 mutate: impl FnOnce(&mut toml_edit::DocumentMut) -> Result<bool>,
231) -> Result<bool> {
232 let contents = tokio::fs::read_to_string(path)
233 .await
234 .with_context(|| format!("reading {}", path.display()))?;
235 let mut document = contents
236 .parse::<toml_edit::DocumentMut>()
237 .with_context(|| format!("parsing {}", path.display()))?;
238 if mutate(&mut document)? {
239 crate::persist::atomic_write(path, document.to_string().as_bytes())
240 .await
241 .with_context(|| format!("writing {}", path.display()))?;
242 return Ok(true);
243 }
244 Ok(false)
245}
246
247async fn workspace_needs_migration(path: &Path) -> Result<bool> {
248 let contents = tokio::fs::read_to_string(path)
249 .await
250 .with_context(|| format!("reading {}", path.display()))?;
251 let document = contents
252 .parse::<toml_edit::DocumentMut>()
253 .with_context(|| format!("parsing {}", path.display()))?;
254 Ok(document
255 .as_table()
256 .get("version")
257 .and_then(toml_edit::Item::as_integer)
258 .unwrap_or(1)
259 < 2
260 || document["env"]["encryption"]["recipient"].is_value())
261}
262
263async fn config_needs_migration(path: &Path) -> Result<bool> {
264 let contents = tokio::fs::read_to_string(path)
265 .await
266 .with_context(|| format!("reading {}", path.display()))?;
267 let document = contents
268 .parse::<toml_edit::DocumentMut>()
269 .with_context(|| format!("parsing {}", path.display()))?;
270 Ok(document.as_table().contains_key("gpg_key_id"))
271}
272
273fn migrate_key(document: &mut toml_edit::DocumentMut, old: &str, new: &str) -> Result<bool> {
274 migrate_table_key(document.as_table_mut(), old, new)
275}
276
277fn migrate_table_key(table: &mut toml_edit::Table, old: &str, new: &str) -> Result<bool> {
278 let Some(old_item) = table.get(old) else {
279 return Ok(false);
280 };
281 if table.contains_key(new) {
282 bail!("configuration contains both {old} and {new}; resolve the conflict before migrating");
283 }
284 let recipient = old_item
285 .as_str()
286 .context("legacy GPG recipient must be a string")?
287 .to_owned();
288 let key_decor = table.key(old).map(|key| key.leaf_decor().clone());
289 let decor = old_item.as_value().map(|value| value.decor().clone());
290 table.remove(old);
291 let mut recipients = toml_edit::Array::new();
292 recipients.push(recipient);
293 table.insert(
294 new,
295 toml_edit::Item::Value(toml_edit::Value::Array(recipients)),
296 );
297 if let (Some(decor), Some(value)) = (
298 decor,
299 table.get_mut(new).and_then(toml_edit::Item::as_value_mut),
300 ) {
301 *value.decor_mut() = decor;
302 }
303 if let (Some(decor), Some(mut key)) = (key_decor, table.key_mut(new)) {
304 *key.leaf_decor_mut() = decor;
305 }
306 Ok(true)
307}
308
309async fn gpg_recipients_from_config(path: &Path) -> Result<Option<Vec<String>>> {
310 let contents = match tokio::fs::read_to_string(path).await {
311 Ok(contents) => contents,
312 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
313 Err(error) => return Err(error).with_context(|| format!("reading {}", path.display())),
314 };
315 let table: toml::Table =
316 toml::from_str(&contents).with_context(|| format!("parsing {}", path.display()))?;
317 let Some(value) = table.get("gpg_recipients") else {
318 return Ok(None);
319 };
320 let recipients = value
321 .as_array()
322 .context("gpg_recipients must be an array")?
323 .iter()
324 .map(|value| {
325 value
326 .as_str()
327 .context("gpg_recipients entries must be strings")
328 })
329 .collect::<Result<Vec<_>>>()?
330 .into_iter()
331 .map(str::to_owned)
332 .collect();
333 Ok(Some(recipients))
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339 use tokio::fs;
340
341 async fn make_temp_dir() -> std::path::PathBuf {
342 crate::test_support::make_temp_dir("shine-state-migrate").await
343 }
344
345 #[test]
346 fn pending_schema_warning_reports_old_schema() {
347 let warning = pending_schema_warning(0).unwrap();
348 assert!(warning.contains("0 -> 2"));
349 assert!(pending_schema_warning(CURRENT_RUNTIME_SCHEMA_VERSION).is_none());
350 }
351
352 #[tokio::test]
353 async fn migrate_removes_update_cache_and_records_schema() {
354 let dir = make_temp_dir().await;
355 let mut config = Config::new_for_test(&dir);
356 config.schema_version = 0;
357 fs::write(dir.join(UPDATE_CACHE_FILE), b"stale")
358 .await
359 .unwrap();
360
361 handle_migrate(&config, false).await.unwrap();
362
363 assert!(!dir.join(UPDATE_CACHE_FILE).exists());
364 let content = fs::read_to_string(dir.join("config.toml")).await.unwrap();
365 let parsed: toml::Table = toml::from_str(&content).unwrap();
366 assert_eq!(
367 parsed["schema_version"].as_integer(),
368 Some(CURRENT_RUNTIME_SCHEMA_VERSION.into())
369 );
370 assert_eq!(
371 parsed["last_cleared_schema_version"].as_integer(),
372 Some(CURRENT_RUNTIME_SCHEMA_VERSION.into())
373 );
374
375 fs::remove_dir_all(&dir).await.unwrap();
376 }
377
378 #[tokio::test]
379 async fn migrate_converts_legacy_gpg_recipient_to_a_list() {
380 let dir = make_temp_dir().await;
381 let mut config = Config::new_for_test(&dir);
382 config.schema_version = 1;
383 fs::write(
384 config.config_path(),
385 "# team encryption key\ngpg_key_id = \"alice@example.com\"\n",
386 )
387 .await
388 .unwrap();
389
390 handle_migrate(&config, false).await.unwrap();
391
392 let content = fs::read_to_string(config.config_path()).await.unwrap();
393 assert!(!content.contains("gpg_key_id"));
394 assert!(content.contains("# team encryption key"));
395 let parsed: toml::Table = toml::from_str(&content).unwrap();
396 assert_eq!(
397 parsed["gpg_recipients"].as_array().unwrap(),
398 &[toml::Value::String("alice@example.com".to_string())]
399 );
400 assert_eq!(
401 parsed["schema_version"].as_integer(),
402 Some(CURRENT_RUNTIME_SCHEMA_VERSION.into())
403 );
404
405 fs::remove_dir_all(&dir).await.unwrap();
406 }
407
408 #[tokio::test]
409 async fn workspace_migration_converts_legacy_recipient() {
410 let dir = make_temp_dir().await;
411 let workspace = dir.join("shine.workspace.toml");
412 fs::write(
413 &workspace,
414 "[env.encryption]\n# deployment key\nrecipient = \"alice@example.com\"\n",
415 )
416 .await
417 .unwrap();
418
419 migrate_workspace_gpg_recipient(&workspace).await.unwrap();
420
421 let content = fs::read_to_string(&workspace).await.unwrap();
422 assert!(!content.contains("recipient ="));
423 assert!(content.contains("# deployment key"));
424 assert!(content.contains("gpg_recipients = [\"alice@example.com\"]"));
425 assert!(content.contains("version = 2"));
426 fs::remove_dir_all(&dir).await.unwrap();
427 }
428
429 #[tokio::test]
430 async fn dry_run_does_not_remove_or_save() {
431 let dir = make_temp_dir().await;
432 let mut config = Config::new_for_test(&dir);
433 config.schema_version = 0;
434 fs::write(dir.join(UPDATE_CACHE_FILE), b"stale")
435 .await
436 .unwrap();
437
438 handle_migrate(&config, true).await.unwrap();
439
440 assert!(dir.join(UPDATE_CACHE_FILE).exists());
441 assert!(!dir.join("config.toml").exists());
442
443 fs::remove_dir_all(&dir).await.unwrap();
444 }
445
446 #[tokio::test]
447 async fn migrate_records_last_cleared_when_already_current() {
448 let dir = make_temp_dir().await;
449 let config = Config::new_for_test(&dir);
450
451 handle_migrate(&config, false).await.unwrap();
452
453 let content = fs::read_to_string(dir.join("config.toml")).await.unwrap();
454 let parsed: toml::Table = toml::from_str(&content).unwrap();
455 assert_eq!(
456 parsed["schema_version"].as_integer(),
457 Some(CURRENT_RUNTIME_SCHEMA_VERSION.into())
458 );
459 assert_eq!(
460 parsed["last_cleared_schema_version"].as_integer(),
461 Some(CURRENT_RUNTIME_SCHEMA_VERSION.into())
462 );
463
464 fs::remove_dir_all(&dir).await.unwrap();
465 }
466
467 #[tokio::test]
468 #[allow(clippy::await_holding_lock)]
469 async fn migrate_current_schema_converts_legacy_project_config() {
470 let _lock = crate::test_support::env_lock();
471 let original_dir = std::env::current_dir().unwrap();
472 let dir = make_temp_dir().await;
473 let project_dir = dir.join("project");
474 fs::create_dir_all(&project_dir).await.unwrap();
475 let project_config = project_dir.join("shine.config.toml");
476 fs::write(
477 &project_config,
478 "# project key\ngpg_key_id = \"project@example.com\"\n",
479 )
480 .await
481 .unwrap();
482 std::env::set_current_dir(&project_dir).unwrap();
483 let config = Config::new_for_test(&dir);
484
485 let result = handle_migrate(&config, false).await;
486 crate::test_support::restore_current_dir(&original_dir);
487 result.unwrap();
488
489 let content = fs::read_to_string(&project_config).await.unwrap();
490 assert!(!content.contains("gpg_key_id"));
491 assert!(content.contains("# project key"));
492 assert!(content.contains("gpg_recipients = [\"project@example.com\"]"));
493
494 fs::remove_dir_all(&dir).await.unwrap();
495 }
496
497 #[tokio::test]
498 async fn migrate_does_not_remove_other_runtime_files() {
499 let dir = make_temp_dir().await;
500 let mut config = Config::new_for_test(&dir);
501 config.schema_version = 0;
502 fs::create_dir_all(dir.join("rendered")).await.unwrap();
503 fs::create_dir_all(dir.join("bin")).await.unwrap();
504 fs::create_dir_all(dir.join("presets")).await.unwrap();
505 fs::write(dir.join("app-manifest.toml"), b"entries = []")
506 .await
507 .unwrap();
508
509 handle_migrate(&config, false).await.unwrap();
510
511 assert!(dir.join("rendered").exists());
512 assert!(dir.join("bin").exists());
513 assert!(dir.join("presets").exists());
514 assert!(dir.join("app-manifest.toml").exists());
515
516 fs::remove_dir_all(&dir).await.unwrap();
517 }
518}