mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-25 16:02:11 +00:00
Datatable fork behavior
This commit is contained in:
Generated
+3
-3
@@ -16869,7 +16869,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.653.0"
|
||||
version = "1.654.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -16949,7 +16949,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.653.0"
|
||||
version = "1.654.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -16979,7 +16979,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.653.0"
|
||||
version = "1.654.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
|
||||
@@ -41,8 +41,8 @@ use windmill_common::workspaces::GitRepositorySettings;
|
||||
use windmill_common::workspaces::WorkspaceDeploymentUISettings;
|
||||
use windmill_common::workspaces::{
|
||||
check_user_against_rule, get_datatable_resource_from_db_unchecked, DataTable,
|
||||
DataTableCatalogResourceType, DataTableDatabase, ProtectionRuleKind, ProtectionRules,
|
||||
ProtectionRuleset, RuleCheckResult, WorkspaceGitSyncSettings,
|
||||
DataTableCatalogResourceType, DataTableDatabase, DataTableForkBehavior, ProtectionRuleKind,
|
||||
ProtectionRules, ProtectionRuleset, RuleCheckResult, WorkspaceGitSyncSettings,
|
||||
};
|
||||
use windmill_common::workspaces::{Ducklake, DucklakeCatalogResourceType};
|
||||
use windmill_common::{
|
||||
@@ -1394,18 +1394,37 @@ async fn fork_datatable(
|
||||
Json(req): Json<ForkDatatableRequest>,
|
||||
) -> Result<String> {
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
fork_datatable_inner(
|
||||
&db,
|
||||
&w_id,
|
||||
&req.source_datatable_name,
|
||||
&req.new_datatable_name,
|
||||
&req.new_custom_instance_database_name,
|
||||
req.include_data,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Core logic for forking a single datatable: creates a new instance DB,
|
||||
/// dumps the source schema (and optionally data), imports into the new DB,
|
||||
/// and updates the target workspace's datatable config.
|
||||
async fn fork_datatable_inner(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
source_datatable_name: &str,
|
||||
new_datatable_name: &str,
|
||||
new_custom_instance_database_name: &str,
|
||||
include_data: bool,
|
||||
) -> Result<String> {
|
||||
// Resolve the source datatable to get the original instance DB name
|
||||
let db_resource =
|
||||
get_datatable_resource_from_db_unchecked(&db, &w_id, &req.source_datatable_name).await?;
|
||||
get_datatable_resource_from_db_unchecked(db, w_id, source_datatable_name).await?;
|
||||
let pg_db: PgDatabase = serde_json::from_value(db_resource)
|
||||
.map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?;
|
||||
|
||||
// Interpolate $current_name with the original database name
|
||||
let original_dbname = &pg_db.dbname;
|
||||
let new_dbname = req
|
||||
.new_custom_instance_database_name
|
||||
.replace("$current_name", original_dbname);
|
||||
let new_dbname = new_custom_instance_database_name.replace("$current_name", original_dbname);
|
||||
|
||||
// Create the new custom instance database
|
||||
let wmill_pg_creds = PgDatabase::parse_uri(&get_database_url().await?.as_str().await)?;
|
||||
@@ -1415,13 +1434,13 @@ async fn fork_datatable(
|
||||
"SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_database WHERE datname = $1)",
|
||||
&new_dbname
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.fetch_one(db)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
if !db_exists {
|
||||
sqlx::query(&format!("CREATE DATABASE \"{}\"", &new_dbname))
|
||||
.execute(&db)
|
||||
.execute(db)
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -1472,31 +1491,32 @@ async fn fork_datatable(
|
||||
r#"UPDATE global_settings SET value = jsonb_set(value, '{databases}', (COALESCE(value->'databases', '{}'::jsonb) || to_jsonb($1::json))) WHERE name = 'custom_instance_pg_databases'"#,
|
||||
serde_json::json!({ &new_dbname: status_json })
|
||||
)
|
||||
.execute(&db)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
// Export the schema (and optionally data) from the source BEFORE updating the config
|
||||
let dump = dump_datatable(db, w_id, source_datatable_name, !include_data).await?;
|
||||
|
||||
// Update the forked workspace's datatable config to point to the new database
|
||||
let new_datatable = DataTable {
|
||||
database: DataTableDatabase {
|
||||
resource_type: DataTableCatalogResourceType::Instance,
|
||||
resource_path: new_dbname.clone(),
|
||||
},
|
||||
fork_behavior: DataTableForkBehavior::default(),
|
||||
};
|
||||
let mut datatables = HashMap::new();
|
||||
datatables.insert(req.new_datatable_name.clone(), new_datatable);
|
||||
datatables.insert(new_datatable_name.to_string(), new_datatable);
|
||||
let new_settings = DataTableSettings { datatables };
|
||||
let config: serde_json::Value =
|
||||
serde_json::to_value(new_settings).map_err(|err| Error::internal_err(err.to_string()))?;
|
||||
|
||||
// Export the schema from the source BEFORE updating the config (which points to the new empty DB)
|
||||
let dump = dump_datatable(&db, &w_id, &req.source_datatable_name, !req.include_data).await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_settings SET datatable = $1 WHERE workspace_id = $2",
|
||||
config,
|
||||
&w_id
|
||||
w_id
|
||||
)
|
||||
.execute(&db)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
// Import the dumped schema into the new database
|
||||
@@ -1504,10 +1524,67 @@ async fn fork_datatable(
|
||||
|
||||
Ok(format!(
|
||||
"Forked datatable '{}' as '{}' with new database '{}'",
|
||||
req.source_datatable_name, req.new_datatable_name, new_dbname
|
||||
source_datatable_name, new_datatable_name, new_dbname
|
||||
))
|
||||
}
|
||||
|
||||
/// Fork all datatables from a source workspace into a target workspace,
|
||||
/// respecting each datatable's configured `fork_behavior`.
|
||||
async fn fork_all_datatables(
|
||||
db: &DB,
|
||||
source_workspace_id: &str,
|
||||
target_workspace_id: &str,
|
||||
) -> Result<()> {
|
||||
let datatable_config = sqlx::query_scalar!(
|
||||
"SELECT datatable FROM workspace_settings WHERE workspace_id = $1",
|
||||
source_workspace_id
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
|
||||
let datatables: HashMap<String, DataTable> = match datatable_config {
|
||||
Some(config) => {
|
||||
let settings: DataTableSettings = serde_json::from_value(config)
|
||||
.unwrap_or(DataTableSettings { datatables: HashMap::new() });
|
||||
settings.datatables
|
||||
}
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
for (name, dt) in &datatables {
|
||||
match dt.fork_behavior {
|
||||
DataTableForkBehavior::KeepOriginal => continue,
|
||||
behavior => {
|
||||
let include_data = behavior == DataTableForkBehavior::SchemaAndData;
|
||||
let new_db_name = format!(
|
||||
"__wmfork__{}__$current_name",
|
||||
target_workspace_id.replace('-', "_")
|
||||
);
|
||||
if let Err(e) = fork_datatable_inner(
|
||||
db,
|
||||
target_workspace_id,
|
||||
name,
|
||||
name,
|
||||
&new_db_name,
|
||||
include_data,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to fork datatable '{}' from '{}' to '{}': {}",
|
||||
name,
|
||||
source_workspace_id,
|
||||
target_workspace_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Import a pg_dump output into a target database using tokio_postgres.
|
||||
/// Filters out psql meta-commands (lines starting with `\`) and comment-only lines
|
||||
/// that are not valid SQL but are included in pg_dump's plain-text output.
|
||||
@@ -3746,6 +3823,16 @@ async fn create_workspace_fork(
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
// Fork datatables after the transaction commits (creates external databases)
|
||||
if let Err(e) = fork_all_datatables(&db, &parent_workspace_id, &forked_id).await {
|
||||
tracing::error!(
|
||||
"Failed to fork datatables for workspace '{}': {}",
|
||||
&forked_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
Ok(format!("Created forked workspace {}", &forked_id))
|
||||
}
|
||||
|
||||
|
||||
@@ -23358,6 +23358,13 @@ components:
|
||||
type: string
|
||||
required:
|
||||
- resource_type
|
||||
fork_behavior:
|
||||
type: string
|
||||
enum:
|
||||
- schema_only
|
||||
- schema_and_data
|
||||
- keep_original
|
||||
default: schema_only
|
||||
|
||||
DataTableSchema:
|
||||
type: object
|
||||
|
||||
@@ -381,9 +381,29 @@ pub async fn check_user_against_rule(
|
||||
Ok(RuleCheckResult::Allowed)
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DataTableForkBehavior {
|
||||
SchemaOnly,
|
||||
SchemaAndData,
|
||||
KeepOriginal,
|
||||
}
|
||||
|
||||
impl Default for DataTableForkBehavior {
|
||||
fn default() -> Self {
|
||||
DataTableForkBehavior::SchemaOnly
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct DataTable {
|
||||
pub database: DataTableDatabase,
|
||||
#[serde(default, skip_serializing_if = "is_default_fork_behavior")]
|
||||
pub fork_behavior: DataTableForkBehavior,
|
||||
}
|
||||
|
||||
fn is_default_fork_behavior(v: &DataTableForkBehavior) -> bool {
|
||||
*v == DataTableForkBehavior::SchemaOnly
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
{#if open}
|
||||
<div
|
||||
transition:fadeFast|local
|
||||
class={'fixed top-0 bottom-0 left-0 right-0 z-[5000]'}
|
||||
class={'fixed top-0 bottom-0 left-0 right-0 z-[9999]'}
|
||||
role="dialog"
|
||||
{id}
|
||||
>
|
||||
|
||||
@@ -188,51 +188,6 @@
|
||||
|
||||
forkCreationLoading = false
|
||||
sendUserToast(`Successfully forked workspace ${$workspaceStore} as: wm-fork-${id}`)
|
||||
|
||||
// Check if the source workspace has a "main" datatable and offer to fork it
|
||||
try {
|
||||
const datatables = await WorkspaceService.listDataTables({
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
if (datatables.includes('main')) {
|
||||
const forkDatatableAction = async (includeData: boolean) => {
|
||||
try {
|
||||
await WorkspaceService.forkDatatable({
|
||||
workspace: prefixed_id,
|
||||
requestBody: {
|
||||
source_datatable_name: 'main',
|
||||
new_datatable_name: 'main',
|
||||
new_custom_instance_database_name: `__wmfork__${prefixed_id.replace(/-/g, '_')}__$current_name`,
|
||||
include_data: includeData
|
||||
}
|
||||
})
|
||||
sendUserToast(
|
||||
`Successfully forked "main" datatable${includeData ? ' with data' : ''} to workspace "${prefixed_id}"`
|
||||
)
|
||||
} catch (e) {
|
||||
sendUserToast(`Failed to fork datatable: ${e?.body ?? e}`, 'error')
|
||||
}
|
||||
}
|
||||
sendUserToast(
|
||||
`Fork the main datatable?`,
|
||||
'info',
|
||||
[
|
||||
{
|
||||
label: 'Fork schema only',
|
||||
callback: () => forkDatatableAction(false)
|
||||
},
|
||||
{
|
||||
label: 'Fork schema and data',
|
||||
callback: () => forkDatatableAction(true)
|
||||
}
|
||||
],
|
||||
undefined,
|
||||
30000
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore if we can't list datatables
|
||||
}
|
||||
} else {
|
||||
sendUserToast('No workspace selected, cannot fork non-existent workspace', true)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts" module>
|
||||
export type DataTableForkBehavior = 'schema_only' | 'schema_and_data' | 'keep_original'
|
||||
|
||||
export type DataTableSettingsType = {
|
||||
dataTables: {
|
||||
name: string
|
||||
@@ -6,6 +8,7 @@
|
||||
resource_type: 'postgresql' | 'instance'
|
||||
resource_path?: string | undefined
|
||||
}
|
||||
fork_behavior?: DataTableForkBehavior
|
||||
}[]
|
||||
}
|
||||
|
||||
@@ -15,7 +18,11 @@
|
||||
const s: DataTableSettingsType = { dataTables: [] }
|
||||
if (settings?.datatables) {
|
||||
for (const [name, rest] of Object.entries(settings.datatables)) {
|
||||
s.dataTables.push({ name, ...rest })
|
||||
s.dataTables.push({
|
||||
name,
|
||||
...rest,
|
||||
fork_behavior: (rest as any).fork_behavior ?? 'schema_only'
|
||||
})
|
||||
}
|
||||
}
|
||||
return s
|
||||
@@ -33,8 +40,9 @@
|
||||
throw dataTable.name + ' database cannot be called "windmill"'
|
||||
|
||||
s.datatables[dataTable.name] = {
|
||||
database: dataTable.database
|
||||
}
|
||||
database: dataTable.database,
|
||||
fork_behavior: dataTable.fork_behavior
|
||||
} as any
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -43,7 +51,7 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { Plus } from 'lucide-svelte'
|
||||
import { Plus, Settings } from 'lucide-svelte'
|
||||
|
||||
import Button from '../common/button/Button.svelte'
|
||||
|
||||
@@ -72,6 +80,7 @@
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { clone } from '$lib/utils'
|
||||
import SettingsFooter from './SettingsFooter.svelte'
|
||||
import Label from '../Label.svelte'
|
||||
|
||||
type Props = {
|
||||
dataTableSettings: DataTableSettingsType
|
||||
@@ -252,6 +261,46 @@
|
||||
</div>
|
||||
</div>
|
||||
</Cell>
|
||||
<Cell class="w-12">
|
||||
<Popover contentClasses="p-4 w-64" enableFlyTransition disableFocusTrap>
|
||||
{#snippet trigger()}
|
||||
<Button variant="default" iconOnly size="sm" startIcon={{ icon: Settings }} />
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<Label
|
||||
label="Fork behavior"
|
||||
tooltip="Determines what happens to this datatable when the workspace is forked."
|
||||
>
|
||||
<Select
|
||||
items={[
|
||||
{ value: 'schema_only', label: 'Fork schema only' },
|
||||
{ value: 'schema_and_data', label: 'Fork schema and data' },
|
||||
{ value: 'keep_original', label: 'Keep original datatable' }
|
||||
]}
|
||||
bind:value={
|
||||
() => dataTable.fork_behavior ?? 'schema_only',
|
||||
(v) => {
|
||||
if (v === 'schema_and_data') {
|
||||
confirmationModal
|
||||
.ask({
|
||||
title: 'Fork schema and data',
|
||||
children:
|
||||
'This will copy ALL data when the workspace is forked, which may take a long time and use significant storage space. Are you sure?',
|
||||
confirmationText: 'Confirm'
|
||||
})
|
||||
.then((confirmed) => {
|
||||
if (confirmed) dataTable.fork_behavior = v
|
||||
})
|
||||
} else {
|
||||
dataTable.fork_behavior = v
|
||||
}
|
||||
}
|
||||
}
|
||||
/>
|
||||
</Label>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
</Cell>
|
||||
<Cell class="w-12">
|
||||
{#if dirtyMap[dataTable.name]}
|
||||
<Popover
|
||||
|
||||
Reference in New Issue
Block a user