fix: make worker identity migrations dry-runnable
This commit is contained in:
@@ -2,7 +2,9 @@ use crate::catalog::{CreateWorkerRequest, WorkingDirectoryStatus};
|
||||
use crate::config_bundle::ConfigBundle;
|
||||
use crate::diagnostics::{DiagnosticSeverity, RuntimeDiagnostic};
|
||||
use crate::error::RuntimeError;
|
||||
use crate::identity::{WorkerId, WorkerRef};
|
||||
use crate::identity::{
|
||||
LegacyWorkerIdentityMapping, WorkerId, WorkerRef, legacy_worker_identity_mapping_digest,
|
||||
};
|
||||
use crate::management::{RuntimeBackendKind, RuntimeStatus};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
@@ -52,6 +54,18 @@ pub struct FsRuntimeStore {
|
||||
}
|
||||
|
||||
impl FsRuntimeStore {
|
||||
pub fn migration_plan(
|
||||
options: &FsRuntimeStoreOptions,
|
||||
) -> Result<FsRuntimeStoreMigrationPlan, RuntimeError> {
|
||||
plan_v1_worker_identity(&options.root, &options.runtime_id).map(|(plan, _)| plan)
|
||||
}
|
||||
|
||||
pub fn migrate(
|
||||
options: &FsRuntimeStoreOptions,
|
||||
) -> Result<FsRuntimeStoreMigrationPlan, RuntimeError> {
|
||||
migrate_v1_worker_identity(&options.root, &options.runtime_id)
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
@@ -286,11 +300,36 @@ fn runtime_store_corrupt(path: &Path, message: String) -> RuntimeError {
|
||||
}
|
||||
}
|
||||
|
||||
fn migrate_v1_worker_identity(root: &Path, runtime_id: &str) -> Result<(), RuntimeError> {
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FsRuntimeStoreMigrationPlan {
|
||||
pub current_schema_version: u32,
|
||||
pub target_schema_version: u32,
|
||||
pub migration_required: bool,
|
||||
pub worker_count: usize,
|
||||
pub mapping_digest: String,
|
||||
pub mappings: Vec<LegacyWorkerIdentityMapping>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct PlannedRuntimeWorkerMigration {
|
||||
mapping: LegacyWorkerIdentityMapping,
|
||||
legacy_dir: PathBuf,
|
||||
}
|
||||
|
||||
fn plan_v1_worker_identity(
|
||||
root: &Path,
|
||||
runtime_id: &str,
|
||||
) -> Result<
|
||||
(
|
||||
FsRuntimeStoreMigrationPlan,
|
||||
Vec<PlannedRuntimeWorkerMigration>,
|
||||
),
|
||||
RuntimeError,
|
||||
> {
|
||||
let runtime_path = root.join(RUNTIME_FILE);
|
||||
let bytes =
|
||||
fs::read(&runtime_path).map_err(|error| runtime_io_error("read", &runtime_path, error))?;
|
||||
let mut document: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| {
|
||||
let document: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| {
|
||||
runtime_store_corrupt(
|
||||
&runtime_path,
|
||||
format!("decode Runtime state {}: {error}", runtime_path.display()),
|
||||
@@ -305,10 +344,36 @@ fn migrate_v1_worker_identity(root: &Path, runtime_id: &str) -> Result<(), Runti
|
||||
"Runtime state is missing schema_version".to_string(),
|
||||
)
|
||||
})?;
|
||||
if schema_version == u64::from(SCHEMA_VERSION) {
|
||||
return Ok(());
|
||||
let current_schema_version = u32::try_from(schema_version).map_err(|_| {
|
||||
runtime_store_corrupt(
|
||||
&runtime_path,
|
||||
format!("Runtime store schema version {schema_version} is out of range"),
|
||||
)
|
||||
})?;
|
||||
let staging = migration_sibling(root, "schema-v2-staging")?;
|
||||
let backup = migration_sibling(root, "schema-v1-backup")?;
|
||||
if staging.exists() || backup.exists() {
|
||||
return Err(runtime_store_corrupt(
|
||||
root,
|
||||
format!(
|
||||
"unfinished Runtime migration artifact exists (staging={}, backup={})",
|
||||
staging.display(),
|
||||
backup.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
if schema_version != 1 {
|
||||
if current_schema_version == SCHEMA_VERSION {
|
||||
let plan = FsRuntimeStoreMigrationPlan {
|
||||
current_schema_version,
|
||||
target_schema_version: SCHEMA_VERSION,
|
||||
migration_required: false,
|
||||
worker_count: 0,
|
||||
mapping_digest: legacy_worker_identity_mapping_digest(&[]),
|
||||
mappings: Vec::new(),
|
||||
};
|
||||
return Ok((plan, Vec::new()));
|
||||
}
|
||||
if current_schema_version != 1 {
|
||||
return Err(runtime_store_corrupt(
|
||||
&runtime_path,
|
||||
format!(
|
||||
@@ -316,30 +381,256 @@ fn migrate_v1_worker_identity(root: &Path, runtime_id: &str) -> Result<(), Runti
|
||||
),
|
||||
));
|
||||
}
|
||||
validate_runtime_tree_copyable(root)?;
|
||||
|
||||
let legacy_ids = document
|
||||
.get("workers")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.ok_or_else(|| {
|
||||
let workers_dir = root.join(WORKERS_DIR);
|
||||
let mut entries = fs::read_dir(&workers_dir)
|
||||
.map_err(|error| runtime_io_error("read workers", &workers_dir, error))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|error| runtime_io_error("read workers", &workers_dir, error))?;
|
||||
entries.sort_by_key(|entry| entry.file_name());
|
||||
let mut planned = Vec::with_capacity(entries.len());
|
||||
let mut target_ids = std::collections::BTreeSet::new();
|
||||
for entry in entries {
|
||||
let legacy_dir = entry.path();
|
||||
if !legacy_dir.is_dir() {
|
||||
return Err(runtime_store_corrupt(
|
||||
&legacy_dir,
|
||||
"legacy workers directory contains a non-directory entry".to_string(),
|
||||
));
|
||||
}
|
||||
let name = entry.file_name();
|
||||
let name = name.to_str().ok_or_else(|| {
|
||||
runtime_store_corrupt(
|
||||
&runtime_path,
|
||||
"Runtime state workers must be an array".to_string(),
|
||||
&legacy_dir,
|
||||
"legacy Worker directory is not UTF-8".to_string(),
|
||||
)
|
||||
})?
|
||||
.iter()
|
||||
.map(|value| {
|
||||
value.as_u64().ok_or_else(|| {
|
||||
})?;
|
||||
let legacy_worker_id = name.parse::<u64>().map_err(|_| {
|
||||
runtime_store_corrupt(
|
||||
&legacy_dir,
|
||||
format!("legacy Worker directory name must be numeric, found {name}"),
|
||||
)
|
||||
})?;
|
||||
let snapshot_path = legacy_dir.join(WORKER_FILE);
|
||||
let snapshot: serde_json::Value = read_json(&snapshot_path, "read legacy worker snapshot")?;
|
||||
let workspace_id = snapshot
|
||||
.get("workspace_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|workspace_id| !workspace_id.is_empty())
|
||||
.ok_or_else(|| {
|
||||
runtime_store_corrupt(
|
||||
&runtime_path,
|
||||
"legacy Worker id must be unsigned".to_string(),
|
||||
&snapshot_path,
|
||||
"legacy Worker snapshot is missing workspace_id; unscoped Workers require an explicit migration disposition"
|
||||
.to_string(),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
})?
|
||||
.to_string();
|
||||
let worker_id = WorkerId::from_legacy_binding(&workspace_id, runtime_id, legacy_worker_id);
|
||||
if !target_ids.insert(worker_id) {
|
||||
return Err(runtime_store_corrupt(
|
||||
&snapshot_path,
|
||||
format!("legacy Worker identity maps to duplicate target {worker_id}"),
|
||||
));
|
||||
}
|
||||
let target_dir = workers_dir.join(worker_id.to_string());
|
||||
if target_dir.exists() && target_dir != legacy_dir {
|
||||
return Err(runtime_store_corrupt(
|
||||
&target_dir,
|
||||
format!("target Worker directory {worker_id} already exists"),
|
||||
));
|
||||
}
|
||||
let request = snapshot
|
||||
.get("request")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.ok_or_else(|| {
|
||||
runtime_store_corrupt(
|
||||
&snapshot_path,
|
||||
"Worker snapshot request must be an object".to_string(),
|
||||
)
|
||||
})?;
|
||||
if request.get("profile").is_none() {
|
||||
return Err(runtime_store_corrupt(
|
||||
&snapshot_path,
|
||||
"Worker snapshot request is missing profile".to_string(),
|
||||
));
|
||||
}
|
||||
planned.push(PlannedRuntimeWorkerMigration {
|
||||
mapping: LegacyWorkerIdentityMapping {
|
||||
workspace_id,
|
||||
runtime_id: runtime_id.to_string(),
|
||||
legacy_worker_id,
|
||||
worker_id,
|
||||
},
|
||||
legacy_dir,
|
||||
});
|
||||
}
|
||||
let mappings = planned
|
||||
.iter()
|
||||
.map(|worker| worker.mapping.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let plan = FsRuntimeStoreMigrationPlan {
|
||||
current_schema_version,
|
||||
target_schema_version: SCHEMA_VERSION,
|
||||
migration_required: true,
|
||||
worker_count: mappings.len(),
|
||||
mapping_digest: legacy_worker_identity_mapping_digest(&mappings),
|
||||
mappings,
|
||||
};
|
||||
Ok((plan, planned))
|
||||
}
|
||||
|
||||
let mut migrated_ids = Vec::with_capacity(legacy_ids.len());
|
||||
for legacy_id in legacy_ids {
|
||||
let legacy_dir = root.join("workers").join(legacy_id.to_string());
|
||||
fn migration_sibling(root: &Path, suffix: &str) -> Result<PathBuf, RuntimeError> {
|
||||
let parent = root.parent().ok_or_else(|| {
|
||||
runtime_store_corrupt(
|
||||
root,
|
||||
"Runtime store root has no parent directory".to_string(),
|
||||
)
|
||||
})?;
|
||||
let name = root
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| {
|
||||
runtime_store_corrupt(root, "Runtime store root name is not UTF-8".to_string())
|
||||
})?;
|
||||
Ok(parent.join(format!(".{name}.{suffix}")))
|
||||
}
|
||||
|
||||
fn validate_runtime_tree_copyable(source: &Path) -> Result<(), RuntimeError> {
|
||||
let entries = fs::read_dir(source)
|
||||
.map_err(|error| runtime_io_error("read migration source", source, error))?;
|
||||
for entry in entries {
|
||||
let entry =
|
||||
entry.map_err(|error| runtime_io_error("read migration source", source, error))?;
|
||||
let source_path = entry.path();
|
||||
let file_type = entry
|
||||
.file_type()
|
||||
.map_err(|error| runtime_io_error("inspect migration source", &source_path, error))?;
|
||||
if file_type.is_dir() {
|
||||
validate_runtime_tree_copyable(&source_path)?;
|
||||
} else if !file_type.is_file() {
|
||||
return Err(runtime_store_corrupt(
|
||||
&source_path,
|
||||
"Runtime migration refuses symlinks and special files".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_runtime_tree(source: &Path, target: &Path) -> Result<(), RuntimeError> {
|
||||
fs::create_dir(target)
|
||||
.map_err(|error| runtime_io_error("create migration staging", target, error))?;
|
||||
let mut entries = fs::read_dir(source)
|
||||
.map_err(|error| runtime_io_error("read migration source", source, error))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|error| runtime_io_error("read migration source", source, error))?;
|
||||
entries.sort_by_key(|entry| entry.file_name());
|
||||
for entry in entries {
|
||||
let source_path = entry.path();
|
||||
let target_path = target.join(entry.file_name());
|
||||
let file_type = entry
|
||||
.file_type()
|
||||
.map_err(|error| runtime_io_error("inspect migration source", &source_path, error))?;
|
||||
if file_type.is_dir() {
|
||||
copy_runtime_tree(&source_path, &target_path)?;
|
||||
} else if file_type.is_file() {
|
||||
fs::copy(&source_path, &target_path)
|
||||
.map_err(|error| runtime_io_error("copy migration source", &source_path, error))?;
|
||||
} else {
|
||||
return Err(runtime_store_corrupt(
|
||||
&source_path,
|
||||
"Runtime migration refuses symlinks and special files".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_v1_worker_identity(
|
||||
root: &Path,
|
||||
runtime_id: &str,
|
||||
) -> Result<FsRuntimeStoreMigrationPlan, RuntimeError> {
|
||||
let (plan, _) = plan_v1_worker_identity(root, runtime_id)?;
|
||||
if !plan.migration_required {
|
||||
return Ok(plan);
|
||||
}
|
||||
let staging = migration_sibling(root, "schema-v2-staging")?;
|
||||
let backup = migration_sibling(root, "schema-v1-backup")?;
|
||||
if staging.exists() || backup.exists() {
|
||||
return Err(runtime_store_corrupt(
|
||||
root,
|
||||
format!(
|
||||
"unfinished Runtime migration artifact exists (staging={}, backup={}); recover or remove it before retrying",
|
||||
staging.display(),
|
||||
backup.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
if let Err(error) = copy_runtime_tree(root, &staging) {
|
||||
let _ = fs::remove_dir_all(&staging);
|
||||
return Err(error);
|
||||
}
|
||||
let staged_plan = match migrate_v1_worker_identity_in_place(&staging, runtime_id) {
|
||||
Ok(plan) => plan,
|
||||
Err(error) => {
|
||||
let _ = fs::remove_dir_all(&staging);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let staged_store = FsRuntimeStore {
|
||||
root: staging.clone(),
|
||||
};
|
||||
if let Err(error) = staged_store.load_runtime_state() {
|
||||
let _ = fs::remove_dir_all(&staging);
|
||||
return Err(error);
|
||||
}
|
||||
fs::rename(root, &backup)
|
||||
.map_err(|error| runtime_io_error("backup runtime store", root, error))?;
|
||||
if let Err(error) = fs::rename(&staging, root) {
|
||||
let rollback = fs::rename(&backup, root);
|
||||
return match rollback {
|
||||
Ok(()) => Err(runtime_io_error(
|
||||
"activate migrated runtime store",
|
||||
&staging,
|
||||
error,
|
||||
)),
|
||||
Err(rollback_error) => Err(runtime_store_corrupt(
|
||||
root,
|
||||
format!(
|
||||
"activate migrated Runtime store failed: {error}; rollback failed: {rollback_error}; backup remains at {}",
|
||||
backup.display()
|
||||
),
|
||||
)),
|
||||
};
|
||||
}
|
||||
fs::remove_dir_all(&backup)
|
||||
.map_err(|error| runtime_io_error("remove runtime migration backup", &backup, error))?;
|
||||
debug_assert_eq!(plan.mapping_digest, staged_plan.mapping_digest);
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
fn migrate_v1_worker_identity_in_place(
|
||||
root: &Path,
|
||||
runtime_id: &str,
|
||||
) -> Result<FsRuntimeStoreMigrationPlan, RuntimeError> {
|
||||
let (plan, planned_workers) = plan_v1_worker_identity(root, runtime_id)?;
|
||||
if !plan.migration_required {
|
||||
return Ok(plan);
|
||||
}
|
||||
let runtime_path = root.join(RUNTIME_FILE);
|
||||
let bytes =
|
||||
fs::read(&runtime_path).map_err(|error| runtime_io_error("read", &runtime_path, error))?;
|
||||
let mut document: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| {
|
||||
runtime_store_corrupt(
|
||||
&runtime_path,
|
||||
format!("decode Runtime state {}: {error}", runtime_path.display()),
|
||||
)
|
||||
})?;
|
||||
|
||||
for planned_worker in &planned_workers {
|
||||
let legacy_id = planned_worker.mapping.legacy_worker_id;
|
||||
let legacy_dir = &planned_worker.legacy_dir;
|
||||
let legacy_snapshot_path = legacy_dir.join(WORKER_FILE);
|
||||
let bytes = fs::read(&legacy_snapshot_path)
|
||||
.map_err(|error| runtime_io_error("read", &legacy_snapshot_path, error))?;
|
||||
@@ -352,12 +643,8 @@ fn migrate_v1_worker_identity(root: &Path, runtime_id: &str) -> Result<(), Runti
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let workspace_id = snapshot
|
||||
.get("workspace_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("local")
|
||||
.to_string();
|
||||
let worker_id = WorkerId::from_legacy_binding(&workspace_id, runtime_id, legacy_id);
|
||||
let workspace_id = planned_worker.mapping.workspace_id.clone();
|
||||
let worker_id = planned_worker.mapping.worker_id;
|
||||
let worker_id_text = worker_id.to_string();
|
||||
snapshot["schema_version"] = serde_json::Value::from(SCHEMA_VERSION);
|
||||
snapshot["worker_id"] = serde_json::Value::String(worker_id_text.clone());
|
||||
@@ -394,19 +681,19 @@ fn migrate_v1_worker_identity(root: &Path, runtime_id: &str) -> Result<(), Runti
|
||||
&snapshot,
|
||||
"migrate Worker identity",
|
||||
)?;
|
||||
migrated_ids.push(serde_json::Value::String(worker_id_text));
|
||||
}
|
||||
|
||||
document["schema_version"] = serde_json::Value::from(SCHEMA_VERSION);
|
||||
document["workers"] = serde_json::Value::Array(migrated_ids);
|
||||
if let Some(object) = document.as_object_mut() {
|
||||
object.remove("workers");
|
||||
object.remove("next_worker_sequence");
|
||||
}
|
||||
atomic_write_json(
|
||||
&runtime_path,
|
||||
&document,
|
||||
"migrate Runtime Worker identities",
|
||||
)
|
||||
)?;
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{fmt, str::FromStr};
|
||||
use uuid::{Uuid, Version};
|
||||
|
||||
@@ -27,8 +28,6 @@ impl WorkerId {
|
||||
}
|
||||
|
||||
pub fn from_legacy_binding(workspace_id: &str, runtime_id: &str, value: u64) -> Self {
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"yoi.workspace-worker-id.v1\0");
|
||||
hasher.update(workspace_id.as_bytes());
|
||||
@@ -101,6 +100,45 @@ impl fmt::Display for WorkerIdParseError {
|
||||
|
||||
impl std::error::Error for WorkerIdParseError {}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LegacyWorkerIdentityMapping {
|
||||
pub workspace_id: String,
|
||||
pub runtime_id: String,
|
||||
pub legacy_worker_id: u64,
|
||||
pub worker_id: WorkerId,
|
||||
}
|
||||
|
||||
pub fn legacy_worker_identity_mapping_digest(mappings: &[LegacyWorkerIdentityMapping]) -> String {
|
||||
let mut mappings = mappings.to_vec();
|
||||
mappings.sort_by(|left, right| {
|
||||
(
|
||||
left.workspace_id.as_str(),
|
||||
left.runtime_id.as_str(),
|
||||
left.legacy_worker_id,
|
||||
left.worker_id,
|
||||
)
|
||||
.cmp(&(
|
||||
right.workspace_id.as_str(),
|
||||
right.runtime_id.as_str(),
|
||||
right.legacy_worker_id,
|
||||
right.worker_id,
|
||||
))
|
||||
});
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"yoi.workspace-worker-migration-plan.v1\0");
|
||||
for mapping in mappings {
|
||||
hasher.update(mapping.workspace_id.as_bytes());
|
||||
hasher.update([0]);
|
||||
hasher.update(mapping.runtime_id.as_bytes());
|
||||
hasher.update([0]);
|
||||
hasher.update(mapping.legacy_worker_id.to_be_bytes());
|
||||
hasher.update(mapping.worker_id.to_string().as_bytes());
|
||||
hasher.update([b'\n']);
|
||||
}
|
||||
let digest = hasher.finalize();
|
||||
digest.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Runtime-local authority reference for Worker operations. The contained id is
|
||||
/// nevertheless the Workspace-owned stable identity; the Runtime does not mint it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
|
||||
@@ -18,7 +18,7 @@ use worker_runtime::auth::{
|
||||
RuntimeHttpAuthConfig, RuntimeIdentityMaterial, TrustedServerKey, decode_public_key,
|
||||
};
|
||||
use worker_runtime::error::RuntimeError;
|
||||
use worker_runtime::fs_store::FsRuntimeStoreOptions;
|
||||
use worker_runtime::fs_store::{FsRuntimeStore, FsRuntimeStoreOptions};
|
||||
use worker_runtime::http_server::{
|
||||
RuntimeHttpServerConfig, RuntimeHttpServerError, RuntimeHttpStoreSelection,
|
||||
};
|
||||
@@ -44,6 +44,9 @@ fn main() -> ExitCode {
|
||||
|
||||
fn run() -> Result<(), ProcessError> {
|
||||
let args = env::args().skip(1).collect::<Vec<_>>();
|
||||
if matches!(args.first().map(String::as_str), Some("migrate")) {
|
||||
return run_migration_command(args);
|
||||
}
|
||||
if matches!(
|
||||
args.first().map(String::as_str),
|
||||
Some("identity" | "trust-server")
|
||||
@@ -78,6 +81,76 @@ fn run() -> Result<(), ProcessError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_migration_command(mut args: Vec<String>) -> Result<(), ProcessError> {
|
||||
args.remove(0);
|
||||
let dry_run_index = args
|
||||
.iter()
|
||||
.position(|argument| argument == "--dry-run")
|
||||
.ok_or_else(|| ProcessError::usage("migrate currently requires --dry-run".to_string()))?;
|
||||
args.remove(dry_run_index);
|
||||
let explicit_runtime_id =
|
||||
if let Some(index) = args.iter().position(|argument| argument == "--runtime-id") {
|
||||
if index + 1 >= args.len() {
|
||||
return Err(ProcessError::usage(
|
||||
"--runtime-id requires a value".to_string(),
|
||||
));
|
||||
}
|
||||
let runtime_id = args.remove(index + 1);
|
||||
args.remove(index);
|
||||
Some(runtime_id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let config = parse_args(args)?.ok_or_else(|| {
|
||||
ProcessError::usage("migrate requires a Runtime store configuration".to_string())
|
||||
})?;
|
||||
let root = match &config.http.store {
|
||||
RuntimeHttpStoreSelection::Fs { root } => root.clone(),
|
||||
RuntimeHttpStoreSelection::Memory => {
|
||||
return Err(ProcessError::usage(
|
||||
"migration dry-run requires the fs Runtime store".to_string(),
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
return Err(ProcessError::usage(
|
||||
"unsupported Runtime catalog store selection".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let persisted_runtime_id = read_runtime_auth_file(&runtime_auth_path(&config))?
|
||||
.identity
|
||||
.map(|identity| identity.identity_id);
|
||||
let runtime_id = match (persisted_runtime_id, explicit_runtime_id) {
|
||||
(Some(persisted), Some(explicit)) if persisted != explicit => {
|
||||
return Err(ProcessError::usage(format!(
|
||||
"--runtime-id {explicit} does not match persisted Runtime identity {persisted}"
|
||||
)));
|
||||
}
|
||||
(Some(persisted), _) => persisted,
|
||||
(None, Some(explicit)) if !explicit.is_empty() => explicit,
|
||||
(None, Some(_)) => {
|
||||
return Err(ProcessError::usage(
|
||||
"--runtime-id must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
(None, None) => {
|
||||
return Err(ProcessError::usage(
|
||||
"migration dry-run requires a persisted Runtime identity or explicit --runtime-id"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let mut options = FsRuntimeStoreOptions::new(root).with_runtime_id(runtime_id);
|
||||
options.display_name = config.http.display_name.clone();
|
||||
let plan = FsRuntimeStore::migration_plan(&options).map_err(ProcessError::Runtime)?;
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&plan)
|
||||
.map_err(|error| ProcessError::Auth(format!("encode migration plan: {error}")))?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
|
||||
let fs_paths = config.resolved_fs_paths();
|
||||
let runtime_store_dir = match &config.http.store {
|
||||
@@ -791,6 +864,7 @@ fn run_trust_server_command(mut args: VecDeque<String>) -> Result<(), ProcessErr
|
||||
|
||||
fn usage() -> &'static str {
|
||||
r#"Usage: yoi-runtime [OPTIONS]
|
||||
yoi-runtime migrate --dry-run [--runtime-id <ID>] [OPTIONS]
|
||||
|
||||
Starts a worker-backed Runtime REST command API for a trusted backend/proxy.
|
||||
Browsers must not connect to this Runtime process directly.
|
||||
@@ -911,6 +985,39 @@ mod tests {
|
||||
assert_eq!(paths.workdir_target, PathBuf::from("/tmp/yoi-workdirs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_dry_run_accepts_real_v1_document_without_workers_field() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("runtime");
|
||||
std::fs::create_dir_all(root.join("workers")).unwrap();
|
||||
std::fs::write(
|
||||
root.join("runtime.json"),
|
||||
serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"schema_version": 1,
|
||||
"assignments": [],
|
||||
"execution": [],
|
||||
"diagnostics": []
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let before = std::fs::read(root.join("runtime.json")).unwrap();
|
||||
run_migration_command(vec![
|
||||
"migrate".to_string(),
|
||||
"--dry-run".to_string(),
|
||||
"--runtime-id".to_string(),
|
||||
"local".to_string(),
|
||||
"--store".to_string(),
|
||||
"fs".to_string(),
|
||||
"--fs-root".to_string(),
|
||||
temp.path().display().to_string(),
|
||||
"--fs-runtime-dir".to_string(),
|
||||
root.display().to_string(),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(std::fs::read(root.join("runtime.json")).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_store_disables_runtime_catalog_persistence() {
|
||||
let config = parse_args(["--no-store"]).unwrap().unwrap();
|
||||
|
||||
@@ -4186,7 +4186,7 @@ mod tests {
|
||||
let mut runtime_json: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(&runtime_path).unwrap()).unwrap();
|
||||
runtime_json["schema_version"] = serde_json::json!(1);
|
||||
runtime_json["workers"] = serde_json::json!([7]);
|
||||
runtime_json["workers"] = serde_json::json!({"legacy": "ignored"});
|
||||
runtime_json["next_worker_sequence"] = serde_json::json!(8);
|
||||
std::fs::write(
|
||||
&runtime_path,
|
||||
@@ -4194,12 +4194,23 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let restored = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions {
|
||||
let runtime_options = crate::fs_store::FsRuntimeStoreOptions {
|
||||
root: root.clone(),
|
||||
runtime_id: runtime_id.to_string(),
|
||||
display_name: None,
|
||||
})
|
||||
.unwrap();
|
||||
};
|
||||
let runtime_before_dry_run = std::fs::read(&runtime_path).unwrap();
|
||||
let plan = crate::fs_store::FsRuntimeStore::migration_plan(&runtime_options).unwrap();
|
||||
assert!(plan.migration_required);
|
||||
assert_eq!(plan.worker_count, 1);
|
||||
assert_eq!(plan.mappings[0].legacy_worker_id, 7);
|
||||
assert_eq!(
|
||||
std::fs::read(&runtime_path).unwrap(),
|
||||
runtime_before_dry_run
|
||||
);
|
||||
assert!(legacy_dir.exists());
|
||||
|
||||
let restored = Runtime::with_fs_store(runtime_options).unwrap();
|
||||
let expected = WorkerId::from_legacy_binding("workspace-a", runtime_id, 7);
|
||||
let detail = restored.worker_detail(&WorkerRef::new(expected)).unwrap();
|
||||
assert_eq!(detail.worker_id, expected);
|
||||
@@ -4209,6 +4220,7 @@ mod tests {
|
||||
let migrated_runtime: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(runtime_path).unwrap()).unwrap();
|
||||
assert_eq!(migrated_runtime["schema_version"], serde_json::json!(2));
|
||||
assert!(migrated_runtime.get("workers").is_none());
|
||||
assert!(migrated_runtime.get("next_worker_sequence").is_none());
|
||||
|
||||
drop(restored);
|
||||
|
||||
Reference in New Issue
Block a user