canic-cli 0.35.11

Operator CLI for Canic fleet backup and restore workflows
Documentation
use super::*;
use crate::test_support::temp_dir;
use canic_backup::{persistence::BackupLayout, restore::RestorePlan};
use serde_json::json;
use std::{ffi::OsString, fs};

// Ensure backup-dir restore planning reads the canonical layout manifest.
#[test]
fn plan_restore_reads_manifest_from_backup_dir() {
    let root = temp_dir("canic-cli-restore-plan-layout");
    let layout = BackupLayout::new(root.clone());
    layout
        .write_manifest(&valid_manifest())
        .expect("write manifest");

    let options = RestorePlanOptions {
        manifest: None,
        backup_dir: Some(root.clone()),
        mapping: None,
        out: None,
        require_verified: false,
        require_restore_ready: false,
    };

    let plan = plan_restore(&options).expect("plan restore");

    fs::remove_dir_all(root).expect("remove temp root");
    assert_eq!(plan.backup_id, "backup-test");
    assert_eq!(plan.member_count, 2);
}

// Ensure restore planning has exactly one manifest source.
#[test]
fn parse_rejects_conflicting_manifest_sources() {
    let err = RestorePlanOptions::parse([
        OsString::from("--manifest"),
        OsString::from("manifest.json"),
        OsString::from("--backup-dir"),
        OsString::from("backups/run"),
    ])
    .expect_err("conflicting sources should fail");

    assert!(matches!(err, RestoreCommandError::Usage(_)));
}

// Ensure verified planning requires the canonical backup layout source.
#[test]
fn parse_rejects_require_verified_with_manifest_source() {
    let err = RestorePlanOptions::parse([
        OsString::from("--manifest"),
        OsString::from("manifest.json"),
        OsString::from("--require-verified"),
    ])
    .expect_err("verification should require a backup layout");

    assert!(matches!(err, RestoreCommandError::Usage(_)));
}

// Ensure restore planning can require manifest, journal, and artifact integrity.
#[test]
fn plan_restore_requires_verified_backup_layout() {
    let root = temp_dir("canic-cli-restore-plan-verified");
    let layout = BackupLayout::new(root.clone());
    let manifest = valid_manifest();
    write_verified_layout(&root, &layout, &manifest);

    let options = RestorePlanOptions {
        manifest: None,
        backup_dir: Some(root.clone()),
        mapping: None,
        out: None,
        require_verified: true,
        require_restore_ready: false,
    };

    let plan = plan_restore(&options).expect("plan verified restore");

    fs::remove_dir_all(root).expect("remove temp root");
    assert_eq!(plan.backup_id, "backup-test");
    assert_eq!(plan.member_count, 2);
}

// Ensure required verification fails before planning when the layout is incomplete.
#[test]
fn plan_restore_rejects_unverified_backup_layout() {
    let root = temp_dir("canic-cli-restore-plan-unverified");
    let layout = BackupLayout::new(root.clone());
    layout
        .write_manifest(&valid_manifest())
        .expect("write manifest");

    let options = RestorePlanOptions {
        manifest: None,
        backup_dir: Some(root.clone()),
        mapping: None,
        out: None,
        require_verified: true,
        require_restore_ready: false,
    };

    let err = plan_restore(&options).expect_err("missing journal should fail");

    fs::remove_dir_all(root).expect("remove temp root");
    assert!(matches!(err, RestoreCommandError::Persistence(_)));
}

// Ensure the CLI planning path validates manifests and applies mappings.
#[test]
fn plan_restore_reads_manifest_and_mapping() {
    let root = temp_dir("canic-cli-restore-plan");
    fs::create_dir_all(&root).expect("create temp root");
    let manifest_path = root.join("manifest.json");
    let mapping_path = root.join("mapping.json");

    fs::write(
        &manifest_path,
        serde_json::to_vec(&valid_manifest()).expect("serialize manifest"),
    )
    .expect("write manifest");
    fs::write(
        &mapping_path,
        json!({
            "members": [
                {"source_canister": ROOT, "target_canister": ROOT},
                {"source_canister": CHILD, "target_canister": MAPPED_CHILD}
            ]
        })
        .to_string(),
    )
    .expect("write mapping");

    let options = RestorePlanOptions {
        manifest: Some(manifest_path),
        backup_dir: None,
        mapping: Some(mapping_path),
        out: None,
        require_verified: false,
        require_restore_ready: false,
    };

    let plan = plan_restore(&options).expect("plan restore");

    fs::remove_dir_all(root).expect("remove temp root");
    let members = plan.ordered_members();
    assert_eq!(members.len(), 2);
    assert_eq!(members[0].source_canister, ROOT);
    assert_eq!(members[1].target_canister, MAPPED_CHILD);
}

// Ensure restore-readiness gating happens after writing the plan artifact.
#[test]
fn run_restore_plan_require_restore_ready_writes_plan_then_fails() {
    let root = temp_dir("canic-cli-restore-plan-require-ready");
    fs::create_dir_all(&root).expect("create temp root");
    let manifest_path = root.join("manifest.json");
    let out_path = root.join("plan.json");

    fs::write(
        &manifest_path,
        serde_json::to_vec(&valid_manifest()).expect("serialize manifest"),
    )
    .expect("write manifest");

    let err = run([
        OsString::from("plan"),
        OsString::from("--manifest"),
        OsString::from(manifest_path.as_os_str()),
        OsString::from("--out"),
        OsString::from(out_path.as_os_str()),
        OsString::from("--require-restore-ready"),
    ])
    .expect_err("restore readiness should be enforced");

    assert!(out_path.exists());
    let plan: RestorePlan =
        serde_json::from_slice(&fs::read(&out_path).expect("read plan")).expect("decode plan");

    fs::remove_dir_all(root).expect("remove temp root");
    assert!(!plan.readiness_summary.ready);
    assert!(matches!(
        err,
        RestoreCommandError::RestoreNotReady {
            reasons,
            ..
        } if reasons == ["missing-snapshot-checksum"]
    ));
}

// Ensure restore-readiness gating accepts plans with complete snapshot artifacts.
#[test]
fn run_restore_plan_require_restore_ready_accepts_ready_plan() {
    let root = temp_dir("canic-cli-restore-plan-ready");
    fs::create_dir_all(&root).expect("create temp root");
    let manifest_path = root.join("manifest.json");
    let out_path = root.join("plan.json");

    fs::write(
        &manifest_path,
        serde_json::to_vec(&restore_ready_manifest()).expect("serialize manifest"),
    )
    .expect("write manifest");

    run([
        OsString::from("plan"),
        OsString::from("--manifest"),
        OsString::from(manifest_path.as_os_str()),
        OsString::from("--out"),
        OsString::from(out_path.as_os_str()),
        OsString::from("--require-restore-ready"),
    ])
    .expect("restore-ready plan should pass");

    let plan: RestorePlan =
        serde_json::from_slice(&fs::read(&out_path).expect("read plan")).expect("decode plan");

    fs::remove_dir_all(root).expect("remove temp root");
    assert!(plan.readiness_summary.ready);
    assert!(plan.readiness_summary.reasons.is_empty());
}