feat: isolate fork object storage under a per-fork prefix (#11829)

* feat: route the python SDK's S3 client settings through the S3 proxy

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: drop the boto3 config key and expose the proxied bucket to polars

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: update ee-repo-ref

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: proxy S3 settings only for server-signed tokens and keep tls verify

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: update ee-repo-ref

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: isolate fork object storage under a per-fork prefix with read-through

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: guard fork storage isolation against traversal, shared lakes and orphaning

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: serialize fork purges with same-id creation and guard shared middle forks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: drop fork storage mappings on every replica and lock fork deletion against child creation

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: update ee-repo-ref

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: update ee-repo-ref

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: update ee-repo-ref

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: update ee-repo-ref to 455681e55eb7cdfcfdb7cd79cbae75b333e07943

This commit updates the EE repository reference after PR #893 was merged in windmill-ee-private.

Previous ee-repo-ref: 93f26e3cf816e4eb97b7d4dbbb5fc97fdf9821af

New ee-repo-ref: 455681e55eb7cdfcfdb7cd79cbae75b333e07943

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
Ruben Fiszel
2026-10-16 22:21:38 +02:00
committed by GitHub
co-authored by Claude Opus 5.5 windmill-internal-app[bot]
parent e0138a7374
commit 9375805fdf
26 changed files with 1391 additions and 18 deletions
+3
View File
@@ -33,6 +33,9 @@ Open-source platform for internal tools, workflows, API integrations, background
administers behind `external_instance` data tables and Ducklake catalogs: its invariants (one
lifecycle lock, managed-object markers, the setup gate, per-cluster roles, fork copy ownership)
and how to run one locally
- **Fork object storage**: `docs/fork-object-storage.md` — how a fork's object keys map under
`__wm_forks/<segment>/` with read-through to its ancestors, where the mapping must be applied,
and why `__wm_forks/` is reachable only through a workspace's own chain
- **Data table viewer roles**: `docs/datatable-viewer-roles.md`, on what a connection naming no
role holds: the default role as a floor, whose roles are added (the job's own, or its viewer's
for an inline app runnable that opts in), why an admin's roles are not added, the per-role-set
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS (SELECT 1 FROM fork_storage_namespace WHERE workspace_id = ANY($1))\n AND EXISTS (SELECT 1 FROM workspace WHERE parent_workspace_id = $2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"TextArray",
"Text"
]
},
"nullable": [
null
]
},
"hash": "45b73345f2009122142f3410736b2d57a07e2047a704d9a7d8eab3416ff1d9d5"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS (SELECT 1 FROM fork_storage_namespace WHERE workspace_id = $1)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "4640ac69b297127cd0f5c369fa4a49857237cd57134dbc467b53601c1ec942d2"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT storage, storage_ref FROM fork_storage_namespace WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "storage",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "storage_ref",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "7796f40e349bf0771cceff037fa2224ab49659213638230be7f9acbf82d8e1b0"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT large_file_storage, ducklake FROM workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "large_file_storage",
"type_info": "Jsonb"
},
{
"ordinal": 1,
"name": "ducklake",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true,
true
]
},
"hash": "98aebe8ead7d3f6e84ad88e6d006309b24b658cac3c99d8d0bdb84cdd00c413a"
}
@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT c.id AS \"id!\", ws.large_file_storage,\n EXISTS (SELECT 1 FROM fork_storage_namespace f\n WHERE f.workspace_id = c.id AND f.storage = $2) AS \"isolated!\"\n FROM unnest($1::text[]) WITH ORDINALITY AS c(id, ord)\n LEFT JOIN workspace_settings ws ON ws.workspace_id = c.id\n ORDER BY c.ord",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id!",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "large_file_storage",
"type_info": "Jsonb"
},
{
"ordinal": 2,
"name": "isolated!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"TextArray",
"Text"
]
},
"nullable": [
null,
true,
null
]
},
"hash": "a37dec4e86f537880d45da01ce872ae5b7858a087cb4eb5869573fff69c1c6b9"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS (SELECT 1 FROM workspace WHERE id = $1)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "bdc39bdd63c38217834a0c0d858f6db33d63ff7bf55329452dc84dae2560c23e"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM fork_storage_namespace WHERE workspace_id = $1 AND storage = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "c37f34ea3603af1496825ec54a3415fe710c5fbbc7380c5088c7069fa2caf990"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO fork_storage_namespace (workspace_id, storage, storage_ref)\n VALUES ($1, $2, $3)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "eb6dc43ac53a853eab20ef804a092249e32b1184a146463094289c604bbd0332"
}
+1 -1
View File
@@ -1 +1 @@
cd3395013d8484cd3917cdb6ee7837b6ac91e577
455681e55eb7cdfcfdb7cd79cbae75b333e07943
@@ -0,0 +1 @@
DROP TABLE IF EXISTS fork_storage_namespace;
@@ -0,0 +1,24 @@
-- Workspace storages a fork/dev workspace isolates from its parent: its writes land under
-- `__wm_forks/<fork segment>/` of that storage, and its reads fall back to the parent's keys.
-- One row per (fork, storage) chosen isolated at fork creation; forks created before this
-- table have none and keep sharing the parent's keys.
--
-- Deliberately NO foreign key to workspace(id), like fork_ducklake_namespace: rows are also the
-- cleanup ledger and must OUTLIVE the workspace row when deleting the prefix fails after the
-- delete commits. A recreated same-id fork would otherwise read the deleted fork's objects
-- under the deterministic prefix; fork creation retries leftover rows and refuses to proceed
-- while they cannot be cleaned.
CREATE TABLE fork_storage_namespace (
workspace_id VARCHAR(50) NOT NULL,
-- '' = the default storage, else a `secondary_storage` name.
storage TEXT NOT NULL,
-- The storage's resolved identity at fork creation (`<lfs type>:<resource path or root
-- path>`), so cleanup deletes the prefix from the storage the fork wrote to even if the
-- name is repointed later.
storage_ref TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (workspace_id, storage)
);
GRANT ALL ON fork_storage_namespace TO windmill_user;
GRANT ALL ON fork_storage_namespace TO windmill_admin;
@@ -515,6 +515,11 @@ struct CreateWorkspaceFork {
/// fork namespace + read-defer.
#[serde(default)]
shared_ducklakes: Vec<String>,
/// Workspace storages the fork reads and writes at the parent's keys (`_default_` for the
/// primary one). Every other storage is isolated: the fork writes under its own prefix
/// and reads through to the parent's objects.
#[serde(default)]
shared_storages: Vec<String>,
/// Create the fork as a persistent dev workspace: the id is not required to carry the
/// `wm-fork-` prefix, and at most one dev workspace may exist per parent.
#[serde(default)]
@@ -2131,6 +2136,38 @@ async fn edit_dbt_warehouses(
Ok("Updated the workspace's dbt warehouses".to_string())
}
/// A fork's isolated storage is purged at deletion from the storage recorded at creation: under
/// another location, its objects would outlive the fork untracked, and a same-id fork would
/// read them.
async fn reject_isolated_storage_repoint(
tx: &mut Transaction<'_, Postgres>,
w_id: &str,
new_lfs: &serde_json::Value,
) -> Result<()> {
let rows = sqlx::query!(
"SELECT storage, storage_ref FROM fork_storage_namespace WHERE workspace_id = $1",
w_id
)
.fetch_all(&mut **tx)
.await?;
for row in rows {
let new_ref = windmill_common::fork_storage::lfs_entry(new_lfs, &row.storage)
.and_then(windmill_common::workspaces::lfs_entry_storage_ref);
if new_ref.is_some_and(|r| r != row.storage_ref) {
let name = if row.storage.is_empty() {
windmill_types::s3::DEFAULT_STORAGE
} else {
row.storage.as_str()
};
return Err(Error::BadRequest(format!(
"storage `{name}` is isolated from the parent in this fork, so where it points \
cannot be changed"
)));
}
}
Ok(())
}
async fn edit_large_file_storage_config(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -2198,6 +2235,7 @@ async fn edit_large_file_storage_config(
let serialized_lfs_config =
serde_json::to_value::<LargeFileStorageWithSecondary>(lfs_config)
.map_err(|err| Error::internal_err(err.to_string()))?;
reject_isolated_storage_repoint(&mut tx, &w_id, &serialized_lfs_config).await?;
// A workspace whose AI session backups fell back to the instance store leaves it
// here: the generation moves on, so nothing it left in any instance store is read
@@ -9571,6 +9609,25 @@ async fn create_workspace_fork(
i.msg
);
}
// Unlike a dropped lake's files, a deleted fork's objects are live to a same-id fork: it
// reads its own prefix first.
let leftover_storages = crate::workspaces_extra::prepare_fork_storage_purges(
&db,
&nw.id,
Some(&parent_workspace_id),
)
.await?;
let storage_issues =
crate::workspaces_extra::purge_fork_storages_unless_live(&db, &nw.id, leftover_storages)
.await?;
if !storage_issues.is_empty() {
return Err(Error::BadRequest(format!(
"a previously deleted workspace with id '{}' left objects in its fork prefix that \
could not be deleted: {}; retry once the storage is reachable again",
nw.id,
storage_issues.join("; ")
)));
}
#[cfg(not(feature = "enterprise"))]
_check_nb_of_workspaces(&db).await?;
@@ -9964,6 +10021,97 @@ fn copy_requests(
.collect()
}
/// Record every storage of the fork's cloned settings as isolated, except `shared` ones.
/// Isolation is a per-fork-creation choice, never inherited: a fork of a shared fork is
/// isolated unless it asks otherwise.
async fn isolate_fork_storages(
tx: &mut Transaction<'_, Postgres>,
fork_id: &str,
shared: &[String],
shared_ducklakes: &[String],
) -> Result<()> {
use windmill_common::fork_storage::{lfs_entry, registry_storage_name};
let settings = sqlx::query!(
"SELECT large_file_storage, ducklake FROM workspace_settings WHERE workspace_id = $1",
fork_id
)
.fetch_optional(&mut **tx)
.await?;
let (lfs, ducklake) = settings
.map(|s| (s.large_file_storage, s.ducklake))
.unwrap_or_default();
let lfs = lfs.filter(|v| v.get("type").is_some());
// A shared lake keeps the parent's data path: its files must land at the parent's keys, or
// the parent's catalog would point at objects in the fork's prefix, purged with the fork.
if lfs.is_some() {
for lake in shared_ducklakes {
let lake_storage = ducklake
.as_ref()
.and_then(|d| {
d.get("ducklakes")?
.get(lake)?
.get("storage")?
.get("storage")
})
.and_then(|s| s.as_str());
let storage = registry_storage_name(lake_storage);
if !shared
.iter()
.any(|s| registry_storage_name(Some(s)) == storage)
{
return Err(Error::BadRequest(format!(
"ducklake `{lake}` is shared with the parent, so the storage holding its \
data (`{}`) must be shared too",
lake_storage.unwrap_or(windmill_types::s3::DEFAULT_STORAGE)
)));
}
}
}
let mut storages: Vec<&str> = vec![];
if let Some(lfs) = &lfs {
storages.push("");
if let Some(secondary) = lfs.get("secondary_storage").and_then(|s| s.as_object()) {
storages.extend(secondary.keys().map(String::as_str));
}
}
for name in shared {
if !storages.contains(&registry_storage_name(Some(name))) {
return Err(Error::BadRequest(format!(
"cannot share storage `{name}` with the parent: no such storage in the workspace \
settings"
)));
}
}
let Some(lfs) = &lfs else {
return Ok(());
};
for storage in storages {
if shared
.iter()
.any(|s| registry_storage_name(Some(s)) == storage)
{
continue;
}
let Some(storage_ref) =
lfs_entry(lfs, storage).and_then(windmill_common::workspaces::lfs_entry_storage_ref)
else {
continue;
};
sqlx::query!(
"INSERT INTO fork_storage_namespace (workspace_id, storage, storage_ref)
VALUES ($1, $2, $3)",
fork_id,
storage,
storage_ref
)
.execute(&mut **tx)
.await?;
}
Ok(())
}
/// Write the fork, with its data tables pointed at `copies`.
async fn write_workspace_fork(
db: DB,
@@ -10229,6 +10377,14 @@ async fn write_workspace_fork(
}
}
isolate_fork_storages(
&mut tx,
&forked_id,
&nw.shared_storages,
&nw.shared_ducklakes,
)
.await?;
// Lock the parent ("prod") so edits are funneled through this dev workspace.
let locked_prod = nw.is_dev_workspace && (nw.lock_prod_deploy || nw.lock_prod_forking);
if locked_prod {
@@ -10276,6 +10432,9 @@ async fn write_workspace_fork(
.await?;
}
tx.commit().await?;
// Same for a mapping cached before the isolation rows were visible: identity, writing the
// root's keys.
windmill_common::fork_storage::invalidate_fork_storage_keys_cache(&forked_id);
// A pre-creation lookup could have cached an EMPTY ancestor chain for this id, which
// would bypass ducklake fork isolation for the TTL. The same lookup could have cached the id
@@ -54,6 +54,23 @@ pub(crate) async fn change_workspace_id(
require_admin(authed.is_admin, &authed.username)?;
}
// An isolating fork's objects live under a prefix derived from its id: under a new id it
// would see none of them, and descendants would lose them from their read-through.
let isolates_storage = sqlx::query_scalar!(
"SELECT EXISTS (SELECT 1 FROM fork_storage_namespace WHERE workspace_id = $1)",
&old_id
)
.fetch_one(&db)
.await?
.unwrap_or(false);
if isolates_storage {
return Err(Error::BadRequest(
"This workspace isolates its object storage from its parent under a prefix derived \
from its id, so its id cannot be changed"
.to_string(),
));
}
let mut tx = db.begin().await?;
// The settings copy below carries every data table entry to the new id, which fork cleanup of
@@ -1109,6 +1126,34 @@ pub(crate) async fn delete_workspace(
tracing::warn!("deleting workspace {w_id}: preparing ducklake cleanup: {e:#}");
vec![]
});
// A child fork reads through to this fork's prefix, or to the one this fork shares, and a
// child sharing the storage writes into it: deleting this link would destroy the child's
// data, or leave its chain without the isolating fork, writing the root's own keys. Under the
// lock forking this workspace takes, so no child can commit between the check and the delete.
windmill_common::workspaces::lock_fork_datatables(&mut tx, &w_id).await?;
let chain = windmill_common::workspaces::workspace_with_fork_ancestors(&db, &w_id).await?;
let isolating_with_children = sqlx::query_scalar!(
"SELECT EXISTS (SELECT 1 FROM fork_storage_namespace WHERE workspace_id = ANY($1))
AND EXISTS (SELECT 1 FROM workspace WHERE parent_workspace_id = $2)",
&chain,
&w_id
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(false);
if isolating_with_children {
return Err(Error::BadRequest(format!(
"the forks of {w_id} reach its fork chain's isolated object storage through it: \
delete them first"
)));
}
// Nothing can reach a deleted fork's prefix once it is gone, so it is always purged.
let fork_storage_purges = prepare_fork_storage_purges(&db, &w_id, None)
.await
.unwrap_or_else(|e| {
tracing::warn!("deleting workspace {w_id}: preparing storage purge: {e:#}");
vec![]
});
sqlx::query!("DELETE FROM ai_agent_memory WHERE workspace_id = $1", &w_id)
.execute(&mut *tx)
@@ -1379,6 +1424,14 @@ pub(crate) async fn delete_workspace(
e.msg
);
}
match purge_fork_storages_unless_live(&db, &w_id, fork_storage_purges).await {
Ok(errors) => {
for e in errors {
tracing::warn!("deleted workspace {w_id}: storage purge: {e}");
}
}
Err(e) => tracing::warn!("deleted workspace {w_id}: storage purge: {e:#}"),
}
if let Some(parent) = dev_lock_parent {
windmill_common::workspaces::invalidate_protection_rules_cache(&parent);
@@ -2308,6 +2361,109 @@ async fn delete_fork_ducklake_data(
))
}
/// A storage a fork isolated, resolved while its `$res:` still exists.
pub(crate) struct PreparedForkStoragePurge {
storage: String,
store: std::result::Result<(windmill_types::s3::LargeFileStorage, serde_json::Value), String>,
}
/// Read-only: one entry per `fork_storage_namespace` row of `w_id`. `res_fallback_w_id` as in
/// [`prepare_fork_ducklake_cleanups`].
pub(crate) async fn prepare_fork_storage_purges(
db: &DB,
w_id: &str,
res_fallback_w_id: Option<&str>,
) -> Result<Vec<PreparedForkStoragePurge>> {
let rows = sqlx::query!(
"SELECT storage, storage_ref FROM fork_storage_namespace WHERE workspace_id = $1",
w_id
)
.fetch_all(db)
.await?;
let mut prepared = Vec::with_capacity(rows.len());
for row in rows {
let storage = Some(row.storage.as_str()).filter(|s| !s.is_empty());
let store = resolve_fork_storage(db, w_id, res_fallback_w_id, storage, &row.storage_ref)
.await
.map_err(|e| e.to_string());
prepared.push(PreparedForkStoragePurge { storage: row.storage, store });
}
Ok(prepared)
}
/// [`purge_fork_storages`] under the lock same-id fork creation takes, unless a workspace with
/// that id exists again: the rows are then the new fork's, and its creation already purged the
/// ones it found.
pub(crate) async fn purge_fork_storages_unless_live(
db: &DB,
w_id: &str,
prepared: Vec<PreparedForkStoragePurge>,
) -> Result<Vec<String>> {
let mut lock = db.begin().await?;
sqlx::query("SELECT pg_advisory_xact_lock(hashtext('fork:' || $1))")
.bind(w_id)
.execute(&mut *lock)
.await?;
let live = sqlx::query_scalar!(
"SELECT EXISTS (SELECT 1 FROM workspace WHERE id = $1)",
w_id
)
.fetch_one(&mut *lock)
.await?
.unwrap_or(false);
let errors = if live {
vec![]
} else {
purge_fork_storages(db, w_id, prepared).await
};
lock.commit().await?;
Ok(errors)
}
/// Delete everything under the fork's `__wm_forks/<segment>/` in each prepared storage, then its
/// registry row. Returns one message per storage that could not be purged; its row survives.
async fn purge_fork_storages(
db: &DB,
w_id: &str,
prepared: Vec<PreparedForkStoragePurge>,
) -> Vec<String> {
let area = format!(
"{}/{}",
windmill_common::workspaces::FORK_DUCKLAKE_DATA_DIR,
windmill_common::workspaces::fork_data_dir_segment(w_id)
);
let mut errors = vec![];
for p in prepared {
let name = if p.storage.is_empty() {
"default storage"
} else {
p.storage.as_str()
};
let purged = match p.store {
Ok((lfs, value)) => delete_fork_ducklake_data(lfs, value, &area)
.await
.map_err(|e| e.to_string()),
Err(e) => Err(e),
};
if let Err(e) = purged {
errors.push(format!("{name}: {e}"));
continue;
}
if let Err(e) = sqlx::query!(
"DELETE FROM fork_storage_namespace WHERE workspace_id = $1 AND storage = $2",
w_id,
p.storage
)
.execute(db)
.await
{
errors.push(format!("{name}: removing registry row: {e}"));
}
}
windmill_common::fork_storage::invalidate_fork_storage_keys_cache(w_id);
errors
}
/// Destroying an ATTACHED dev workspace (or its data environments) must be a prod-admin
/// action, not just the dev's own owner (dev ownership can diverge from prod's) — mirrors
/// detach_dev_workspace. Shared by `delete_workspace` and `drop_forked_ducklake_namespaces`
+5
View File
@@ -36678,6 +36678,11 @@ components:
items:
type: string
description: "Lake names the fork SHARES with the parent (reads and writes the parent's lake directly). Every lake not listed gets the default isolated fork namespace with read-defer to the parent."
shared_storages:
type: array
items:
type: string
description: "Workspace storages the fork SHARES with the parent (`_default_` for the primary one): it reads and writes the parent's objects directly. Every storage not listed is isolated: the fork writes under its own prefix and reads through to the parent's objects."
is_dev_workspace:
type: boolean
description: "Create the fork as a persistent dev workspace (id not required to carry the wm-fork- prefix; at most one per parent)"
+50 -9
View File
@@ -5317,10 +5317,14 @@ async fn upload_s3_file_from_app(
let user_db = UserDB::new(db.clone());
let (s3_resource_opt, file_key, on_behalf_of_email, permissioned_as, username) = if policy
.as_ref()
.is_some_and(|p| p.s3_inputs.is_some())
{
let (
s3_resource_opt,
file_key,
on_behalf_of_email,
permissioned_as,
username,
is_workspace_storage,
) = if policy.as_ref().is_some_and(|p| p.s3_inputs.is_some()) {
let policy = policy.unwrap();
let s3_inputs = policy.s3_inputs.as_ref().unwrap();
@@ -5389,6 +5393,7 @@ async fn upload_s3_file_from_app(
email,
permissioned_as,
username,
false,
)
} else {
return Err(Error::BadRequest(
@@ -5417,6 +5422,7 @@ async fn upload_s3_file_from_app(
email,
permissioned_as,
username,
false,
)
}
} else {
@@ -5434,7 +5440,14 @@ async fn upload_s3_file_from_app(
None,
)
.await?;
(s3_resource_opt, file_key, email, permissioned_as, username)
(
s3_resource_opt,
file_key,
email,
permissioned_as,
username,
true,
)
}
} else {
return Err(Error::BadRequest(
@@ -5468,7 +5481,14 @@ async fn upload_s3_file_from_app(
)
.await?;
(s3_resource_opt, file_key, email, permissioned_as, username)
(
s3_resource_opt,
file_key,
email,
permissioned_as,
username,
true,
)
}
} else {
// backward compatibility (no policy)
@@ -5500,6 +5520,7 @@ async fn upload_s3_file_from_app(
on_behalf_of_email,
permissioned_as,
username,
false,
)
} else {
let db_with_opt_authed = DbWithOptAuthed::from_authed(&authed, db.clone(), None);
@@ -5519,6 +5540,7 @@ async fn upload_s3_file_from_app(
on_behalf_of_email,
permissioned_as,
username,
true,
)
}
} else {
@@ -5529,7 +5551,19 @@ async fn upload_s3_file_from_app(
let s3_resource = s3_resource_opt.ok_or(Error::internal_err(
"No files storage resource defined at the workspace level".to_string(),
))?;
let s3_client = windmill_object_store::build_object_store_client(&s3_resource).await?;
// The quota sizes an overwrite by the fork's own copy: a fork's write never replaces the
// ancestor's object its reads fall through to.
type Client = std::sync::Arc<dyn windmill_object_store::object_store_reexports::ObjectStore>;
let (s3_client, _quota_client): (Client, Client) = if is_workspace_storage {
let store =
windmill_object_store::fork_store::workspace_fork_store(&db, &w_id, None, &s3_resource)
.await?;
let own_only = store.own_only();
(store, own_only)
} else {
let client = windmill_object_store::build_object_store_client(&s3_resource).await?;
(client.clone(), client)
};
let options = Attributes::from_iter(vec![
(
@@ -5562,7 +5596,8 @@ async fn upload_s3_file_from_app(
.get(http::header::CONTENT_LENGTH)
.and_then(|h| h.to_str().ok())
.and_then(|s| s.parse::<i64>().ok());
let budget = ce_upload_budget(&db, &w_id, &s3_client, &file_key, content_length).await?;
let budget =
ce_upload_budget(&db, &w_id, &_quota_client, &file_key, content_length).await?;
(Some(budget.max_size), budget.existing_size)
} else {
(None, 0)
@@ -5644,6 +5679,7 @@ async fn delete_s3_file_from_app(
)
.await?;
let is_workspace_storage = s3_resource_path.is_none();
let s3_resource = if let Some(s3_resource_path) = s3_resource_path {
let db_with_opt_authed =
DbWithOptAuthed::from_authed(&on_behalf_authed, db.clone(), Some(user_db.clone()));
@@ -5672,7 +5708,12 @@ async fn delete_s3_file_from_app(
))?
};
let s3_client = windmill_object_store::build_object_store_client(&s3_resource).await?;
let s3_client = if is_workspace_storage {
windmill_object_store::fork_store::workspace_object_store(&db, &w_id, None, &s3_resource)
.await?
} else {
windmill_object_store::build_object_store_client(&s3_resource).await?
};
s3_client.delete(&path).await.map_err(|err| {
tracing::error!("Error deleting file: {:?}", err);
+2 -2
View File
@@ -139,13 +139,13 @@ impl RawWebhookArgs {
get_random_file_name, get_workspace_s3_resource, upload_file_internal,
};
use futures::TryStreamExt;
use windmill_object_store::build_object_store_client;
use windmill_object_store::fork_store::workspace_object_store;
use windmill_object_store::object_store_reexports::{Attribute, Attributes};
let (_, s3_resource) = get_workspace_s3_resource(authed, db, None, w_id, None).await?;
if let Some(s3_resource) = s3_resource {
let s3_client = build_object_store_client(&s3_resource).await?;
let s3_client = workspace_object_store(db, w_id, None, &s3_resource).await?;
let mut parts: Vec<(String, MultipartValue)> = Vec::new();
@@ -34,10 +34,7 @@ use windmill_store::resources::try_get_resource_from_db_as;
use windmill_trigger::TriggerMode;
#[cfg(feature = "parquet")]
use {
crate::job_helpers_oss::get_workspace_s3_resource,
windmill_object_store::build_object_store_client,
};
use crate::job_helpers_oss::get_workspace_s3_resource;
/// Which router a request's CORS decision must be looked up in.
///
@@ -624,7 +621,13 @@ async fn route_job(
let s3_resource = s3_resource_opt.ok_or_else(|| {
windmill_object_store::workspace_storage_misconfigured(config.storage.as_deref())
})?;
let s3_client = build_object_store_client(&s3_resource).await?;
let s3_client = windmill_object_store::fork_store::workspace_object_store(
&db,
&trigger.workspace_id,
config.storage.as_deref(),
&s3_resource,
)
.await?;
let path = if trigger.is_static_website {
let subpath = params
+394
View File
@@ -0,0 +1,394 @@
//! Where a workspace's object keys live in a storage it may share with its fork ancestors.
//!
//! A fork that isolates a storage (a `fork_storage_namespace` row) writes logical key `k` at
//! `__wm_forks/<fork segment>/k` and reads `k` from the first of its own and its ancestors'
//! locations that has it. Everything under `__wm_forks/` is reachable only through the areas of
//! the workspace's own chain, which is also what DuckLake's fork namespaces live in: those keys
//! arrive already prefixed and pass through unchanged.
use std::sync::Arc;
use quick_cache::sync::Cache;
use crate::error::{Error, Result};
use crate::workspaces::{
fork_data_dir_segment, lfs_entry_storage_ref, workspace_with_fork_ancestors,
FORK_DUCKLAKE_DATA_DIR,
};
use crate::DB;
/// How one workspace's logical keys map to physical keys in one of its storages.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ForkStorageKeys {
/// Prefix of every write: `__wm_forks/<segment>/` in an isolating fork, `""` otherwise.
write_prefix: String,
/// Prefixes a read tries in order, nearest first.
read_prefixes: Vec<String>,
/// `__wm_forks/<segment>/` of this workspace, writable as is.
own_area: String,
/// The fork areas of this workspace and its ancestors, readable as is.
readable_areas: Vec<String>,
}
/// A listing request translated to the physical storage.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListMapping {
pub physical_prefix: String,
/// Stripped from each physical key to get the logical key returned to the caller.
strip: String,
/// Logical keys under `__wm_forks/` are dropped: reached only by listing that area itself.
hide_fork_area: bool,
}
impl ListMapping {
/// Whether listed keys differ from what the caller may see: false lets a listing response
/// be passed through untouched.
pub fn rewrites(&self) -> bool {
let fork_dir = format!("{FORK_DUCKLAKE_DATA_DIR}/");
!self.strip.is_empty()
|| (self.hide_fork_area && fork_dir.starts_with(&self.physical_prefix))
}
/// The physical form of a logical key of this listing, e.g. a start-after cursor.
pub fn physical_key(&self, logical: &str) -> String {
format!("{}{logical}", self.strip)
}
/// The logical key for a listed physical key, or None when the caller must not see it.
pub fn logical_key(&self, physical: &str) -> Option<String> {
let logical = physical.strip_prefix(self.strip.as_str())?;
if self.hide_fork_area && in_fork_area(logical) {
return None;
}
Some(logical.to_string())
}
}
fn in_fork_area(key: &str) -> bool {
key == FORK_DUCKLAKE_DATA_DIR
|| key
.strip_prefix(FORK_DUCKLAKE_DATA_DIR)
.is_some_and(|rest| rest.starts_with('/'))
}
fn fork_area(w_id: &str) -> String {
format!("{FORK_DUCKLAKE_DATA_DIR}/{}/", fork_data_dir_segment(w_id))
}
fn forbidden(key: &str) -> Error {
Error::NotAuthorized(format!(
"`{key}` is in another workspace's fork namespace (`{FORK_DUCKLAKE_DATA_DIR}/`)"
))
}
/// The S3 proxy forwards mapped keys in a URL, where clients and signers collapse `.` and `..`
/// segments: `<fork area>../../k` would reach the parent's `k`.
fn reject_dot_segments(key: &str) -> Result<&str> {
let key = key.trim_start_matches('/');
if key.split('/').any(|s| s == "." || s == "..") {
return Err(Error::BadRequest(format!(
"`{key}` has a `.` or `..` path segment, which object storage keys may not use here"
)));
}
Ok(key)
}
/// One workspace of a fork chain, nearest first, as the storage mapping needs it.
#[derive(Debug, Clone)]
pub struct ChainLink {
pub w_id: String,
/// Identity of the storage in this workspace's settings (`lfs_entry_storage_ref`).
pub storage_ref: Option<String>,
pub isolated: bool,
}
impl ForkStorageKeys {
/// The mapping of `chain[0]`. Reads stop at the first ancestor whose storage is a different
/// one: its keys are not in this bucket.
pub fn from_chain(chain: &[ChainLink]) -> Self {
let own = &chain[0];
let mut read_prefixes = vec![];
let mut reached_root = true;
for link in chain {
if link.storage_ref != own.storage_ref {
reached_root = false;
break;
}
if link.isolated {
read_prefixes.push(fork_area(&link.w_id));
}
}
if reached_root || read_prefixes.is_empty() {
read_prefixes.push(String::new());
}
Self {
write_prefix: read_prefixes[0].clone(),
read_prefixes,
own_area: fork_area(&own.w_id),
readable_areas: chain.iter().map(|l| fork_area(&l.w_id)).collect(),
}
}
/// Whether logical keys map to themselves (only the fork-area boundary applies).
pub fn is_identity(&self) -> bool {
self.write_prefix.is_empty() && self.read_prefixes.len() == 1
}
/// The same mapping with reads confined to the workspace's own copy, for sizing what a
/// write replaces: an ancestor's object is not overwritten by it.
pub fn own_only(&self) -> Self {
Self { read_prefixes: vec![self.write_prefix.clone()], ..self.clone() }
}
/// The physical key a write (put, multipart, delete, copy destination) of `key` goes to.
pub fn write_key(&self, key: &str) -> Result<String> {
let key = reject_dot_segments(key)?;
if in_fork_area(key) {
return if key.starts_with(&self.own_area) {
Ok(key.to_string())
} else {
Err(forbidden(key))
};
}
Ok(format!("{}{key}", self.write_prefix))
}
/// The physical keys a read of `key` tries, in order, until one exists.
pub fn read_keys(&self, key: &str) -> Result<Vec<String>> {
let key = reject_dot_segments(key)?;
if in_fork_area(key) {
return if self.readable_areas.iter().any(|a| key.starts_with(a)) {
Ok(vec![key.to_string()])
} else {
Err(forbidden(key))
};
}
Ok(self
.read_prefixes
.iter()
.map(|p| format!("{p}{key}"))
.collect())
}
/// A listing of `prefix` covers this workspace's own objects only: an isolating fork does
/// not list its ancestors' keys.
pub fn list(&self, prefix: &str) -> Result<ListMapping> {
let prefix = reject_dot_segments(prefix)?;
if in_fork_area(prefix) {
if !self.readable_areas.iter().any(|a| prefix.starts_with(a)) {
return Err(forbidden(prefix));
}
return Ok(ListMapping {
physical_prefix: prefix.to_string(),
strip: String::new(),
hide_fork_area: false,
});
}
Ok(ListMapping {
physical_prefix: format!("{}{prefix}", self.write_prefix),
strip: self.write_prefix.clone(),
hide_fork_area: true,
})
}
}
lazy_static::lazy_static! {
static ref FORK_STORAGE_KEYS_CACHE: Cache<(String, String), (Arc<ForkStorageKeys>, i64)> =
Cache::new(1000);
}
/// Drop the cached mappings of a workspace whose isolation rows or lineage just changed.
pub fn invalidate_fork_storage_keys_cache(w_id: &str) {
FORK_STORAGE_KEYS_CACHE.retain(|(w, _), _| w != w_id);
}
pub fn clear_fork_storage_keys_cache() {
FORK_STORAGE_KEYS_CACHE.clear();
}
/// `storage` as the registry keys it: '' for the default storage.
pub fn registry_storage_name(storage: Option<&str>) -> &str {
match storage {
None | Some("") | Some(windmill_types::s3::DEFAULT_STORAGE) => "",
Some(s) => s,
}
}
/// The storage entry named `storage` (registry form) in a `large_file_storage` value.
pub fn lfs_entry<'a>(lfs: &'a serde_json::Value, storage: &str) -> Option<&'a serde_json::Value> {
if storage.is_empty() {
Some(lfs)
} else {
lfs.get("secondary_storage")?.get(storage)
}
}
/// How `w_id`'s keys map in `storage`. Reads lineage for any `w_id` with no authorization
/// check, like [`workspace_with_fork_ancestors`]: callers must be authorized for `w_id`.
pub async fn fork_storage_keys(
db: &DB,
w_id: &str,
storage: Option<&str>,
) -> Result<Arc<ForkStorageKeys>> {
let storage = registry_storage_name(storage).to_string();
let cache_key = (w_id.to_string(), storage.clone());
let now = chrono::Utc::now().timestamp();
if let Some((keys, expiry)) = FORK_STORAGE_KEYS_CACHE.get(&cache_key) {
if expiry > now {
return Ok(keys);
}
}
let chain_ids = workspace_with_fork_ancestors(db, w_id).await?;
let rows = sqlx::query!(
r#"SELECT c.id AS "id!", ws.large_file_storage,
EXISTS (SELECT 1 FROM fork_storage_namespace f
WHERE f.workspace_id = c.id AND f.storage = $2) AS "isolated!"
FROM unnest($1::text[]) WITH ORDINALITY AS c(id, ord)
LEFT JOIN workspace_settings ws ON ws.workspace_id = c.id
ORDER BY c.ord"#,
&chain_ids,
&storage,
)
.fetch_all(db)
.await?;
let chain: Vec<ChainLink> = rows
.into_iter()
.map(|r| ChainLink {
storage_ref: r
.large_file_storage
.as_ref()
.and_then(|lfs| lfs_entry(lfs, &storage))
.and_then(lfs_entry_storage_ref),
w_id: r.id,
isolated: r.isolated,
})
.collect();
let keys = Arc::new(if chain.is_empty() {
ForkStorageKeys::from_chain(&[ChainLink {
w_id: w_id.to_string(),
storage_ref: None,
isolated: false,
}])
} else {
ForkStorageKeys::from_chain(&chain)
});
FORK_STORAGE_KEYS_CACHE.insert(cache_key, (keys.clone(), now + 30));
Ok(keys)
}
#[cfg(test)]
mod tests {
use super::*;
fn link(w_id: &str, storage_ref: &str, isolated: bool) -> ChainLink {
ChainLink { w_id: w_id.into(), storage_ref: Some(storage_ref.into()), isolated }
}
#[test]
fn root_is_identity_but_keeps_fork_areas_out_of_reach() {
let keys = ForkStorageKeys::from_chain(&[link("prod", "S3Storage:u/a/s3", false)]);
assert!(keys.is_identity());
assert_eq!(keys.write_key("a/b.csv").unwrap(), "a/b.csv");
assert_eq!(keys.read_keys("a/b.csv").unwrap(), vec!["a/b.csv"]);
let other = fork_area("wm-fork-x");
assert!(keys.write_key(&format!("{other}a")).is_err());
assert!(keys.read_keys(&format!("{other}a")).is_err());
let listing = keys.list("").unwrap();
assert_eq!(listing.logical_key("a/b.csv").as_deref(), Some("a/b.csv"));
assert_eq!(listing.logical_key(&format!("{other}a")), None);
}
#[test]
fn isolated_fork_writes_its_area_and_reads_through_to_ancestors() {
let s = "S3Storage:u/a/s3";
let chain = [
link("wm-fork-b", s, true),
link("wm-fork-a", s, false),
link("dev", s, true),
link("prod", s, false),
];
let keys = ForkStorageKeys::from_chain(&chain);
let (b, dev) = (fork_area("wm-fork-b"), fork_area("dev"));
assert_eq!(keys.write_key("x.csv").unwrap(), format!("{b}x.csv"));
assert_eq!(
keys.read_keys("x.csv").unwrap(),
vec![
format!("{b}x.csv"),
format!("{dev}x.csv"),
"x.csv".to_string()
]
);
// DuckLake's own fork namespace and its ancestors' pass through as is.
assert_eq!(
keys.write_key(&format!("{b}lake/f")).unwrap(),
format!("{b}lake/f")
);
let a = fork_area("wm-fork-a");
assert_eq!(
keys.read_keys(&format!("{a}lake/f")).unwrap(),
vec![format!("{a}lake/f")]
);
assert!(keys.write_key(&format!("{a}lake/f")).is_err());
let listing = keys.list("dir/").unwrap();
assert_eq!(listing.physical_prefix, format!("{b}dir/"));
assert_eq!(
listing.logical_key(&format!("{b}dir/f")).as_deref(),
Some("dir/f")
);
}
#[test]
fn shared_fork_writes_where_its_parent_does() {
let s = "S3Storage:u/a/s3";
let keys = ForkStorageKeys::from_chain(&[
link("wm-fork-c", s, false),
link("dev", s, true),
link("prod", s, false),
]);
let dev = fork_area("dev");
assert_eq!(keys.write_key("x").unwrap(), format!("{dev}x"));
assert_eq!(
keys.read_keys("x").unwrap(),
vec![format!("{dev}x"), "x".into()]
);
}
#[test]
fn reads_stop_at_an_ancestor_on_another_storage() {
let keys = ForkStorageKeys::from_chain(&[
link("wm-fork-d", "S3Storage:u/a/other", true),
link("prod", "S3Storage:u/a/s3", false),
]);
let d = fork_area("wm-fork-d");
assert_eq!(keys.read_keys("x").unwrap(), vec![format!("{d}x")]);
}
#[test]
fn dot_segments_cannot_climb_out_of_the_fork_area() {
let keys = ForkStorageKeys::from_chain(&[
link("wm-fork-a", "S3Storage:u/a/s3", true),
link("prod", "S3Storage:u/a/s3", false),
]);
let area = fork_area("wm-fork-a");
assert!(keys.write_key("../../prod.csv").is_err());
assert!(keys.write_key(&format!("{area}lake/../../x")).is_err());
assert!(keys.read_keys("a/./b").is_err());
assert!(keys.list("..").is_err());
assert_eq!(
keys.write_key("a..b/c.d").unwrap(),
format!("{area}a..b/c.d")
);
}
#[test]
fn fork_area_match_is_on_whole_segments() {
assert!(in_fork_area("__wm_forks/a"));
assert!(!in_fork_area("__wm_forks_backup/a"));
let keys = ForkStorageKeys::from_chain(&[link("prod", "S3Storage:u/a/s3", false)]);
assert_eq!(
keys.write_key("__wm_forks_backup/a").unwrap(),
"__wm_forks_backup/a"
);
}
}
+1
View File
@@ -73,6 +73,7 @@ pub use feature_usage_oss as feature_usage;
pub mod flow_conversations;
pub mod flow_status;
pub mod flows;
pub mod fork_storage;
pub mod folders;
pub mod global_settings;
pub mod hub_resource_types;
@@ -0,0 +1,260 @@
//! An [`ObjectStore`] over one workspace storage that applies the workspace's
//! [`ForkStorageKeys`]: callers keep using logical keys.
use std::sync::Arc;
use futures::{stream::BoxStream, StreamExt, TryStreamExt};
use object_store::path::Path;
use object_store::{
GetOptions, GetResult, ListResult, MultipartUpload, ObjectMeta, ObjectStore, PutMultipartOpts,
PutOptions, PutPayload, PutResult, Result,
};
use windmill_common::fork_storage::{fork_storage_keys, ForkStorageKeys};
use crate::{build_object_store_client, ObjectStoreResource};
/// The client for `w_id`'s storage `storage` (None = default). Every server-side access to a
/// workspace storage by logical key goes through this, so fork isolation holds on each path.
///
/// It checks no authorization: the caller must already have checked that the requester may
/// use `w_id`'s storage, and its permission rules for each key it reads or writes.
pub async fn workspace_object_store(
db: &windmill_common::DB,
w_id: &str,
storage: Option<&str>,
resource: &ObjectStoreResource,
) -> windmill_common::error::Result<Arc<dyn ObjectStore>> {
Ok(workspace_fork_store(db, w_id, storage, resource).await?)
}
/// [`workspace_object_store`] as its concrete type, under the same authorization contract.
pub async fn workspace_fork_store(
db: &windmill_common::DB,
w_id: &str,
storage: Option<&str>,
resource: &ObjectStoreResource,
) -> windmill_common::error::Result<Arc<ForkStore>> {
let inner = build_object_store_client(resource).await?;
let keys = fork_storage_keys(db, w_id, storage).await?;
Ok(Arc::new(ForkStore { inner, keys }))
}
#[derive(Debug)]
pub struct ForkStore {
inner: Arc<dyn ObjectStore>,
keys: Arc<ForkStorageKeys>,
}
impl ForkStore {
pub fn new(inner: Arc<dyn ObjectStore>, keys: Arc<ForkStorageKeys>) -> Self {
Self { inner, keys }
}
pub fn keys(&self) -> &ForkStorageKeys {
&self.keys
}
/// This store with reads confined to the workspace's own copies.
pub fn own_only(&self) -> Arc<dyn ObjectStore> {
Arc::new(ForkStore { inner: self.inner.clone(), keys: Arc::new(self.keys.own_only()) })
}
}
impl std::fmt::Display for ForkStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "ForkStore({})", self.inner)
}
}
fn denied(location: &Path, e: windmill_common::error::Error) -> object_store::Error {
object_store::Error::PermissionDenied { path: location.to_string(), source: Box::new(e) }
}
fn parse(key: String) -> Result<Path> {
Path::parse(key).map_err(|source| object_store::Error::InvalidPath { source })
}
impl ForkStore {
fn write_path(&self, location: &Path) -> Result<Path> {
parse(
self.keys
.write_key(location.as_ref())
.map_err(|e| denied(location, e))?,
)
}
fn read_paths(&self, location: &Path) -> Result<Vec<Path>> {
self.keys
.read_keys(location.as_ref())
.map_err(|e| denied(location, e))?
.into_iter()
.map(parse)
.collect()
}
/// The physical path of the first of `location`'s read paths that exists (the last one
/// when none does).
pub async fn resolve_read(&self, location: &Path) -> Result<Path> {
parse(self.resolve_read_key(location.as_ref()).await?)
}
/// [`Self::resolve_read`] on a raw key, for callers that forward the key themselves.
pub async fn resolve_read_key(&self, key: &str) -> Result<String> {
let location = Path::from(key);
let mut keys = self.keys.read_keys(key).map_err(|e| denied(&location, e))?;
let last = keys.pop().expect("read_keys returns at least one key");
for candidate in keys {
match self.inner.head(&parse(candidate.clone())?).await {
Ok(_) => return Ok(candidate),
Err(object_store::Error::NotFound { .. }) => continue,
Err(e) => return Err(e),
}
}
Ok(last)
}
}
#[async_trait::async_trait]
impl ObjectStore for ForkStore {
async fn put_opts(
&self,
location: &Path,
payload: PutPayload,
opts: PutOptions,
) -> Result<PutResult> {
self.inner
.put_opts(&self.write_path(location)?, payload, opts)
.await
}
async fn put_multipart_opts(
&self,
location: &Path,
opts: PutMultipartOpts,
) -> Result<Box<dyn MultipartUpload>> {
self.inner
.put_multipart_opts(&self.write_path(location)?, opts)
.await
}
async fn get_opts(&self, location: &Path, options: GetOptions) -> Result<GetResult> {
let paths = self.read_paths(location)?;
let last = paths.len() - 1;
for (i, path) in paths.into_iter().enumerate() {
match self.inner.get_opts(&path, options.clone()).await {
Err(object_store::Error::NotFound { .. }) if i < last => continue,
Ok(mut result) => {
result.meta.location = location.clone();
return Ok(result);
}
Err(e) => return Err(e),
}
}
unreachable!("read_keys returns at least one key")
}
async fn head(&self, location: &Path) -> Result<ObjectMeta> {
let paths = self.read_paths(location)?;
let last = paths.len() - 1;
for (i, path) in paths.into_iter().enumerate() {
match self.inner.head(&path).await {
Err(object_store::Error::NotFound { .. }) if i < last => continue,
Ok(meta) => return Ok(ObjectMeta { location: location.clone(), ..meta }),
Err(e) => return Err(e),
}
}
unreachable!("read_keys returns at least one key")
}
/// Removes this workspace's copy only: an ancestor's object stays readable through it.
async fn delete(&self, location: &Path) -> Result<()> {
self.inner.delete(&self.write_path(location)?).await
}
fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, Result<ObjectMeta>> {
let prefix = prefix.map(|p| p.as_ref().to_string()).unwrap_or_default();
let mapping = match self.keys.list(&prefix) {
Ok(m) => m,
Err(e) => {
let err = denied(&Path::from(prefix), e);
return futures::stream::once(async move { Err(err) }).boxed();
}
};
let physical = Path::from(mapping.physical_prefix.clone());
self.inner
.list(Some(&physical))
.try_filter_map(move |meta| {
let logical = mapping.logical_key(meta.location.as_ref());
futures::future::ready(Ok(logical
.and_then(|l| Path::parse(l).ok())
.map(|location| ObjectMeta { location, ..meta })))
})
.boxed()
}
fn list_with_offset(
&self,
prefix: Option<&Path>,
offset: &Path,
) -> BoxStream<'static, Result<ObjectMeta>> {
let prefix = prefix.map(|p| p.as_ref().to_string()).unwrap_or_default();
let mapping = match self.keys.list(&prefix) {
Ok(m) => m,
Err(e) => {
let err = denied(&Path::from(prefix), e);
return futures::stream::once(async move { Err(err) }).boxed();
}
};
let physical = Path::from(mapping.physical_prefix.clone());
let physical_offset = match parse(mapping.physical_key(offset.as_ref())) {
Ok(p) => p,
Err(e) => return futures::stream::once(async move { Err(e) }).boxed(),
};
self.inner
.list_with_offset(Some(&physical), &physical_offset)
.try_filter_map(move |meta| {
let logical = mapping.logical_key(meta.location.as_ref());
futures::future::ready(Ok(logical
.and_then(|l| Path::parse(l).ok())
.map(|location| ObjectMeta { location, ..meta })))
})
.boxed()
}
async fn list_with_delimiter(&self, prefix: Option<&Path>) -> Result<ListResult> {
let prefix_str = prefix.map(|p| p.as_ref().to_string()).unwrap_or_default();
let mapping = self
.keys
.list(&prefix_str)
.map_err(|e| denied(&Path::from(prefix_str.clone()), e))?;
let physical = Path::from(mapping.physical_prefix.clone());
let listed = self.inner.list_with_delimiter(Some(&physical)).await?;
let logical = |p: &Path| {
mapping
.logical_key(p.as_ref())
.and_then(|l| Path::parse(l).ok())
};
Ok(ListResult {
common_prefixes: listed.common_prefixes.iter().filter_map(logical).collect(),
objects: listed
.objects
.into_iter()
.filter_map(|meta| {
logical(&meta.location).map(|location| ObjectMeta { location, ..meta })
})
.collect(),
})
}
async fn copy(&self, from: &Path, to: &Path) -> Result<()> {
let from = self.resolve_read(from).await?;
self.inner.copy(&from, &self.write_path(to)?).await
}
async fn copy_if_not_exists(&self, from: &Path, to: &Path) -> Result<()> {
let from = self.resolve_read(from).await?;
self.inner
.copy_if_not_exists(&from, &self.write_path(to)?)
.await
}
}
+2
View File
@@ -59,6 +59,8 @@ use windmill_common::utils::rd_string;
pub mod job_s3_helpers_ee;
#[cfg(feature = "parquet")]
pub mod job_s3_helpers_oss;
#[cfg(feature = "parquet")]
pub mod fork_store;
// Re-export object_store types so consumers don't need a direct object_store dep
#[cfg(feature = "parquet")]
+5 -1
View File
@@ -61,16 +61,20 @@ const CLEAR_ALL: &str = "*";
pub fn invalidate_fork_parent_cache(workspace_id: &str) {
FORK_PARENT_CACHE.remove(workspace_id);
windmill_common::workspaces::invalidate_root_workspace_cache(workspace_id);
windmill_common::fork_storage::invalidate_fork_storage_keys_cache(workspace_id);
}
/// Apply a broadcast lineage change to this process's caches.
/// Apply a broadcast lineage change to this process's caches. The object storage key mapping is
/// among them: a replica keeping a recreated fork's old mapping would write the wrong keys.
pub fn apply_fork_lineage_change(payload: &str) {
if payload == CLEAR_ALL {
FORK_PARENT_CACHE.clear();
windmill_common::workspaces::clear_root_workspace_cache();
windmill_common::fork_storage::clear_fork_storage_keys_cache();
} else {
FORK_PARENT_CACHE.remove(payload);
windmill_common::workspaces::invalidate_root_workspace_cache(payload);
windmill_common::fork_storage::invalidate_fork_storage_keys_cache(payload);
}
}
+56
View File
@@ -0,0 +1,56 @@
# Fork-isolated workspace object storage
A fork clones its parent's `large_file_storage` settings, so without isolation it reads and
writes the parent's objects: a script iterating in a fork overwrites production's files. Each
storage the fork isolates (a `fork_storage_namespace` row, chosen at fork creation, default
isolated) instead maps logical keys:
- **Writes** (put, multipart, delete, copy destination) go to `__wm_forks/<fork segment>/<key>`,
the same one-component segment DuckLake's fork namespaces use (`fork_data_dir_segment`).
- **Reads** try the fork's own key, then each ancestor's mapping, nearest first, then the root's
raw key. They stop at the first ancestor whose storage is a different one
(`lfs_entry_storage_ref`): its keys are not in this bucket. A shared fork maps like its parent.
- **Listings** show the workspace's own objects only, as logical keys.
- **Deletes** remove the fork's copy; an ancestor's object stays readable through it.
The mapping (`windmill_common::fork_storage::ForkStorageKeys`) is applied wherever Windmill
turns a workspace-storage key into a storage operation: the `ForkStore` object-store wrapper
(`windmill_object_store::fork_store::workspace_object_store`) for every server-side helper, and
key rewriting in the S3 proxy's raw S3 forwarding (URI key, list `prefix`/`start-after`/`marker`
and response keys, `x-amz-copy-source`). A named resource (`s3_resource_path`) is not workspace
storage and is never mapped. A workspace whose keys are mapped is refused its workspace
storage's own credentials (`s3_resource_info`, the duckdb/polars settings endpoints): a client
holding them would write past the mapping.
Not mapped, on purpose: AI sessions and volumes (their keys already carry the workspace id),
SMTP attachments (job-id keys), the storage usage count (it counts the whole bucket). Known
gaps: the worker's result-cache hash HEADs the logical key unmapped, and a read of an object
the fork does not own costs one HEAD per chain level before the GET.
## Invariants
- **`__wm_forks/` is reachable only through the workspace's own chain.** A key under it maps to
itself when it is in the workspace's own area (writes) or an area of the chain (reads), and is
refused otherwise; listings hide it. This is also what keeps DuckLake working: its fork data
paths arrive already prefixed, and its defer attaches read ancestors' raw keys through the
read chain. Every workspace gets the rule, isolating or not, so pre-existing forks' lakes keep
working.
- **No `.` or `..` key segments**, in any workspace: the proxy forwards mapped keys in a URL,
where they collapse and would climb out of the fork's prefix.
- **The prefix derives from the workspace id**, so an isolating fork's id cannot be changed
(`change_workspace_id` refuses): under a new id it would see none of its objects. Nor can an
isolated storage be repointed, since the purge targets the storage recorded at creation.
- **A shared lake needs its storage shared**: its files must land at the parent's keys, where the
parent's catalog looks for them. Fork creation refuses the other combination.
- **Children before parents**: deleting a fork that has forks of its own is refused when anything
in its chain isolates a storage, shared forks included. A child reads through, or writes into,
the isolating prefix via that link, and without it the child's chain would map like the root's.
- **Deletion and same-id creation are serialized** on the `fork:<id>` advisory lock, and a purge
skips an id that exists again: the rows are then the new fork's. The proxy caches only the raw
client, never a mapping, so fork creation and deletion invalidate the one mapping cache.
- **Deletion always purges** the fork's area of each isolated storage, post-commit, from the
storage identity recorded at creation: nothing can reach it once the fork is gone. Rows have
no FK and outlive the workspace when the purge fails; creating a fork with the same id retries
them and refuses while they cannot be purged, since a same-id fork would read them.
- Isolation is a per-fork-creation choice (`shared_storages` on create-fork), never inherited.
Forks created before the registry existed have no rows and keep sharing.
@@ -76,6 +76,7 @@
import WorkspaceScopeTrigger from '../WorkspaceScopeTrigger.svelte'
import DarkModeToggle from '../sidebar/DarkModeToggle.svelte'
import ForkDucklakeSection from './ForkDucklakeSection.svelte'
import ForkStorageSection from './ForkStorageSection.svelte'
import { createWorkspaceForkAndWait } from '$lib/utils/forkCreation'
interface Props {
@@ -277,6 +278,7 @@
let forkDatatableSection: ReturnType<typeof ForkDatatableSection> | undefined = $state(undefined)
let forkDucklakeSection: ReturnType<typeof ForkDucklakeSection> | undefined = $state(undefined)
let forkStorageSection: ReturnType<typeof ForkStorageSection> | undefined = $state(undefined)
let workspaceColor: string | undefined = $state(undefined)
let colorEnabled = $state(false)
@@ -499,6 +501,7 @@
color: colorEnabled && workspaceColor ? workspaceColor : undefined,
forked_datatables: forkedDatatables,
shared_ducklakes: forkDucklakeSection?.getSharedDucklakes() ?? [],
shared_storages: forkStorageSection?.getSharedStorages() ?? [],
is_dev_workspace: createAsDevWorkspace,
dev_workspace_label: createAsDevWorkspace ? devWorkspaceLabel : undefined,
lock_prod_deploy: createAsDevWorkspace && effectiveLockProdDeploy,
@@ -1021,6 +1024,7 @@
}}
/>
<ForkDucklakeSection bind:this={forkDucklakeSection} sourceWorkspace={baseWorkspaceId} />
<ForkStorageSection bind:this={forkStorageSection} sourceWorkspace={baseWorkspaceId} />
{/if}
{#if !automateUsernameCreation}
<Label label="Your username in that workspace">
@@ -0,0 +1,71 @@
<script lang="ts">
import { SettingService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { resource } from 'runed'
import Select from '../select/Select.svelte'
import Label from '../Label.svelte'
import { TriangleAlert } from 'lucide-svelte'
// The workspace the fork is created from, like ForkDucklakeSection: the storages
// listed here must match the ones the create-fork call submits to.
let { sourceWorkspace }: { sourceWorkspace?: string } = $props()
let effectiveSource = $derived(sourceWorkspace ?? $workspaceStore ?? undefined)
let storages = resource(
() => effectiveSource,
async (ws) =>
ws
? SettingService.getSecondaryStorageNames({ workspace: ws, includeDefault: true })
: undefined
)
let behaviors: Record<string, 'isolated' | 'shared'> = $state({})
// Sent as `shared_storages` on the create-fork request; unlisted storages are isolated.
export function getSharedStorages(): string[] {
return (storages.current ?? []).filter((name) => behaviors[name] === 'shared')
}
let anyShared = $derived((storages.current ?? []).some((n) => behaviors[n] === 'shared'))
function storageLabel(name: string): string {
return name === '_default_' ? 'Primary storage' : name
}
</script>
<!-- Self-hides when the workspace has no object storage configured. -->
{#if storages.current && storages.current.length > 0}
<Label label="Object storage">
<span class="text-xs text-secondary">
By default each storage is isolated in the fork: the fork's writes go to its own prefix, and
it reads this workspace's files until it writes its own. Deleting the fork deletes what it
wrote.
</span>
<div class="border rounded-md divide-y">
{#each storages.current as name (name)}
<div class="flex items-center gap-2 justify-between px-4 py-1.5">
<span class="text-xs font-medium">{storageLabel(name)}</span>
<Select
dropdownClass="max-w-96"
bind:value={() => behaviors[name] ?? 'isolated', (v) => (behaviors[name] = v)}
items={[
{ value: 'isolated', label: 'Isolated (read through to parent)' },
{ value: 'shared', label: 'Shared with parent' }
]}
/>
</div>
{/each}
</div>
{#if anyShared}
<div
class="flex items-start gap-2 px-3 py-2 text-xs rounded-md border border-amber-200 dark:border-amber-900/60 bg-amber-50 dark:bg-amber-950/40 text-amber-700 dark:text-amber-300"
>
<TriangleAlert size={14} class="shrink-0 mt-0.5" />
<span>
A shared storage is NOT isolated: scripts in the fork read, overwrite and delete this
workspace's files directly.
</span>
</div>
{/if}
</Label>
{/if}