mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 00:03:07 +00:00
ok
This commit is contained in:
@@ -770,6 +770,48 @@ paths:
|
||||
schema:
|
||||
type: boolean
|
||||
|
||||
/workspaces/migrate:
|
||||
post:
|
||||
summary: migrate workspace data from source to target
|
||||
operationId: migrateWorkspace
|
||||
tags:
|
||||
- workspace
|
||||
requestBody:
|
||||
description: migration request
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MigrateWorkspaceRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: migration completed
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/workspaces/migration/status:
|
||||
get:
|
||||
summary: get migration status
|
||||
operationId: getMigrationStatus
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- name: source_workspace
|
||||
in: query
|
||||
required: true
|
||||
description: source workspace id to check migration status
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: migration status
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MigrateJobResponse"
|
||||
|
||||
/settings/get_ducklake_instance_catalog_db_status:
|
||||
post:
|
||||
summary: Returns the set-up statuses of ducklake instance catalog dbs
|
||||
@@ -17613,6 +17655,52 @@ components:
|
||||
- SKIP
|
||||
- FAIL
|
||||
|
||||
MigrateWorkspaceRequest:
|
||||
type: object
|
||||
properties:
|
||||
source_workspace_id:
|
||||
type: string
|
||||
description: source workspace id
|
||||
target_workspace_id:
|
||||
type: string
|
||||
description: target workspace id
|
||||
target_workspace_name:
|
||||
type: string
|
||||
description: target workspace name
|
||||
migration_type:
|
||||
type: string
|
||||
enum: [all, metadata, jobs]
|
||||
description: type of migration to perform
|
||||
disable_workspace:
|
||||
type: boolean
|
||||
default: true
|
||||
description: whether to disable source workspace after migration
|
||||
required:
|
||||
- source_workspace_id
|
||||
- target_workspace_name
|
||||
- target_workspace_id
|
||||
- migration_type
|
||||
|
||||
MigrateJobResponse:
|
||||
type: object
|
||||
properties:
|
||||
source_workspace:
|
||||
type: string
|
||||
total_jobs:
|
||||
type: integer
|
||||
format: int64
|
||||
remaining_jobs:
|
||||
type: integer
|
||||
format: int64
|
||||
migration_progress:
|
||||
type: number
|
||||
format: float
|
||||
required:
|
||||
- source_workspace
|
||||
- total_jobs
|
||||
- remaining_jobs
|
||||
- migration_progress
|
||||
|
||||
DucklakeInstanceCatalogDbStatusLogs:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -143,10 +143,6 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/get_workspace_name", get(get_workspace_name))
|
||||
.route("/change_workspace_name", post(change_workspace_name))
|
||||
.route("/change_workspace_color", post(change_workspace_color))
|
||||
.route(
|
||||
"/change_workspace_id",
|
||||
post(crate::workspaces_extra::change_workspace_id),
|
||||
)
|
||||
.route("/usage", get(get_usage))
|
||||
.route("/used_triggers", get(get_used_triggers))
|
||||
.route("/critical_alerts", get(get_critical_alerts))
|
||||
@@ -188,6 +184,11 @@ pub fn global_service() -> Router {
|
||||
"/create_workspace_require_superadmin",
|
||||
get(create_workspace_require_superadmin),
|
||||
)
|
||||
.route("/migrate", post(crate::workspaces_extra::migrate_workspace))
|
||||
.route(
|
||||
"/migration/status",
|
||||
get(crate::workspaces_extra::get_migration_status),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(FromRow, Serialize)]
|
||||
|
||||
@@ -13,6 +13,7 @@ use sqlx::{Postgres, Transaction};
|
||||
use windmill_audit::audit_oss::audit_log;
|
||||
use windmill_audit::ActionKind;
|
||||
|
||||
use windmill_common::error::JsonResult;
|
||||
use windmill_common::worker::CLOUD_HOSTED;
|
||||
|
||||
use windmill_common::{
|
||||
@@ -21,399 +22,29 @@ use windmill_common::{
|
||||
utils::require_admin,
|
||||
};
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct ChangeWorkspaceId {
|
||||
new_id: String,
|
||||
new_name: String,
|
||||
pub(crate) struct MigrateWorkspaceRequest {
|
||||
source_workspace_id: String,
|
||||
target_workspace_name: String,
|
||||
target_workspace_id: String,
|
||||
migration_type: MigrationType,
|
||||
#[serde(default = "default_disable_workspace")]
|
||||
disable_workspace: bool,
|
||||
}
|
||||
|
||||
pub(crate) async fn change_workspace_id(
|
||||
authed: ApiAuthed,
|
||||
Path(old_id): Path<String>,
|
||||
Extension(db): Extension<DB>,
|
||||
Json(rw): Json<ChangeWorkspaceId>,
|
||||
) -> Result<String> {
|
||||
if *CLOUD_HOSTED && !is_super_admin_email(&db, &authed.email).await? {
|
||||
return Err(Error::BadRequest(
|
||||
"This feature is not available on the cloud".to_string(),
|
||||
));
|
||||
}
|
||||
fn default_disable_workspace() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
if *CREATE_WORKSPACE_REQUIRE_SUPERADMIN {
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
} else {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
}
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
check_w_id_conflict(&mut tx, &rw.new_id).await?;
|
||||
|
||||
// duplicate workspace with new id name
|
||||
sqlx::query!(
|
||||
"INSERT INTO workspace SELECT $1, $2, owner, deleted, premium FROM workspace WHERE id = $3",
|
||||
&rw.new_id,
|
||||
&rw.new_name,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE account SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE app SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE audit SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE capture SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE capture_config SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE http_trigger SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE websocket_trigger SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE kafka_trigger SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE nats_trigger SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE v2_job_completed SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE dependency_map SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE deployment_metadata SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE draft SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE favorite SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO flow
|
||||
(workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at)
|
||||
SELECT $1, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at
|
||||
FROM flow WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE flow_version SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_runnable_dependencies SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE asset SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE flow_node SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!("DELETE FROM flow WHERE workspace_id = $1", &old_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// have to duplicate group_ with new workspace id because of foreign key constraint
|
||||
sqlx::query!(
|
||||
"INSERT INTO group_ SELECT $1, name, summary, extra_perms FROM group_ WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE usr_to_group SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// then delete old group_
|
||||
sqlx::query!("DELETE FROM group_ WHERE workspace_id = $1", &old_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE folder SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE input SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE job_logs SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE job_stats SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE v2_job_queue SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE v2_job SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE raw_app SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE resource SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE resource_type SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE schedule SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE script SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE token SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE usage SET id = $1 WHERE id = $2 AND is_workspace = true",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE usr SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE variable SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_env SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_invite SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_key SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_settings SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// delete old workspace
|
||||
sqlx::query!("DELETE FROM workspace WHERE id = $1", &old_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"workspace.change_workspace_id",
|
||||
ActionKind::Update,
|
||||
&rw.new_id,
|
||||
Some(&authed.email),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(format!(
|
||||
"updated workspace from {} to {}",
|
||||
&old_id, &rw.new_id
|
||||
))
|
||||
#[derive(Default, Deserialize, Serialize, Debug, Clone, Copy, PartialEq)]
|
||||
#[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))]
|
||||
pub enum MigrationType {
|
||||
#[default]
|
||||
All,
|
||||
Metadata,
|
||||
Jobs,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -602,3 +233,288 @@ async fn is_workspace_owner(
|
||||
.await?;
|
||||
Ok(owner.map(|o| o == authed.email).unwrap_or(false))
|
||||
}
|
||||
|
||||
pub(crate) async fn migrate_workspace(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Json(req): Json<MigrateWorkspaceRequest>,
|
||||
) -> Result<String> {
|
||||
if *CLOUD_HOSTED && !is_super_admin_email(&db, &authed.email).await? {
|
||||
return Err(Error::BadRequest(
|
||||
"This feature is not available on the cloud".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if *CREATE_WORKSPACE_REQUIRE_SUPERADMIN {
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
} else {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
}
|
||||
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
check_w_id_conflict(&mut tx, &req.target_workspace_id).await?;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO
|
||||
workspace SELECT $1, $2, owner, deleted, premium FROM workspace WHERE id = $3",
|
||||
&req.target_workspace_id,
|
||||
&req.target_workspace_name,
|
||||
&req.source_workspace_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
match req.migration_type {
|
||||
MigrationType::Metadata | MigrationType::All => {
|
||||
migrate_metadata_tables(&mut tx, &req.source_workspace_id, &req.target_workspace_id)
|
||||
.await?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
match req.migration_type {
|
||||
MigrationType::Jobs | MigrationType::All => {
|
||||
migrate_job_tables(&mut tx, &req.source_workspace_id, &req.target_workspace_id).await?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if req.disable_workspace && req.migration_type != MigrationType::Jobs {
|
||||
sqlx::query!(
|
||||
"UPDATE workspace SET deleted = true WHERE id = $1",
|
||||
&req.source_workspace_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"workspace.migrate",
|
||||
ActionKind::Update,
|
||||
&req.target_workspace_id,
|
||||
Some(&authed.email),
|
||||
Some(
|
||||
[
|
||||
("source", req.source_workspace_id.as_str()),
|
||||
("target", req.target_workspace_id.as_str()),
|
||||
(
|
||||
"type",
|
||||
match req.migration_type {
|
||||
MigrationType::All => "all",
|
||||
MigrationType::Metadata => "metadata",
|
||||
MigrationType::Jobs => "jobs",
|
||||
},
|
||||
),
|
||||
]
|
||||
.into(),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(format!(
|
||||
"Migrated {} from {} to {}",
|
||||
match req.migration_type {
|
||||
MigrationType::All => "all data",
|
||||
MigrationType::Metadata => "metadata",
|
||||
MigrationType::Jobs => "jobs",
|
||||
},
|
||||
&req.source_workspace_id,
|
||||
&req.target_workspace_id
|
||||
))
|
||||
}
|
||||
|
||||
async fn migrate_metadata_tables(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
source: &str,
|
||||
target: &str,
|
||||
) -> Result<()> {
|
||||
// Simple tables that can be updated directly (in same order as change_workspace_id)
|
||||
let simple_tables = vec![
|
||||
"account",
|
||||
"app",
|
||||
"audit",
|
||||
"capture",
|
||||
"capture_config",
|
||||
"http_trigger",
|
||||
"websocket_trigger",
|
||||
"kafka_trigger",
|
||||
"nats_trigger",
|
||||
"dependency_map",
|
||||
"deployment_metadata",
|
||||
"draft",
|
||||
"favorite",
|
||||
"flow_version",
|
||||
"workspace_runnable_dependencies",
|
||||
"asset",
|
||||
"flow_node",
|
||||
"folder",
|
||||
"input",
|
||||
"raw_app",
|
||||
"resource",
|
||||
"resource_type",
|
||||
"schedule",
|
||||
"script",
|
||||
"token",
|
||||
"usr",
|
||||
"variable",
|
||||
"workspace_env",
|
||||
"workspace_invite",
|
||||
"workspace_key",
|
||||
"workspace_settings",
|
||||
];
|
||||
|
||||
for table in simple_tables {
|
||||
sqlx::query(&format!(
|
||||
"UPDATE {} SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
table
|
||||
))
|
||||
.bind(target)
|
||||
.bind(source)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO flow
|
||||
(workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at)
|
||||
SELECT $1, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at
|
||||
FROM flow WHERE workspace_id = $2",
|
||||
target,
|
||||
source
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!("DELETE FROM flow WHERE workspace_id = $1", source)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO group_ SELECT $1, name, summary, extra_perms FROM group_ WHERE workspace_id = $2",
|
||||
target,
|
||||
source
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE usr_to_group SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
target,
|
||||
source
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!("DELETE FROM group_ WHERE workspace_id = $1", source)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE usage SET id = $1 WHERE id = $2 AND is_workspace = true",
|
||||
target,
|
||||
source
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn migrate_job_tables(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
source: &str,
|
||||
target: &str,
|
||||
) -> Result<()> {
|
||||
sqlx::query!(
|
||||
"UPDATE job_logs SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
target,
|
||||
source
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE job_stats SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
target,
|
||||
source
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE v2_job_queue SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
target,
|
||||
source
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE v2_job SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
target,
|
||||
source
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE v2_job_completed SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
target,
|
||||
source
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct MigrationStatus {
|
||||
source_workspace: String,
|
||||
total_jobs: i64,
|
||||
remaining_jobs: i64,
|
||||
migration_progress: f64,
|
||||
}
|
||||
|
||||
pub(crate) async fn get_migration_status(
|
||||
authed: ApiAuthed,
|
||||
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||
Extension(db): Extension<DB>,
|
||||
) -> JsonResult<MigrationStatus> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
let source_workspace = params
|
||||
.get("source_workspace")
|
||||
.ok_or_else(|| Error::BadRequest("source_workspace parameter required".to_string()))?;
|
||||
|
||||
let source_jobs = sqlx::query_scalar!(
|
||||
"SELECT COALESCE(
|
||||
(SELECT COUNT(*) FROM v2_job WHERE workspace_id = $1) +
|
||||
(SELECT COUNT(*) FROM v2_job_completed WHERE workspace_id = $1) +
|
||||
(SELECT COUNT(*) FROM v2_job_queue WHERE workspace_id = $1),
|
||||
0
|
||||
)",
|
||||
source_workspace
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?
|
||||
.unwrap_or(0);
|
||||
|
||||
let total_jobs = source_jobs;
|
||||
|
||||
let progress = if total_jobs > 0 { 0.0 } else { 100.0 };
|
||||
|
||||
Ok(Json(MigrationStatus {
|
||||
source_workspace: source_workspace.to_string(),
|
||||
total_jobs,
|
||||
remaining_jobs: source_jobs,
|
||||
migration_progress: progress,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// deno-lint-ignore-file no-explicit-any
|
||||
import { Command, colors, log } from "../../../deps.ts";
|
||||
import { GlobalOptions } from "../../types.ts";
|
||||
import { requireLogin } from "../../core/auth.ts";
|
||||
import { setClient } from "../../../deps.ts";
|
||||
import * as wmill from "../../../gen/services.gen.ts";
|
||||
import { getActiveWorkspace } from "./workspace.ts";
|
||||
|
||||
async function migrate(
|
||||
opts: GlobalOptions & {
|
||||
all?: boolean;
|
||||
metadataOnly?: boolean;
|
||||
jobsOnly?: boolean;
|
||||
noArchiveSource?: boolean;
|
||||
},
|
||||
sourceWorkspace: string,
|
||||
targetWorkspace: string
|
||||
) {
|
||||
await requireLogin(opts);
|
||||
|
||||
const workspace = await getActiveWorkspace(opts);
|
||||
if (!workspace) {
|
||||
throw new Error("No active workspace. Please run 'wmill workspace add' first.");
|
||||
}
|
||||
|
||||
setClient(
|
||||
workspace.token,
|
||||
workspace.remote.endsWith("/")
|
||||
? workspace.remote.substring(0, workspace.remote.length - 1)
|
||||
: workspace.remote
|
||||
);
|
||||
|
||||
// Determine migration type from flags
|
||||
let migrationType: "all" | "metadata" | "jobs" = "all";
|
||||
if (opts.jobsOnly) {
|
||||
migrationType = "jobs";
|
||||
} else if (opts.metadataOnly) {
|
||||
migrationType = "metadata";
|
||||
}
|
||||
|
||||
const archiveSource = !opts.noArchiveSource;
|
||||
|
||||
log.info(colors.blue("Starting workspace migration:"));
|
||||
log.info(` Source: ${colors.bold(sourceWorkspace)}`);
|
||||
log.info(` Target: ${colors.bold(targetWorkspace)}`);
|
||||
log.info(` Type: ${colors.bold(migrationType)}`);
|
||||
log.info(` Archive source: ${colors.bold(String(archiveSource))}`);
|
||||
log.info("");
|
||||
|
||||
try {
|
||||
const result = await wmill.migrateWorkspace({
|
||||
requestBody: {
|
||||
source_workspace: sourceWorkspace,
|
||||
target_workspace: targetWorkspace,
|
||||
migration_type: migrationType,
|
||||
archive_source: archiveSource
|
||||
}
|
||||
});
|
||||
|
||||
log.info(colors.green(`✅ ${result}`));
|
||||
|
||||
if (migrationType === "metadata") {
|
||||
log.info("");
|
||||
log.info(
|
||||
colors.yellow(
|
||||
"⚠️ Metadata migration complete. Run with --jobs-only to migrate job history."
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(colors.red(`❌ Migration failed: ${error.message}`));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const command = new Command()
|
||||
.name("migrate")
|
||||
.description("Migrate workspace data from source to target workspace")
|
||||
.arguments("<source_workspace:string> <target_workspace:string>")
|
||||
.option("--all", "Migrate all tables (default)")
|
||||
.option("--metadata-only", "Migrate all tables except job tables")
|
||||
.option("--jobs-only", "Migrate only job tables (v2_job, v2_job_completed, v2_job_queue)")
|
||||
.option("--no-archive-source", "Do not archive source workspace after migration")
|
||||
.action(migrate as any);
|
||||
|
||||
export default command;
|
||||
@@ -5,6 +5,7 @@ import { loginInteractive, tryGetLoginInfo } from "../../core/login.ts";
|
||||
import { colors, Command, Confirm, Input, log, setClient, Table } from "../../../deps.ts";
|
||||
import { requireLogin } from "../../core/auth.ts";
|
||||
import { createWorkspaceFork, deleteWorkspaceFork } from "./fork.ts";
|
||||
import migrate from "./migrate.ts";
|
||||
|
||||
import * as wmill from "../../../gen/services.gen.ts";
|
||||
|
||||
@@ -477,6 +478,7 @@ const command = new Command()
|
||||
.command("delete-fork")
|
||||
.description("Delete a forked workspace and git branch")
|
||||
.arguments("<fork_name:string>")
|
||||
.action(deleteWorkspaceFork as any);
|
||||
.action(deleteWorkspaceFork as any)
|
||||
.command("migrate", migrate);
|
||||
|
||||
export default command;
|
||||
|
||||
@@ -8,14 +8,16 @@
|
||||
import { Pen } from 'lucide-svelte'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
let newName = ''
|
||||
let newId = ''
|
||||
let checking = false
|
||||
let errorId = ''
|
||||
let { open = $bindable(false) } = $props()
|
||||
|
||||
$: newId = newName.toLowerCase().replace(/\s/gi, '-')
|
||||
let newName = $state('')
|
||||
let newId = $derived(newName.toLowerCase().replace(/\s/gi, '-'))
|
||||
let checking = $state(false)
|
||||
let errorId = $state('')
|
||||
|
||||
$: validateName(newId)
|
||||
$effect(() => {
|
||||
validateName(newId)
|
||||
})
|
||||
|
||||
async function validateName(id: string): Promise<void> {
|
||||
checking = true
|
||||
@@ -30,30 +32,35 @@
|
||||
checking = false
|
||||
}
|
||||
|
||||
let loading = false
|
||||
let loading = $state(false)
|
||||
let showMigrationInfo = $state(false)
|
||||
let oldWorkspaceId = $state('')
|
||||
|
||||
async function renameWorkspace() {
|
||||
try {
|
||||
loading = true
|
||||
await WorkspaceService.changeWorkspaceId({
|
||||
workspace: $workspaceStore!,
|
||||
oldWorkspaceId = $workspaceStore!
|
||||
|
||||
await WorkspaceService.migrateWorkspace({
|
||||
requestBody: {
|
||||
new_name: newName,
|
||||
new_id: newId
|
||||
source_workspace_id: $workspaceStore!,
|
||||
target_workspace_name: newName,
|
||||
target_workspace_id: newId,
|
||||
migration_type: 'metadata',
|
||||
disable_workspace: true
|
||||
}
|
||||
})
|
||||
open = false
|
||||
|
||||
sendUserToast(`Renamed workspace to ${newName}. Reloading...`)
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
window.location.href = '/workspace_settings?tab=general&workspace=' + newId
|
||||
open = false
|
||||
showMigrationInfo = true
|
||||
|
||||
sendUserToast(`Workspace metadata migrated to ${newId}`)
|
||||
} catch (err) {
|
||||
sendUserToast(`Error renaming workspace: ${err}`, true)
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
export let open = false
|
||||
</script>
|
||||
|
||||
<div>
|
||||
@@ -113,3 +120,23 @@
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
|
||||
{#if showMigrationInfo}
|
||||
<Alert type="info" title="Migration Status" class="mt-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<p>Workspace metadata has been successfully migrated to <strong>{newId}</strong>.</p>
|
||||
<p class="text-sm text-secondary">
|
||||
Job history has not been migrated yet. Click the button below to sync jobs from the old
|
||||
workspace.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
on:click={() => {
|
||||
window.location.href = `/workspace_settings/migration?source=${oldWorkspaceId}&workspace=${newId}`
|
||||
}}
|
||||
>
|
||||
Sync Jobs
|
||||
</Button>
|
||||
</div>
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<script lang="ts">
|
||||
import { WorkspaceService, type MigrateJobResponse } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import Alert from '../common/alert/Alert.svelte'
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
let { sourceWorkspace }: { sourceWorkspace: string } = $props()
|
||||
|
||||
let migrating = $state(false)
|
||||
let migrationComplete = $state(false)
|
||||
let status = $state<MigrateJobResponse | undefined>(undefined)
|
||||
|
||||
let pollInterval: number | null = null
|
||||
|
||||
const progress = $derived(status ? status.migration_progress : 0)
|
||||
|
||||
async function checkStatus() {
|
||||
try {
|
||||
status = await WorkspaceService.getMigrationStatus({
|
||||
sourceWorkspace
|
||||
})
|
||||
|
||||
if (status && status.remaining_jobs === 0 && status.total_jobs > 0) {
|
||||
migrationComplete = true
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval)
|
||||
pollInterval = null
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to check migration status:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function startMigration() {
|
||||
try {
|
||||
migrating = true
|
||||
|
||||
await WorkspaceService.migrateWorkspace({
|
||||
requestBody: {
|
||||
source_workspace: sourceWorkspace,
|
||||
target_workspace: $workspaceStore!,
|
||||
migration_type: 'jobs',
|
||||
disable_workspace: false
|
||||
}
|
||||
})
|
||||
|
||||
sendUserToast('Job migration completed!')
|
||||
await checkStatus()
|
||||
migrationComplete = true
|
||||
} catch (err) {
|
||||
sendUserToast(`Migration failed: ${err}`, true)
|
||||
} finally {
|
||||
migrating = false
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (!pollInterval) {
|
||||
pollInterval = setInterval(checkStatus, 2000) as any
|
||||
}
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval)
|
||||
pollInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
checkStatus()
|
||||
return () => {
|
||||
stopPolling()
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (migrating) {
|
||||
startPolling()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold mb-2">Workspace Job Migration</h2>
|
||||
<p class="text-sm text-secondary">
|
||||
Migrate job history from <strong class="text-primary">{sourceWorkspace}</strong> to
|
||||
<strong class="text-primary">{$workspaceStore}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if status}
|
||||
<div class="bg-surface-secondary p-6 rounded-md border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm font-medium">Jobs Remaining:</span>
|
||||
<span class="text-lg font-semibold"
|
||||
>{status.remaining_jobs.toLocaleString()} / {status.total_jobs.toLocaleString()}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-4">
|
||||
<div
|
||||
class="bg-blue-500 h-4 rounded-full transition-all duration-300 flex items-center justify-center"
|
||||
style="width: {progress}%"
|
||||
>
|
||||
{#if progress > 10}
|
||||
<span class="text-xs text-white font-medium">{progress.toFixed(1)}%</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if migrating}
|
||||
<div class="flex items-center gap-2 text-sm text-secondary animate-pulse">
|
||||
<div
|
||||
class="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
<span>Migration in progress...</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !migrationComplete && !migrating && status.total_jobs > 0}
|
||||
<Button on:click={startMigration} size="sm" class="w-full">Start Job Migration</Button>
|
||||
{/if}
|
||||
|
||||
{#if migrationComplete}
|
||||
<Alert type="success" title="Migration Complete">
|
||||
<p class="mb-2">All jobs have been successfully migrated!</p>
|
||||
<Button
|
||||
size="sm"
|
||||
on:click={() => {
|
||||
window.location.href = `/workspace_settings?tab=general&workspace=${$workspaceStore}`
|
||||
}}
|
||||
>
|
||||
Back to Workspace Settings
|
||||
</Button>
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
{#if status.total_jobs === 0}
|
||||
<Alert type="info" title="No Jobs to Migrate">
|
||||
<p class="mb-2">The source workspace has no job history to migrate.</p>
|
||||
<Button
|
||||
size="sm"
|
||||
on:click={() => {
|
||||
window.location.href = `/workspace_settings?tab=general&workspace=${$workspaceStore}`
|
||||
}}
|
||||
>
|
||||
Back to Workspace Settings
|
||||
</Button>
|
||||
</Alert>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center p-8">
|
||||
<div class="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores'
|
||||
import WorkspaceMigration from '$lib/components/settings/WorkspaceMigration.svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
|
||||
const sourceWorkspace = $derived($page.url.searchParams.get('source') || '')
|
||||
|
||||
$effect(() => {
|
||||
// Redirect if no source workspace provided
|
||||
if (!sourceWorkspace) {
|
||||
window.location.href = `/workspace_settings?workspace=${$workspaceStore}`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="max-w-4xl mx-auto p-6">
|
||||
{#if sourceWorkspace}
|
||||
<WorkspaceMigration {sourceWorkspace} />
|
||||
{:else}
|
||||
<div class="flex items-center justify-center p-8">
|
||||
<p class="text-secondary">Loading...</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user