mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 16:02:19 +00:00
51d8db6602
* docs: add design doc for automatic git-to-windmill pull sync
* docs: add migration plan and implementation phases to git-sync pull design
* feat(git-sync): add auto_pull settings schema and pull enqueue primitive
Adds AutoPullSettings/AutoPullMode/AutoPullStatus on GitRepositorySettings
(workspace_settings.git_sync JSONB), the GIT_SYNC_PULL_SCRIPT_PATH constant,
and should_pull/effective_poll_interval_s helpers with unit tests. Exports the
EE enqueue_git_pull_job primitive. Foundation for repo→Windmill auto-pull.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(git-sync): poll repos and auto-pull new commits into the workspace
Phase 1 of automatic repo → Windmill sync. A monitor task (EE-licensed,
single-replica via advisory lock) git ls-remotes each auto-pull-enabled
repository ~every minute and enqueues a pull when the tracked branch moves,
reusing the {workspace_id}:git_sync concurrency key so pulls serialize with
in-flight push commits.
- windmill-store: background (no-authed) resolver get_git_repo_head_for_autopull
that resolves the repo resource (incl. $var: refs) and ls-remotes; GitHub-App
repos are skipped here and will sync via webhooks (phase 2).
- monitor.rs: poll/reconcile/persist with optimistic sha advance and failure
status; targeted jsonb update so concurrent settings edits aren't clobbered.
- edit_git_sync_repository: preserve server-owned auto_pull state on UI save.
- openapi: AutoPullSettings/AutoPullMode/AutoPullStatus + auto_pull field.
- frontend: per-repo "Automatically deploy changes from Git" toggle with last
sync status; demote the GitHub Actions link to an advanced CI option.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(git-sync): wire webhook lifecycle + receiver; share reconcile logic
OSS side of phase 2 auto-pull webhooks:
- edit_git_sync_repository creates/removes the repo webhook on save (EE-gated,
best-effort → falls back to polling).
- monitor poller now delegates to the shared windmill_git_sync reconcile/persist
helpers (also used by the webhook receiver), removing duplicated logic.
- export the shared reconcile/persist/failure helpers; bump EE ref.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(git-sync): bump EE ref for phase 3 in-app PR creation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(git-sync): show webhook vs polling status on the auto-pull toggle
When a repo has an active webhook (auto_pull.webhook_id set), the status line
reads "instant via webhook"; otherwise it reads the ~1-minute polling cadence.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(git-sync): post PR diff check on dry-run completion (phase 4)
Worker completion hook in process_completed_job: when a DeploymentCallback job
carrying the __git_sync_pr_check marker finishes, parse the dry-run SyncResponse
and patch the GitHub check run with the diff summary (success/neutral/failure).
Export enqueue_git_pull_dry_run; bump EE ref.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(git-sync): bump EE ref (drop unused GHES webhook_secret)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* revert(git-sync): defer phase 4 PR diff checks (OSS side)
Remove the worker completion hook that posted the PR check run, drop the
enqueue_git_pull_dry_run re-export and the orphaned sqlx cache, bump EE ref.
Phases 1-3 (polling, webhooks, in-app PR creation) are unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Revert "revert(git-sync): defer phase 4 PR diff checks (OSS side)"
This reverts commit 0137d3ca48.
* chore(git-sync): point EE ref at restored phase 4 commit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(git-sync): bump EE ref for clone_ref dry-run
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(git-sync): bump init-repository hub script to v28784
Picks up the clone_ref param (windmill-integrations#158) so the phase 4 PR-check
dry-run can clone the PR head. Backward compatible; manual pull/push and the
automated pull/poller/webhook all move to the same published version.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(git-sync): bump EE ref for auto-pull admin-permissioning fix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(git-sync): bump EE ref for superadmin pull fallback
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(git-sync): refresh auto-pull tooltip; bump EE ref for webhook secret encryption
The auto-pull toggle tooltip claimed GitHub App repos would sync via
webhooks "in a future update"; webhook delivery now works, so describe
the webhook-vs-polling behavior accurately. Bump the EE ref to pick up
encrypting the webhook HMAC secret at rest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(git-sync): poll app-backed repos in auto/polling mode
The auto-pull poller skipped app-backed repos (the ls-remote head check
can't authenticate a tokenless URL), so auto- and polling-mode app repos
never synced when their webhook wasn't live. Wire the poller to fetch the
head via the GitHub API for app repos and reconcile. Bump the EE ref.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(git-sync): auto-pull UI — direction split, delivery mode, fallback notice
Reorganize the repository card into two clearly labeled directions:
"Push to Git on deploy (Windmill → Git)" and "Pull from Git (Git →
Windmill)". In the pull section:
- new connections default to auto-pull enabled (webhook with polling
fallback); existing repos load with auto-pull off and are unchanged
- a Delivery selector chooses "Webhook with polling fallback" or
"Polling only (air-gapped)"
- a notice surfaces webhook_error when delivery falls back to polling
- a reminder to remove any pre-existing GitHub Action that pushed into
Windmill, to avoid conflicting double-syncs
Adds the webhook_error field to AutoPullSettings (+ openapi) and bumps
the EE ref.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(git-sync): clearer push indicator + gate webhook delivery to app repos
- Push-on-deploy is shown with a check icon + concise line (via the
shared GitSyncModeDisplay, restyled from the oversized "Sync:" text);
the setup wizard reuses it without the check (pre-save preview).
- The delivery-mode selector only shows for GitHub App-backed repos;
token-based repos show a "webhooks require the GitHub App (managed or
GHES)" note with a docs link and poll instead. Bumps the EE ref.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(git-sync): fork auto-sync (phase 5) + live deploy check (phase 6)
Phase 5 — fork auto-sync configured at the parent (replaces the *-to-forks
GitHub Actions):
- Add fork_open_prs + fork_pull_sync to GitRepositorySettings (openapi + UI).
- UI: two "Forks of this workspace" toggles in the repo card, gated on
app-backed and not-a-fork; serialize the flags on save.
- On fork creation, strip the inherited auto_pull block (and fork_* flags) from
the copied git_sync repo: a fork must not carry the parent's webhook id (it
would delete the parent's hook on disable) or self-poll on top of the parent's
fan-out. Push-direction config + installation are still inherited unchanged.
Phase 6 — live deploy status check on the commit (Cloudflare-style): an
in-progress "Windmill" check on the head commit that flips to "Deployed N
changes"; completion handled by the generalized git-sync check hook.
Bump EE ref for the phase 5-6 EE implementation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* chore(git-sync): bump EE ref for PAT auto-pull mode normalization
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* fix(git-sync): address PR review findings
- webhook_secret: redact from the settings API response and Debug output (still
persisted encrypted); it's a server-only HMAC key the UI never needs.
- poller: honor each repo's effective poll interval (relaxed ~10 min when a
webhook is live) instead of probing every ~60s tick.
- settings save: roll back a just-created webhook if the settings transaction
doesn't commit, so a failed save can't orphan a hook.
- auto-pull head check: fail SSH remotes with an actionable message (background
polling has no SSH identity) instead of a confusing ls-remote error.
- deploy/PR check summary: a pull result carrying neither changes nor a settings
diff now falls back to the unsummarized path instead of a false "in sync".
- UI: reset isGithubApp on resource change / failed fetch so webhook + fork
controls can't show for the wrong repo.
- tests: cover parse_git_sync_changes and format_change_list edge cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* fix(git-sync): correct feature gating for OSS builds
- monitor.rs: keep the AUTO_PULL_LAST_POLL static, slack const, and
poll_git_auto_pull_inner all behind #[cfg(feature = "private")] (an inserted
static had split the cfg off the function, ungating it in OSS builds).
- edit_git_sync_repository: the webhook create/rollback block references
windmill_common::git_sync_ee (private module), so gate it on
all(enterprise, private) instead of enterprise only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* chore(sqlx): cache workspace_diff query pulled in from origin/main
Re-merged origin/main (advanced past the earlier merge); regenerate the offline
sqlx entry for the new workspace_comparison test query so SQLX_OFFLINE builds
(cargo_test) pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* fix(git-sync): address Codex review findings (webhook cleanup on delete)
- Deleting a git-sync repository now tears down its managed GitHub webhook
(deletion bypassed the sync_repo_webhook lifecycle, orphaning the hook so
GitHub kept delivering to the instance).
- Worker completion hook rolls back the optimistic auto-pull sha on job failure
(OSS side of the EE change) + caches the new marker query. Bump EE ref.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* fix(git-sync): delete repo webhook after the removal commits
Codex re-review nits:
- delete_git_sync_repository deleted the webhook before the settings transaction
committed; a failed save would then leave the repo pointing at a hook that no
longer exists (sync_repo_webhook treats a set webhook_id as live and won't
recreate it). Capture the hook id, commit the DB removal, then delete the hook.
- Reword a fork-copy comment to drop drafting-history wording per AGENTS.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* fix(git-sync): reconcile the edit-path webhook after the settings commit
Codex nit: edit_git_sync_repository ran sync_repo_webhook before the transaction
committed. The rollback only covered created hooks, but sync_repo_webhook also
deletes a hook on disable/switch-to-polling — a commit failure then left the DB
with a webhook_id whose hook was already gone (and it wouldn't be recreated).
Save + commit first, then reconcile the webhook against the durable config and
persist any hook id/secret change (best-effort). Bump EE ref.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* fix(git-sync): preserve webhook secret on whole-config save + default on visible add
Codex nits:
- edit_git_sync_config saved the client config verbatim, so the webhook_secret
redacted from the GET response would be dropped (breaking delivery). Preserve
server-owned auto-pull state (webhook id/secret, synced sha, last status) per
repo from the existing settings, matching edit_git_sync_repository.
- addSyncRepository (the visible add path) didn't set the auto_pull default, so
new sync repos added from the UI came up with auto-deploy off. Match
addRepository's default (webhook + polling fallback).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* refactor(git-sync): drop fork_pull_sync (parent-level keep-forks-in-sync)
Removes the "Keep forks in sync with the tracked branch" toggle and its
fan-out. Pulling the tracked branch straight into every fork was the
inconsistent piece; the consistent model is per-fork branch sync (each
fork tracks its own wm-fork/** branch), which is a separate follow-up.
fork_open_prs is kept. Also tightens the fork toggle-section spacing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* fix(git-sync): detect dev workspaces in CLI fork branch derivation
isForkWorkspace / computeGitSyncDeployBranch keyed off the wm-fork- id
prefix. Dev workspaces are forks with a custom, prefix-less id, so their
wm-fork/** branch was never derived or created. Detect them via
parent_workspace_id too (which the backend already passes), mirroring the
backend's `parent.is_some() || wm-fork- prefix` rule.
Pairs with the hub-script clone-flag fix (windmill-integrations#163); both
take effect once the CLI is released and the pinned version is bumped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* fix(git-sync): reconcile webhooks on full-config save
edit_git_sync_config preserved server-owned webhook fields but never
created or deleted the managed GitHub webhook, so enabling auto-pull
through the whole-config endpoint only polled, and disabling or removing
a repo left an orphan hook still delivering. Mirror the per-repository
endpoint: after the commit is durable, reconcile every saved repo's
webhook (sync_repo_webhook) and delete the hooks of repos the save
removed, including the clear-whole-config case. Addresses the Codex nit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* fix(git-sync): address Codex nits (webhook orphan on cleared auto_pull, fork detection)
- edit_git_sync_config: also delete a repo's old webhook when the save drops
the repo OR clears its auto_pull. Webhook fields are only preserved onto a
Some auto_pull, so a save that present-but-clears a repo would otherwise
orphan its hook.
- GitSyncRepositoryCard: isFork now uses parent_workspace_id OR the wm-fork-
prefix (was AND), matching the backend/CLI rule, so prefix-less dev
workspaces are detected as forks and don't show the parent fork-PR toggle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* docs(git-sync): update design doc for the dropped fork_pull_sync
Phase 5 documented "Keep forks in sync with the tracked branch"
(fork_pull_sync) and its fan-out as implemented; that feature was removed.
Rewrite the section to reflect what ships (fork_open_prs), note the drop +
the per-fork-branch follow-up, and remove the stale fan-out mentions
elsewhere. Addresses the Codex nit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* fix(git-sync): redact webhook secrets from workspace export; fix doc endpoints
- Export (P1): strip the server-owned auto_pull state (webhook secret/id/error
+ synced sha + last pull status) from git_sync before it is written into an
export's settings.json for both settings formats. The HMAC webhook secret
must never leave the server (matching the GET-settings redaction), and a
re-imported workspace must not inherit another install's hook/sync state.
- Docs: the webhook receiver is a single per-workspace endpoint
/api/w/{workspace}/github_app/webhook (host-aware for managed + self-managed);
update the stale push_webhook/{id} and instance-global /api/github_app/webhook
references.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* fix(git-sync): skip deleted/archived workspaces in the auto-pull poller
The poller scanned workspace_settings directly, so an archived (soft-deleted)
or renamed-away workspace — whose settings row persists — kept polling and
could enqueue a pull into a dead workspace. Join workspace and require
NOT deleted. The EE webhook receiver gets the same filter (ee ref bumped).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* fix(git-sync): never trust client-supplied server-owned auto-pull fields
Both write endpoints (edit_git_sync_repository, edit_git_sync_config)
persisted caller-supplied auto_pull.webhook_id / webhook_secret /
webhook_error / last_synced_sha / last_pull_status when adding a repo or
newly enabling auto-pull, letting a client inject a webhook id/secret or
fake sync state. Strip those server-owned fields from the request up front;
existing repos re-derive them from the DB (carried over), new ones start
clean and the server (re)creates the webhook.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* fix(git-sync): merge webhook fields post-commit instead of clobbering the row
The post-commit webhook reconcile in edit_git_sync_repository and
edit_git_sync_config wrote the whole pre-reconcile git_sync snapshot back
after the main save committed. A concurrent git-sync edit or poller status
write that landed in the gap could then be dropped by the stale snapshot.
Re-read the current row and merge only the reconciled webhook id/secret/error
for the repos the reconcile actually changed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* feat(git-sync): parent-managed fork sync + PR-on-deploy toggles
Fork sync (push-on-merge-to-forks parity): a parent-level
auto_pull.sync_forks toggle routes changes on each fork's wm-fork/** branch
into that fork workspace, via the parent's existing webhook and one extra
fork-heads listing per poll tick (git ls-remote pattern for token repos,
git/matching-refs for app-backed). Fork state is a server-written
status-only auto_pull blob on the fork's own repo entry; the fork's card
shows a read-only "managed in the parent workspace" line with its branch
and last pull status. Dev workspaces (prefix-less ids) use the same branch
parsing (unit-tested in windmill-common).
PR-on-deploy: opening PRs for Windmill-pushed branches moves into the
deploy pipeline, per repo toggle (promotion_open_prs on the promotion
repo; parent-level fork_open_prs for fork deploys). The push job carries a
marker and the job-completion hook derives the pushed branch (helper
unit-tested against the CLI formula) and opens the PR outbound, so it
works without inbound webhooks; the webhook-side wm_deploy PR arm is
removed. The documented open-pr-* GitHub Actions remain valid alternatives
(PR creation is idempotent).
Fork guards: promotion mode, enabled auto-pull, and fork_open_prs are
rejected on fork workspaces (they are parent-managed; a fork's deploys
always target its wm-fork/** branch) and the promotion card is hidden in a
fork's settings. Enabling auto-pull now also requires EE, and the
post-commit webhook reconcile persists the normalized delivery mode.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* feat(git-sync): dev workspaces sync with their environment-label branch
A dev workspace's git branch is its environment label verbatim (dev/
staging, default dev) — a first-class env branch like the documented
push-on-merge-staging layout — instead of the wm-fork/** form. The label
rides the deploy job args (backend → hub script → CLI
--dev-workspace-label), the PR completion hook derives the same head, the
webhook/poller route label branches into the matching dev-workspace child
(poller lists them alongside wm-fork/* via extra ls-remote refs / per-label
API lookups), and manual pulls from the UI pass clone_ref accordingly. The
CLI refuses to deploy when the label branch equals the checked-out tracked
branch, which would otherwise commit fork content straight to it.
Because the branch is keyed on the label, the label is now immutable after
creation: set at create/attach only, the set_dev_workspace_label endpoint
is removed and the settings tab shows it read-only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* feat(git-sync): nested fork routing + fork-of-dev branch rooting
A fork of a dev workspace now roots its wm-fork/** branch on the dev's
environment-label branch (the content it diverged from) and its PR merges
back into that branch: the backend passes parent_dev_workspace_label with
the deploy (parent row joined in both enqueue paths), the CLI gains
--parent-dev-workspace-label and checks it before the wm-fork- prefix
fallback when rooting a fork-of-a-fork branch, and the PR completion hook
uses it as the PR base.
Fork sync routing covers the whole live descendant chain of the
webhook/poller workspace (recursive, depth-capped) instead of direct
children only, and fork_open_prs is resolved at the root ancestor — only
the root can hold auto-pull config, so grandchild forks sync through it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* feat(git-sync): PR deploy-preview comment, clearer check copy, app-only hints
- The PR diff completion hook maintains one managed comment on the PR
(Cloudflare deploy-preview style: workspace, status, commit, collapsible
change list), upserted per synchronize via a hidden marker. The check run
stays for required-check gating.
- A settings difference in the diff summary is worded by cause: the PR
changes wmill.yaml, vs pre-existing drift between the repo's wmill.yaml
and the workspace, vs undetermined (neutral wording).
- Deploy-status check titles name the target workspace ("Deployed 2
change(s) to staging"), since GitHub shows a head commit's checks on any
PR containing it and a bare "Deployed" read as if the PR had deployed.
- Token-based repos see a hint pointing at the open-pr-on-commit /
open-pr-on-fork-commit workflows where the app-only PR toggles would be;
an API-set toggle on a non-app repo now logs a warning naming the
fallback; the design doc lists app-only features and their degradation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* fix(git-sync): EE-gate auto-pull UI, fork pull clone_ref, no-op push PR gate
- CE: the auto-pull and fork-PR toggles are disabled with an EE badge, and
new sync repos only default them on when licensed (basic git sync is
available on CE since #8493, but auto-pull is EE and the backend rejects it)
- The pull modal passes clone_ref for wm-fork- forks (wm-fork/<tracked>/<id>)
so a manual pull fetches the fork branch instead of the tracked branch head
- PR-on-deploy skips no-op pushes: when the push script reports pushed=false
(e.g. the deploy was caused by an auto-pull), the completion hook no longer
ensures a PR, so closed PRs aren't recreated by the sync loop
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* chore: refresh package-lock after main merge (windmill-utils-internal 1.8.2)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* test: auto-pull e2e integration tests; fix PR comment table formatting
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* fix(git-sync): runtime license gate for auto-pull saves; user/group promotion-branch parity
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* fix(git-sync): explain in-sync PR verdicts with the repo's sync filter scope
A PR that only touches files outside the repository's include paths gets
"In sync", which reads as a wrong verdict; the check summary (and managed
comment) now name the filters, e.g. "Only files matching this repository's
sync filters deploy on merge: `f/**` (excluding `f/pat/**`)."
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* feat(git-sync): clearer card copy/structure; surface PR-creation failures
- Fork sync toggle renamed and kept in the pull section; the fork PR toggle
moves to the push section with a note that push settings apply to forks
- Fork/dev workspaces' push section names their actual branch instead of the
tracked-branch line; promotion repos hide the pull direction (promotion
pushes deploy branches on top of a sync-mode setup)
- Promotion mode line describes the wm_deploy/** branch + merge-to-promote
flow; workflow-fallback hints lead with the how-to and link to the docs;
test connection button demoted from accent per brand guidelines
- New server-owned open_pr_error on repo settings: the deploy completion hook
records why a PR couldn't be opened (e.g. app permission not yet approved)
and clears it on the next success; shown as a warning under the PR toggles
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* fix: cfg-gate scope-note helper (dead code on OSS builds)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* fix(git-sync): license-gate preserved auto-pull; attach strips parent-only settings
- edit_git_sync_repository re-checks the runtime Enterprise gate against the
EFFECTIVE repo state after preservation: the older-client arm copies the
existing auto_pull back, which the request-side check never saw
- attach_dev_workspace now mirrors the fork-creation copy on the attached
workspace's own git sync: promotion repos dropped, auto_pull/fork PRs/PR
error stripped, and any managed webhook deleted after commit (the attached
workspace is parent-managed and must not keep pulling its old tracked branch)
- integration test: attaching an auto-pull-enabled workspace strips it
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* fix(git-sync): detach clears standalone parent; reject label == tracked branch
- detach_dev_workspace clears parent_workspace_id for prefix-less (attached
standalone) workspaces so they stop classifying as forks and deploying to
wm-fork/** branches; wm-fork- re-designated forks keep their parent; cache
invalidations mirror attach
- dev-workspace create/attach reject an environment label that equals a
git-sync repository's tracked branch (prod's or the candidate's): deploys
would target the very branch the repo syncs from, and the CLI guard would
fail every push job after the fact
- CLI unit tests: prefix-less fork beats wm_deploy derivation; isForkWorkspace
parent-id argument
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* chore(git-sync): bump hub script pins (push 28786, pull 28785)
Published from windmill-integrations #163 with windmill-cli@1.753.1-gitsync.0:
dev-workspace label deploys, fork-of-dev rooting, fork checkout on the
existing remote branch, and the pushed-flag result.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* fix(git-sync): no parent-only defaults on fork repos; rename strips webhook state
- addSyncRepository skips the auto_pull/fork_open_prs defaults on fork/dev
workspaces where the backend rejects them (saving a new sync repo from an
EE fork 400'd deterministically)
- change_workspace_id strips webhook id/secret/error from the copied git_sync
and deletes the stale GitHub hooks post-commit: they deliver to the old
(archived) workspace URL, so the new workspace would report a live webhook
while polling at the relaxed interval; next save re-registers cleanly
- EE: PR diff checks for contributor-fork PRs clone the synthetic
pull/<n>/head ref (head.ref doesn't exist in the base repo)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* chore(git-sync): bump pull script pin to hub/28787 (synthetic PR ref support)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* fix(git-sync): targeted jsonb update for open_pr_error (no full-blob clobber)
The full read-modify-write raced the poller's concurrent last_synced_sha /
last_pull_status writes on the same column; mirror the EE status writer and
update only the matching repository element's open_pr_error key.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* style(git-sync): inline EE badge on gated toggles (matches settings nav)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* copy(git-sync): both directions in page/card descriptions; clearer promotion flow
- Page header and sync-card description mention the pull direction, not only
push-on-deploy
- Promotion description walks the actual flow (wm_deploy/** branch, merge to
promote, sync the target workspace) and points at the PR toggle / workflow;
the Git Promotion docs link now also shows on configured cards, not only in
the empty state
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* fix(git-sync): resolve branch-less resources' default branch for fork sync
A git resource without an explicit branch polled as the bare "HEAD" ref,
which the fork/dev-label fan-out cannot scope (wm-fork/<branch>/*), so fork
sync silently never ran on polling-only repos. Resolve the remote's default
branch name with `ls-remote --symref HEAD` (one call for name + head sha);
"HEAD" only remains when resolution fails. The polling e2e test now uses a
branch-less resource to cover this shape.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* fix(git-sync): runtime license gate for in-app PR creation
promotion_open_prs/fork_open_prs are rejected on save without an Enterprise
plan (like auto_pull), and the deploy completion hook re-checks the plan
before opening PRs so flags stored while licensed stop driving GitHub calls
after a lapse.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* feat(git-sync): app-aware pull defaults, always webhook delivery, token-repo guidance
- Pull-from-Git defaults on only for app-backed repos (applied when the
selected resource resolves); polling is opt-in for token repositories,
with a warning alert recommending the GitHub App (instant pull + in-app
PRs) or the sync GitHub workflow
- App repos always use webhook delivery with polling fallback: the delivery
selector is gone and a stored polling mode is normalized back to auto
- Post-save modal reflects the auto-pull state instead of telling the user
to turn on a toggle that is already on
- Non-app PR hints recommend the GitHub App explicitly
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* copy(git-sync): single info box for token-repo pull guidance
Merges the instant-pull recommendation with the GitHub Action conflict note,
shown only for non-app repos; app repos need neither, and the redundant
'instant webhook sync requires' line is gone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* copy(git-sync): keep the GitHub Action conflict note on app repos
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* copy(git-sync): anchor docs links to their exact sections
GitHub App references point at integrations/git_repository#github-app, the
workflow hints at deploy_gh_gl#github-actions-setup, and the sync workflow
at git_sync#github-actions (all anchors verified against the live docs).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* copy(git-sync): fork workflow hint links to git_sync#github-actions
open-pr-on-fork-commit is documented on the git_sync page, not deploy_gh_gl.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* fix(git-sync): fork PRs are opt-in on new connections too
Only auto-pull and fork sync default on for new app-backed connections;
opening pull requests stays a deliberate per-repo decision.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* feat(git-sync): default the managed PR on for new app-backed promotion repos
A promotion deploy's wm_deploy/** branch exists to be merged; without a PR
it's an orphaned branch. Fork PRs stay opt-in. Also scope the sync-repo
auto-pull default to sync mode so promotion repos can't pick it up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* docs(git-sync): GHES self-managed app permission setup
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* docs(git-sync): frame permission update against GitHub Actions, not polling
Existing installations don't have polling; their git-to-Windmill direction
runs on GitHub Actions today, so the approval text describes the update as
replacing those workflows and notes every feature is opt-in.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
* copy(workspaces): drop 'cosmetic' qualifier from dev-workspace label UI
* chore: update ee-repo-ref to 9b2a6375f838436cf68cff449cc9bc621cca5281
This commit updates the EE repository reference after PR #632 was merged in windmill-ee-private.
Previous ee-repo-ref: 99eef24e2f0402b9a997cde5f67be52ee5d54b0e
New ee-repo-ref: 9b2a6375f838436cf68cff449cc9bc621cca5281
Automated by sync-ee-ref workflow.
* fix(git-sync): reject '/' in fork and dev workspace ids
* fix(git-sync): bound auto-pull git probes with a per-command timeout
* fix(git-sync): persist webhook reconcile via targeted jsonb updates
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
1919 lines
68 KiB
Rust
1919 lines
68 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use windmill_api_auth::{require_super_admin, ApiAuthed};
|
|
use windmill_common::DB;
|
|
|
|
use crate::workspaces::{
|
|
archive_workspace_impl, check_w_id_conflict, CREATE_WORKSPACE_REQUIRE_SUPERADMIN,
|
|
};
|
|
|
|
use axum::extract::Query;
|
|
use axum::{
|
|
extract::{Extension, Path},
|
|
Json,
|
|
};
|
|
|
|
use sqlx::{Postgres, Transaction};
|
|
use tracing::info;
|
|
use windmill_audit::audit_oss::audit_log;
|
|
use windmill_audit::ActionKind;
|
|
|
|
use windmill_common::worker::CLOUD_HOSTED;
|
|
|
|
use windmill_common::{
|
|
auth::is_super_admin_email,
|
|
db::UserDB,
|
|
error::{Error, Result},
|
|
utils::require_admin,
|
|
workspaces::{DataTable, DEV_WORKSPACE_LOCK_RULE_NAME, WM_FORK_PREFIX},
|
|
};
|
|
use windmill_queue::schedule::{get_schedule_opt, push_scheduled_job};
|
|
|
|
use serde::Deserialize;
|
|
|
|
#[derive(Deserialize)]
|
|
pub(crate) struct ChangeWorkspaceId {
|
|
new_id: String,
|
|
new_name: String,
|
|
}
|
|
|
|
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(),
|
|
));
|
|
}
|
|
|
|
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?;
|
|
|
|
info!(
|
|
"Changing workspace id from {} to {} (move and archive approach)",
|
|
old_id, rw.new_id
|
|
);
|
|
|
|
// Create new workspace with new id and name. Fork lineage AND the dev designation are preserved
|
|
// from the source row, not inferred from the new id's prefix: a prefix-less fork (a dev or
|
|
// detached-dev workspace) would otherwise be silently promoted to a root workspace, and a dev
|
|
// would lose its flag — leaving its prod locked with no canonical dev. Promoting out of a fork is
|
|
// a separate, explicit action — a rename never does it implicitly.
|
|
info!("Creating new workspace row");
|
|
let old = sqlx::query!(
|
|
r#"SELECT (parent_workspace_id IS NOT NULL) AS "has_parent!", is_dev_workspace
|
|
FROM workspace WHERE id = $1"#,
|
|
&old_id
|
|
)
|
|
.fetch_optional(&mut *tx)
|
|
.await?;
|
|
let new_is_fork = old.as_ref().map(|o| o.has_parent).unwrap_or(false);
|
|
let new_is_dev = new_is_fork && old.as_ref().map(|o| o.is_dev_workspace).unwrap_or(false);
|
|
// Neutralize the old row's dev flag BEFORE inserting the new one: the move-and-archive archives
|
|
// the old row only later, so without this the new dev row and the not-yet-archived old dev row
|
|
// would momentarily both be active under the same parent and trip the one-dev-per-parent index.
|
|
if new_is_dev {
|
|
sqlx::query!(
|
|
"UPDATE workspace SET is_dev_workspace = false WHERE id = $1",
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
sqlx::query!(
|
|
"INSERT INTO workspace (id, name, owner, deleted, premium, parent_workspace_id, is_dev_workspace, dev_workspace_label)
|
|
SELECT $1, $2, owner, false, premium,
|
|
CASE WHEN $4 THEN parent_workspace_id ELSE NULL END, $5,
|
|
CASE WHEN $5 THEN dev_workspace_label ELSE NULL END
|
|
FROM workspace WHERE id = $3",
|
|
&rw.new_id,
|
|
&rw.new_name,
|
|
&old_id,
|
|
new_is_fork,
|
|
new_is_dev
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
// Duplicate workspace settings (keep copy in old workspace for reference)
|
|
info!("Duplicating workspace_settings table");
|
|
sqlx::query!(
|
|
"INSERT INTO workspace_settings SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, deploy_to, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler FROM workspace_settings WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
// The managed git-sync webhooks deliver to /api/w/{old_id}/... — a URL the
|
|
// renamed workspace no longer answers on (the old id is archived and the
|
|
// receiver skips it). Strip the webhook fields from the new row so polling
|
|
// resumes at the normal interval and the next settings save re-registers a
|
|
// hook with the new URL; the stale hooks are deleted after commit.
|
|
#[allow(unused_mut)]
|
|
let mut stale_webhooks: Vec<(String, i64)> = Vec::new();
|
|
if let Some(git_sync) = sqlx::query_scalar!(
|
|
"SELECT git_sync FROM workspace_settings WHERE workspace_id = $1",
|
|
&rw.new_id
|
|
)
|
|
.fetch_optional(&mut *tx)
|
|
.await?
|
|
.flatten()
|
|
{
|
|
if let Ok(mut settings) = serde_json::from_value::<
|
|
windmill_common::workspaces::WorkspaceGitSyncSettings,
|
|
>(git_sync)
|
|
{
|
|
let mut changed = false;
|
|
for r in settings.repositories.iter_mut() {
|
|
if let Some(ap) = r.auto_pull.as_mut() {
|
|
if let Some(hook) = ap.webhook_id {
|
|
stale_webhooks.push((r.git_repo_resource_path.clone(), hook));
|
|
}
|
|
changed |= ap.webhook_id.is_some()
|
|
|| ap.webhook_secret.is_some()
|
|
|| ap.webhook_error.is_some();
|
|
ap.webhook_id = None;
|
|
ap.webhook_secret = None;
|
|
ap.webhook_error = None;
|
|
}
|
|
}
|
|
if changed {
|
|
let serialized = serde_json::to_value(&settings)
|
|
.map_err(|e| Error::internal_err(e.to_string()))?;
|
|
sqlx::query!(
|
|
"UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2",
|
|
serialized,
|
|
&rw.new_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
}
|
|
}
|
|
|
|
info!("Duplicating workspace_key table");
|
|
sqlx::query!(
|
|
"INSERT INTO workspace_key SELECT $1, kind, key FROM workspace_key WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating workspace_env table");
|
|
sqlx::query!(
|
|
"UPDATE workspace_env SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating workspace_invite table");
|
|
sqlx::query!(
|
|
"UPDATE workspace_invite SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating account table");
|
|
sqlx::query!(
|
|
"UPDATE account SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating app table");
|
|
sqlx::query!(
|
|
"UPDATE app SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating capture table");
|
|
sqlx::query!(
|
|
"UPDATE capture SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating capture_config table");
|
|
sqlx::query!(
|
|
"UPDATE capture_config SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating http_trigger table");
|
|
sqlx::query!(
|
|
"UPDATE http_trigger SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating websocket_trigger table");
|
|
sqlx::query!(
|
|
"UPDATE websocket_trigger SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating kafka_trigger table");
|
|
sqlx::query!(
|
|
"UPDATE kafka_trigger SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating nats_trigger table");
|
|
sqlx::query!(
|
|
"UPDATE nats_trigger SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating postgres_trigger table");
|
|
sqlx::query!(
|
|
"UPDATE postgres_trigger SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating mqtt_trigger table");
|
|
sqlx::query!(
|
|
"UPDATE mqtt_trigger SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating gcp_trigger table");
|
|
sqlx::query!(
|
|
"UPDATE gcp_trigger SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating sqs_trigger table");
|
|
sqlx::query!(
|
|
"UPDATE sqs_trigger SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating email_trigger table");
|
|
sqlx::query!(
|
|
"UPDATE email_trigger SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating native_trigger table");
|
|
sqlx::query!(
|
|
"UPDATE native_trigger SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating dependency_map table");
|
|
sqlx::query!(
|
|
"UPDATE dependency_map SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating macro_definition table");
|
|
sqlx::query!(
|
|
"UPDATE macro_definition SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating macro_usage table");
|
|
sqlx::query!(
|
|
"UPDATE macro_usage SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating deployment_metadata table");
|
|
sqlx::query!(
|
|
"UPDATE deployment_metadata SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating draft table");
|
|
sqlx::query!(
|
|
"UPDATE draft SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating favorite table");
|
|
sqlx::query!(
|
|
"UPDATE favorite SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
// Duplicate flow table rows (FK constraint requires insert then delete)
|
|
info!("Duplicating flow table rows");
|
|
sqlx::query!(
|
|
"INSERT INTO flow
|
|
(workspace_id, path, summary, description, archived, extra_perms, dependency_job, 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, lock_error_logs)
|
|
SELECT $1, path, summary, description, archived, extra_perms, dependency_job, 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, lock_error_logs
|
|
FROM flow WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating flow_version table");
|
|
sqlx::query!(
|
|
"UPDATE flow_version SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating workspace_runnable_dependencies table");
|
|
sqlx::query!(
|
|
"UPDATE workspace_runnable_dependencies SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating workspace_dependencies table");
|
|
sqlx::query!(
|
|
"UPDATE workspace_dependencies SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating workspace_diff table");
|
|
sqlx::query!(
|
|
"UPDATE workspace_diff SET source_workspace_id = $1 WHERE source_workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
sqlx::query!(
|
|
"UPDATE workspace_diff SET fork_workspace_id = $1 WHERE fork_workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating workspace_fork_deployment_request table");
|
|
sqlx::query!(
|
|
"UPDATE workspace_fork_deployment_request SET source_workspace_id = $1 WHERE source_workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
sqlx::query!(
|
|
"UPDATE workspace_fork_deployment_request SET fork_workspace_id = $1 WHERE fork_workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
// Re-parent child forks: any fork whose parent_workspace_id was the old id
|
|
// must follow the renamed parent to the new id, otherwise it is left
|
|
// pointing at the soft-deleted old shell (whose data has moved here).
|
|
info!("Re-parenting child forks to the new workspace id");
|
|
let reparented_children: Vec<String> = sqlx::query_scalar!(
|
|
"UPDATE workspace SET parent_workspace_id = $1 WHERE parent_workspace_id = $2 RETURNING id",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.fetch_all(&mut *tx)
|
|
.await?;
|
|
|
|
// A dev/fork's `deploy_to` points at the prod root, so it must follow the rename too — otherwise
|
|
// the child re-parents to the new id but still deploys to the soft-deleted old shell.
|
|
sqlx::query!(
|
|
"UPDATE workspace_settings SET deploy_to = $1 WHERE deploy_to = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating workspace_protection_rule table");
|
|
sqlx::query!(
|
|
"UPDATE workspace_protection_rule SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating workspace_integrations table");
|
|
sqlx::query!(
|
|
"UPDATE workspace_integrations SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating ai_agent_memory table");
|
|
sqlx::query!(
|
|
"UPDATE ai_agent_memory SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating flow_conversation table");
|
|
sqlx::query!(
|
|
"UPDATE flow_conversation SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating mcp_oauth_refresh_token table");
|
|
sqlx::query!(
|
|
"UPDATE mcp_oauth_refresh_token SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating mcp_oauth_server_code table");
|
|
sqlx::query!(
|
|
"UPDATE mcp_oauth_server_code SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Duplicating skip_workspace_diff_tally table");
|
|
sqlx::query!(
|
|
"INSERT INTO skip_workspace_diff_tally SELECT $1, added_at FROM skip_workspace_diff_tally WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating asset table");
|
|
sqlx::query!(
|
|
"UPDATE asset SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating flow_node table");
|
|
sqlx::query!(
|
|
"UPDATE flow_node SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Deleting old flow rows");
|
|
sqlx::query!("DELETE FROM flow WHERE workspace_id = $1", &old_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
// Duplicate group_ with new workspace id (FK constraint)
|
|
info!("Duplicating group_ table rows");
|
|
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?;
|
|
|
|
info!("Updating usr_to_group table");
|
|
sqlx::query!(
|
|
"UPDATE usr_to_group SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating group_permission_history table");
|
|
sqlx::query!(
|
|
"UPDATE group_permission_history SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Deleting old group_ rows");
|
|
sqlx::query!("DELETE FROM group_ WHERE workspace_id = $1", &old_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
// Duplicate folders with new workspace id (FK constraint)
|
|
info!("Duplicating folder table rows");
|
|
sqlx::query!(
|
|
"INSERT INTO folder (name, workspace_id, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as, labels) \
|
|
SELECT name, $1, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as, labels \
|
|
FROM folder WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating folder_permission_history table");
|
|
sqlx::query!(
|
|
"UPDATE folder_permission_history SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Deleting old folder rows");
|
|
sqlx::query!("DELETE FROM folder WHERE workspace_id = $1", &old_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating input table");
|
|
sqlx::query!(
|
|
"UPDATE input SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
// Get enabled schedules and clear their queued jobs BEFORE moving jobs
|
|
// This way we don't need to filter out scheduled jobs - they're already removed
|
|
let enabled_schedule_paths: Vec<String> = sqlx::query_scalar!(
|
|
"SELECT path FROM schedule WHERE workspace_id = $1 AND enabled = true",
|
|
&old_id
|
|
)
|
|
.fetch_all(&mut *tx)
|
|
.await?;
|
|
|
|
info!(
|
|
"Found {} enabled schedules, clearing their queued jobs",
|
|
enabled_schedule_paths.len()
|
|
);
|
|
|
|
for schedule_path in &enabled_schedule_paths {
|
|
windmill_queue::schedule::clear_schedule(&mut tx, schedule_path, &old_id).await?;
|
|
}
|
|
|
|
// Move queued jobs (not running) to new workspace using skip lock
|
|
// Scheduled jobs were already cleared above, so no need to filter them
|
|
info!("Moving v2_job_queue entries to new workspace");
|
|
sqlx::query!(
|
|
"UPDATE v2_job_queue SET workspace_id = $1
|
|
WHERE id IN (
|
|
SELECT id FROM v2_job_queue
|
|
WHERE workspace_id = $2
|
|
AND running = false
|
|
AND id IN (SELECT id FROM v2_job WHERE workspace_id = $2 AND parent_job IS NULL)
|
|
FOR UPDATE SKIP LOCKED
|
|
)",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating v2_job table for moved queue entries");
|
|
sqlx::query!(
|
|
"UPDATE v2_job SET workspace_id = $1
|
|
WHERE workspace_id = $2
|
|
AND id IN (SELECT id FROM v2_job_queue WHERE workspace_id = $1)",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating job_perms table for migrated jobs");
|
|
sqlx::query!(
|
|
"UPDATE job_perms SET workspace_id = $1
|
|
WHERE job_id IN (SELECT id FROM v2_job WHERE workspace_id = $1)",
|
|
&rw.new_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating raw_app table");
|
|
sqlx::query!(
|
|
"UPDATE raw_app SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Deleting raw_script_temp table");
|
|
sqlx::query!(
|
|
"DELETE FROM raw_script_temp WHERE workspace_id = $1",
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating resource table");
|
|
sqlx::query!(
|
|
"UPDATE resource SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating resource_type table");
|
|
sqlx::query!(
|
|
"UPDATE resource_type SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating schedule table");
|
|
sqlx::query!(
|
|
"UPDATE schedule SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating script table");
|
|
sqlx::query!(
|
|
"UPDATE script SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating token table");
|
|
sqlx::query!(
|
|
"UPDATE token SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating usage table");
|
|
sqlx::query!(
|
|
"UPDATE usage SET id = $1 WHERE id = $2 AND is_workspace = true",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Duplicating usr table");
|
|
sqlx::query!(
|
|
"INSERT INTO usr SELECT $1, username, email, is_admin, created_at, operator, disabled, role FROM usr WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
info!("Updating variable table");
|
|
sqlx::query!(
|
|
"UPDATE variable SET workspace_id = $1 WHERE workspace_id = $2",
|
|
&rw.new_id,
|
|
&old_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
// Re-push jobs for enabled schedules in the new workspace (same transaction)
|
|
info!(
|
|
"Re-pushing jobs for {} enabled schedules",
|
|
enabled_schedule_paths.len()
|
|
);
|
|
for schedule_path in &enabled_schedule_paths {
|
|
if let Some(schedule) = get_schedule_opt(&mut *tx, &rw.new_id, schedule_path).await? {
|
|
tx = push_scheduled_job(&db, tx, &schedule, None, None).await?;
|
|
}
|
|
}
|
|
|
|
// Audit log in the same transaction as the workspace changes
|
|
audit_log(
|
|
&mut *tx,
|
|
&authed,
|
|
"workspace.change_workspace_id",
|
|
ActionKind::Update,
|
|
&rw.new_id,
|
|
Some(&authed.email),
|
|
Some(
|
|
[("old_workspace_id", old_id.as_str())]
|
|
.into_iter()
|
|
.collect::<HashMap<&str, &str>>(),
|
|
),
|
|
)
|
|
.await?;
|
|
|
|
tx.commit().await?;
|
|
|
|
// Best-effort: the hooks stripped above still exist on GitHub pointing at
|
|
// the old workspace URL; remove them (resources already live under the new id).
|
|
#[cfg(all(feature = "enterprise", feature = "private"))]
|
|
for (path, hook_id) in stale_webhooks {
|
|
if let Ok(url) =
|
|
windmill_common::git_sync_ee::resolve_repo_url(&db, &rw.new_id, &path).await
|
|
{
|
|
let _ =
|
|
windmill_common::git_sync_ee::delete_repo_webhook(&db, &rw.new_id, &url, hook_id)
|
|
.await;
|
|
}
|
|
}
|
|
|
|
// The children's parent_workspace_id changed (old root -> new root); invalidate their fork-parent
|
|
// routing cache and their billing-workspace mapping so jobs route + meter under the renamed root
|
|
// rather than the old (archived) one, instead of waiting for the caches' TTLs. Deeper descendants
|
|
// (fork-of-fork) self-heal via the 60s billing-cache TTL.
|
|
for child in &reparented_children {
|
|
windmill_queue::tags::invalidate_fork_parent_cache(child);
|
|
windmill_common::workspaces::invalidate_fork_ancestor_chain_cache(child);
|
|
// Grandchildren's cached chains contain the old (renamed-away) ancestor id; a stale
|
|
// chain drops all defer ancestors in the ducklake resolver, so sweep the subtree
|
|
// rather than letting it wait out the TTL.
|
|
for id in windmill_common::workspaces::list_fork_descendants(&db, child)
|
|
.await
|
|
.unwrap_or_default()
|
|
{
|
|
windmill_common::workspaces::invalidate_fork_ancestor_chain_cache(&id);
|
|
}
|
|
#[cfg(feature = "cloud")]
|
|
windmill_common::workspaces::invalidate_billing_workspace_cache(child);
|
|
}
|
|
|
|
// Archive old workspace: disable schedules, cancel remaining jobs, set deleted=true
|
|
// Note: schedules were already moved to new workspace, so this will find 0 schedules
|
|
info!("Archiving old workspace");
|
|
let (_schedules_count, canceled_count, _deleted_tokens_count) =
|
|
archive_workspace_impl(&db, &old_id, &authed.username, None).await?;
|
|
|
|
info!(
|
|
"Workspace id change completed: moved {} to {}, archived old workspace",
|
|
old_id, rw.new_id
|
|
);
|
|
|
|
Ok(format!(
|
|
"Moved workspace from {} to {}, archived old workspace (canceled {} remaining jobs)",
|
|
&old_id, &rw.new_id, canceled_count
|
|
))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub(crate) struct DeleteWorkspaceQuery {
|
|
pub(crate) only_delete_forks: Option<bool>,
|
|
}
|
|
|
|
pub(crate) async fn delete_workspace(
|
|
Extension(db): Extension<DB>,
|
|
Path(w_id): Path<String>,
|
|
authed: ApiAuthed,
|
|
Query(dwq): Query<DeleteWorkspaceQuery>,
|
|
) -> Result<String> {
|
|
let w_id = match w_id.as_str() {
|
|
"starter" => Err(Error::BadRequest(
|
|
"starter workspace cannot be deleted".to_string(),
|
|
)),
|
|
"admins" => Err(Error::BadRequest(
|
|
"admins workspace cannot be deleted".to_string(),
|
|
)),
|
|
_ => Ok(w_id),
|
|
}?;
|
|
|
|
let is_fork = workspace_is_fork(&db, &w_id).await?;
|
|
if dwq.only_delete_forks.unwrap_or(false) && !is_fork {
|
|
return Err(Error::BadRequest(
|
|
"Cannot delete this workspace because it is not a workspace fork.".to_string(),
|
|
));
|
|
}
|
|
|
|
let mut tx = db.begin().await?;
|
|
if !(is_fork && is_workspace_owner(&authed, &w_id, &mut tx).await?)
|
|
&& !is_super_admin_email(&db, &authed.email).await?
|
|
{
|
|
return Err(Error::PermissionDenied(
|
|
"Deleting this workspace requires being the fork's owner or a superadmin".to_string(),
|
|
));
|
|
}
|
|
|
|
// Don't hard-delete a workspace that still has a dev workspace paired to it: the FK is
|
|
// ON DELETE SET NULL, which would orphan the (prefix-less) dev into a parentless, non-fork row
|
|
// its owner could no longer self-delete. Require detaching/deleting the dev first. Ordinary
|
|
// forks have no such guard — they keep their prefix and stay owner-deletable when orphaned.
|
|
// Archived devs (deleted = true) are included: they keep is_dev_workspace = true, so SET NULL on
|
|
// their parent would violate the `is_dev ⇒ has parent` CHECK and fail the whole delete with a 500.
|
|
if let Some(dev_id) = sqlx::query_scalar!(
|
|
"SELECT id FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace",
|
|
&w_id
|
|
)
|
|
.fetch_optional(&mut *tx)
|
|
.await?
|
|
{
|
|
return Err(Error::BadRequest(format!(
|
|
"Cannot delete workspace '{}' because it has a dev workspace ('{}'). Detach or delete the dev workspace first.",
|
|
w_id, dev_id
|
|
)));
|
|
}
|
|
|
|
// Deleting an attached dev workspace removes the parent prod's dev_workspace_lock (below), so it
|
|
// must be a prod-admin action, not just the dev's own owner (dev ownership can diverge from
|
|
// prod's) — mirrors detach_dev_workspace, which is prod-admin gated.
|
|
require_prod_admin_for_dev_workspace(&db, &authed, &w_id).await?;
|
|
|
|
// Snapshot the fork's ducklake namespaces + RESOLVE their connection material NOW — the
|
|
// registry rows and the fork's `$res:` resources both CASCADE with the workspace row —
|
|
// but the destructive cleanup itself runs only after the commit below: a delete that
|
|
// fails mid-way must never leave a live workspace with its fork data destroyed and no
|
|
// registry row to retry from. Read-only: nothing is dropped here.
|
|
let fork_ducklake_cleanups = prepare_fork_ducklake_cleanups(&db, &w_id, None)
|
|
.await
|
|
.unwrap_or_else(|e| {
|
|
tracing::warn!("deleting workspace {w_id}: preparing ducklake cleanup: {e:#}");
|
|
vec![]
|
|
});
|
|
|
|
sqlx::query!("DELETE FROM ai_agent_memory WHERE workspace_id = $1", &w_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
sqlx::query!(
|
|
"DELETE FROM flow_conversation WHERE workspace_id = $1",
|
|
&w_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
sqlx::query!("DELETE FROM workspace_env WHERE workspace_id = $1", &w_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
sqlx::query!("DELETE FROM dependency_map WHERE workspace_id = $1", &w_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
sqlx::query!("DELETE FROM macro_usage WHERE workspace_id = $1", &w_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
sqlx::query!(
|
|
"DELETE FROM macro_definition WHERE workspace_id = $1",
|
|
&w_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
sqlx::query!("DELETE FROM v2_job_queue WHERE workspace_id = $1", &w_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
// dispatch_event / flow_conversation_message / zombie_job_counter no longer cascade from
|
|
// v2_job (see migration drop_v2_job_side_table_cascades); delete them before v2_job so the
|
|
// workspace's jobs leave no orphan side rows. One round-trip, scanning v2_job once.
|
|
sqlx::query!(
|
|
"WITH ids AS (SELECT id FROM v2_job WHERE workspace_id = $1),
|
|
_de AS (DELETE FROM dispatch_event WHERE workspace_id = $1),
|
|
_fc AS (DELETE FROM flow_conversation_message WHERE job_id IN (SELECT id FROM ids))
|
|
DELETE FROM zombie_job_counter WHERE job_id IN (SELECT id FROM ids)",
|
|
&w_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
sqlx::query!("DELETE FROM v2_job WHERE workspace_id = $1", &w_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
sqlx::query!("DELETE FROM capture WHERE workspace_id = $1", &w_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
// capture_config has on delete cascade
|
|
|
|
sqlx::query!("DELETE FROM draft WHERE workspace_id = $1", &w_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
sqlx::query!("DELETE FROM script WHERE workspace_id = $1", &w_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
sqlx::query!("DELETE FROM flow WHERE workspace_id = $1", &w_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
sqlx::query!("DELETE FROM app WHERE workspace_id = $1", &w_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
sqlx::query!("DELETE FROM raw_app WHERE workspace_id = $1", &w_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
sqlx::query!("DELETE FROM input WHERE workspace_id = $1", &w_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
sqlx::query!("DELETE FROM variable WHERE workspace_id = $1", &w_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
sqlx::query!("DELETE FROM resource WHERE workspace_id = $1", &w_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
sqlx::query!("DELETE FROM schedule WHERE workspace_id = $1", &w_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
sqlx::query!(
|
|
"DELETE FROM v2_job_completed WHERE workspace_id = $1",
|
|
&w_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
sqlx::query!("DELETE FROM job_stats WHERE workspace_id = $1", &w_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
sqlx::query!(
|
|
"DELETE FROM deployment_metadata WHERE workspace_id = $1",
|
|
&w_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
sqlx::query!("DELETE FROM usr WHERE workspace_id = $1", &w_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
sqlx::query!("DELETE FROM resource_type WHERE workspace_id = $1", &w_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
sqlx::query!(
|
|
"DELETE FROM workspace_invite WHERE workspace_id = $1",
|
|
&w_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
sqlx::query!("DELETE FROM usr_to_group WHERE workspace_id = $1", &w_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
sqlx::query!("DELETE FROM group_ WHERE workspace_id = $1", &w_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
sqlx::query!("DELETE FROM folder WHERE workspace_id = $1", &w_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
sqlx::query!("DELETE FROM account WHERE workspace_id = $1", &w_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
sqlx::query!("DELETE FROM workspace_key WHERE workspace_id = $1", &w_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
sqlx::query!(
|
|
"DELETE FROM workspace_settings WHERE workspace_id = $1",
|
|
&w_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
sqlx::query!("DELETE FROM token WHERE workspace_id = $1", &w_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
sqlx::query!("DELETE FROM http_trigger WHERE workspace_id = $1", &w_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
sqlx::query!(
|
|
"DELETE FROM websocket_trigger WHERE workspace_id = $1",
|
|
&w_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
sqlx::query!("DELETE FROM kafka_trigger WHERE workspace_id = $1", &w_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
// NATS triggers have on delete cascade
|
|
|
|
sqlx::query!("DELETE FROM raw_script_temp WHERE workspace_id = $1", &w_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
// workspace_diff and skip_workspace_diff_tally are keyed by workspace id with no
|
|
// FK cascade. A fork id is reused when a fork is deleted and recreated under the
|
|
// same name, so leaving these rows behind leaks the previous fork's cached diff
|
|
// verdicts onto the new fork — causing a spurious "changes not visible" warning
|
|
// that hides the deploy button.
|
|
sqlx::query!(
|
|
"DELETE FROM workspace_diff WHERE source_workspace_id = $1 OR fork_workspace_id = $1",
|
|
&w_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
sqlx::query!(
|
|
"DELETE FROM skip_workspace_diff_tally WHERE workspace_id = $1",
|
|
&w_id
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
// If this workspace is itself a dev workspace, deleting it dissolves the pairing, so also drop
|
|
// the parent prod's reserved dev_workspace_lock (mirrors detach_dev_workspace) — otherwise prod
|
|
// stays locked against direct deploy/forking with no dev workspace left to make changes in.
|
|
let dev_lock_parent: Option<String> = sqlx::query_scalar!(
|
|
"SELECT parent_workspace_id FROM workspace WHERE id = $1 AND is_dev_workspace",
|
|
&w_id
|
|
)
|
|
.fetch_optional(&mut *tx)
|
|
.await?
|
|
.flatten();
|
|
|
|
// Capture direct child forks before the delete: the FK is ON DELETE SET NULL, so they're about to
|
|
// be orphaned (their billing root changes from this workspace's root to themselves). We drop their
|
|
// cached mappings after commit alongside the deleted id itself.
|
|
let orphaned_children: Vec<String> = sqlx::query_scalar!(
|
|
"SELECT id FROM workspace WHERE parent_workspace_id = $1",
|
|
&w_id
|
|
)
|
|
.fetch_all(&mut *tx)
|
|
.await?;
|
|
|
|
sqlx::query!("DELETE FROM workspace WHERE id = $1", &w_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
if let Some(ref parent) = dev_lock_parent {
|
|
sqlx::query!(
|
|
"DELETE FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2",
|
|
parent,
|
|
DEV_WORKSPACE_LOCK_RULE_NAME
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
|
|
// Record under the instance-level "admins" workspace. The per-workspace audit
|
|
// rows are deleted along with the workspace, so this instance-level entry is the
|
|
// only durable, superadmin-discoverable record of who deleted the workspace.
|
|
audit_log(
|
|
&mut *tx,
|
|
&authed,
|
|
"workspaces.delete",
|
|
ActionKind::Delete,
|
|
"admins",
|
|
Some(&w_id),
|
|
None,
|
|
)
|
|
.await?;
|
|
tx.commit().await?;
|
|
|
|
// Physical ducklake-namespace cleanup, post-commit, from the pre-read snapshot: fork
|
|
// namespaces are deterministic from (id, lake), so an orphan would silently REATTACH to a
|
|
// recreated identical fork id. Runs inline so every delete path is covered (CLI,
|
|
// force-delete dialog, direct API — not just the sidebar flow, which still calls the
|
|
// endpoint first for per-lake error toasts; the rerun is an idempotent no-op). Best
|
|
// effort: failures are logged — the workspace row is already gone, and broken storage
|
|
// credentials must not have made it undeletable.
|
|
for e in cleanup_fork_ducklake_namespaces(&db, &w_id, fork_ducklake_cleanups).await {
|
|
tracing::warn!(
|
|
"deleted workspace {w_id}: ducklake namespace cleanup: {}",
|
|
e.msg
|
|
);
|
|
}
|
|
|
|
if let Some(parent) = dev_lock_parent {
|
|
windmill_common::workspaces::invalidate_protection_rules_cache(&parent);
|
|
}
|
|
|
|
// Workspace ids are reusable after permanent deletion, so drop every cached mapping keyed by the
|
|
// deleted id (and any just-orphaned children) — otherwise a recreated id could inherit the gone
|
|
// workspace's state within the caches' lifetimes. This covers fork->parent (tag routing) and
|
|
// fork->root (billing), plus the premium/team-plan status: TEAM_PLAN_CACHE has no TTL and is only
|
|
// evicted by the premium-change NOTIFY, so without this a reused id would keep the old workspace's
|
|
// premium indefinitely (free forks/usage). Deeper (grandchild) descendants self-heal via the 60s
|
|
// billing TTL.
|
|
for id in std::iter::once(&w_id).chain(orphaned_children.iter()) {
|
|
windmill_queue::tags::invalidate_fork_parent_cache(id);
|
|
windmill_common::workspaces::invalidate_fork_ancestor_chain_cache(id);
|
|
#[cfg(feature = "cloud")]
|
|
{
|
|
windmill_common::workspaces::invalidate_billing_workspace_cache(id);
|
|
windmill_common::workspaces::invalidate_team_plan_cache(id);
|
|
}
|
|
}
|
|
// Deeper descendants' cached ancestor CHAINS still contain the deleted workspace; unlike
|
|
// the sibling caches (which self-heal harmlessly via TTL), a stale chain makes the
|
|
// ducklake resolver drop all defer ancestors (all-or-nothing on broken links) — a visible
|
|
// defer/chips outage for up to the TTL. Anchor at the orphaned children: the deleted row
|
|
// is gone, but their subtrees are intact.
|
|
for child in orphaned_children.iter() {
|
|
for id in windmill_common::workspaces::list_fork_descendants(&db, child)
|
|
.await
|
|
.unwrap_or_default()
|
|
{
|
|
windmill_common::workspaces::invalidate_fork_ancestor_chain_cache(&id);
|
|
}
|
|
}
|
|
|
|
Ok(format!("Deleted workspace {}", &w_id))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct DropForkedDatatableDatabasesRequest {
|
|
datatable_names: Vec<String>,
|
|
}
|
|
|
|
/// Drop forked datatable databases. Returns errors per datatable that failed.
|
|
/// Same permission as delete_workspace: fork owner or super admin.
|
|
pub async fn drop_forked_datatable_databases(
|
|
authed: ApiAuthed,
|
|
Extension(user_db): Extension<UserDB>,
|
|
Extension(db): Extension<DB>,
|
|
Path(w_id): Path<String>,
|
|
Json(req): Json<DropForkedDatatableDatabasesRequest>,
|
|
) -> Result<Json<Vec<String>>> {
|
|
// Same permission check as delete_workspace: fork owner or super admin
|
|
let is_fork = workspace_is_fork(&db, &w_id).await?;
|
|
let mut tx = db.begin().await?;
|
|
if !(is_fork && is_workspace_owner(&authed, &w_id, &mut tx).await?)
|
|
&& !is_super_admin_email(&db, &authed.email).await?
|
|
{
|
|
return Err(Error::PermissionDenied(
|
|
"Dropping forked datatable databases requires being the fork's owner or a superadmin"
|
|
.to_string(),
|
|
));
|
|
}
|
|
tx.commit().await?;
|
|
|
|
let parent_w_id = sqlx::query_scalar!(
|
|
"SELECT parent_workspace_id FROM workspace WHERE id = $1",
|
|
&w_id
|
|
)
|
|
.fetch_optional(&db)
|
|
.await?
|
|
.flatten()
|
|
.ok_or_else(|| Error::BadRequest("No parent workspace found".to_string()))?;
|
|
|
|
let datatable_config = sqlx::query_scalar!(
|
|
"SELECT datatable->'datatables' FROM workspace_settings WHERE workspace_id = $1",
|
|
&w_id
|
|
)
|
|
.fetch_optional(&db)
|
|
.await?
|
|
.flatten()
|
|
.unwrap_or(serde_json::json!({}));
|
|
|
|
let datatables: HashMap<String, DataTable> =
|
|
serde_json::from_value(datatable_config).unwrap_or_default();
|
|
|
|
let mut errors: Vec<String> = Vec::new();
|
|
|
|
for dt_name in &req.datatable_names {
|
|
let dt = match datatables.get(dt_name) {
|
|
Some(dt) if dt.forked_from.is_some() => dt,
|
|
_ => continue,
|
|
};
|
|
|
|
if dt.database.resource_type
|
|
== windmill_common::workspaces::DataTableCatalogResourceType::Instance
|
|
{
|
|
let db_to_drop = &dt.database.resource_path;
|
|
if !db_to_drop.starts_with("wm_fork_") {
|
|
errors.push(format!(
|
|
"Refusing to drop instance database '{}' for datatable://{}: name does not start with 'wm_fork_'",
|
|
db_to_drop, dt_name
|
|
));
|
|
continue;
|
|
}
|
|
if let Err(e) = windmill_common::drop_custom_instance_database(&db, db_to_drop).await {
|
|
errors.push(format!(
|
|
"Could not drop instance database '{}' for datatable://{}: {}",
|
|
db_to_drop, dt_name, e
|
|
));
|
|
}
|
|
} else {
|
|
let fork_pg = match crate::workspaces::resolve_pg_source_checked(
|
|
&db,
|
|
&user_db,
|
|
&authed,
|
|
&w_id,
|
|
&format!("datatable://{}", dt_name),
|
|
)
|
|
.await
|
|
{
|
|
Ok(pg) => pg,
|
|
Err(e) => {
|
|
errors.push(format!(
|
|
"Could not resolve fork resource for datatable://{}: {}",
|
|
dt_name, e
|
|
));
|
|
continue;
|
|
}
|
|
};
|
|
// We cannot drop the current database, so we connect to the parent's version to run DROP DATABASE on
|
|
// the forked version
|
|
let parent_pg = match crate::workspaces::resolve_pg_source_checked(
|
|
&db,
|
|
&user_db,
|
|
&authed,
|
|
&parent_w_id,
|
|
&format!("datatable://{}", dt_name),
|
|
)
|
|
.await
|
|
{
|
|
Ok(pg) => pg,
|
|
Err(e) => {
|
|
errors.push(format!(
|
|
"Could not resolve parent resource for datatable://{}: {}",
|
|
dt_name, e
|
|
));
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let db_to_drop = &fork_pg.dbname;
|
|
if let Err(e) = windmill_common::validate_dbname(db_to_drop) {
|
|
errors.push(format!(
|
|
"Invalid database name '{}' for datatable://{}: {}",
|
|
db_to_drop, dt_name, e
|
|
));
|
|
continue;
|
|
}
|
|
if !db_to_drop.starts_with("wm_fork_") {
|
|
errors.push(format!(
|
|
"Refusing to drop resource database '{}' for datatable://{}: name does not start with 'wm_fork_'",
|
|
db_to_drop, dt_name
|
|
));
|
|
continue;
|
|
}
|
|
|
|
match parent_pg.connect(Some(&db)).await {
|
|
Ok((client, connection)) => {
|
|
let join_handle = tokio::spawn(async move { connection.await });
|
|
if let Err(e) = client
|
|
.execute(&format!("DROP DATABASE \"{}\"", db_to_drop), &[])
|
|
.await
|
|
{
|
|
errors.push(format!(
|
|
"Could not drop database '{}' for datatable://{}: {}",
|
|
db_to_drop, dt_name, e
|
|
));
|
|
}
|
|
drop(client);
|
|
let _ = join_handle.await;
|
|
}
|
|
Err(e) => {
|
|
errors.push(format!(
|
|
"Could not connect to drop database for datatable://{}: {}",
|
|
dt_name, e
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(Json(errors))
|
|
}
|
|
|
|
/// Drop this fork workspace's ducklake namespaces: the `wm_fork_*` metadata schema in each
|
|
/// lake's catalog database, plus (best effort) the fork's `__wm_forks/<wid>/…` data files in
|
|
/// the workspace storage. Driven by the `fork_ducklake_namespace` registry written at first
|
|
/// fork attach, so it works even after settings drift. Returns errors per lake that failed;
|
|
/// the registry row is only deleted once both cleanups succeeded, so a retry resumes.
|
|
/// Same permission as delete_workspace: fork owner or super admin. `delete_workspace` also
|
|
/// runs this inline (the UI calls this endpoint first for error visibility; the inline run
|
|
/// covers every other delete path — CLI, force-delete dialogs, direct API — whose row delete
|
|
/// would otherwise CASCADE the registry away while orphaning the physical namespaces, which a
|
|
/// recreated identical fork id would then silently reattach).
|
|
pub async fn drop_forked_ducklake_namespaces(
|
|
authed: ApiAuthed,
|
|
Extension(db): Extension<DB>,
|
|
Path(w_id): Path<String>,
|
|
) -> Result<Json<Vec<String>>> {
|
|
let is_fork = workspace_is_fork(&db, &w_id).await?;
|
|
let mut tx = db.begin().await?;
|
|
if !(is_fork && is_workspace_owner(&authed, &w_id, &mut tx).await?)
|
|
&& !is_super_admin_email(&db, &authed.email).await?
|
|
{
|
|
return Err(Error::PermissionDenied(
|
|
"Dropping forked ducklake namespaces requires being the fork's owner or a superadmin"
|
|
.to_string(),
|
|
));
|
|
}
|
|
tx.commit().await?;
|
|
require_prod_admin_for_dev_workspace(&db, &authed, &w_id).await?;
|
|
Ok(Json(
|
|
drop_forked_ducklake_namespaces_impl(&db, &w_id, None)
|
|
.await?
|
|
.into_iter()
|
|
.map(|i| i.msg)
|
|
.collect(),
|
|
))
|
|
}
|
|
|
|
/// The cleanup itself, shared by the endpoint and the post-commit `delete_workspace` run. No
|
|
/// authorization of its own — callers gate.
|
|
pub(crate) async fn drop_forked_ducklake_namespaces_impl(
|
|
db: &DB,
|
|
w_id: &str,
|
|
res_fallback_w_id: Option<&str>,
|
|
) -> Result<Vec<ForkDucklakeCleanupIssue>> {
|
|
let prepared = prepare_fork_ducklake_cleanups(db, w_id, res_fallback_w_id).await?;
|
|
Ok(cleanup_fork_ducklake_namespaces(db, w_id, prepared).await)
|
|
}
|
|
|
|
struct ForkDucklakeNamespaceRow {
|
|
ducklake_name: String,
|
|
metadata_schema: String,
|
|
catalog: String,
|
|
storage: String,
|
|
storage_ref: String,
|
|
schema_dropped: bool,
|
|
data_path: String,
|
|
}
|
|
|
|
/// A namespace row plus its RESOLVED connection material. `delete_workspace` prepares these
|
|
/// BEFORE its transaction commits — the registry rows AND the fork's `$res:` resources both
|
|
/// disappear with the workspace row — and runs the destructive cleanup only AFTER the commit:
|
|
/// a delete that fails mid-way must never leave a live workspace with its fork data
|
|
/// destroyed, and a committed delete must still be able to reach catalogs/storages whose
|
|
/// credentials lived in the (now gone) fork resources. Per-row resolution failures are
|
|
/// carried as strings so they surface with the other cleanup errors.
|
|
pub(crate) struct PreparedForkDucklakeCleanup {
|
|
ns: ForkDucklakeNamespaceRow,
|
|
catalog_pg: std::result::Result<windmill_common::PgDatabase, String>,
|
|
store: std::result::Result<(windmill_types::s3::LargeFileStorage, serde_json::Value), String>,
|
|
}
|
|
|
|
/// One row per (lake, catalog, storage, data path) ever attached — settings drift adds rows,
|
|
/// so every location the fork wrote gets cleaned, not just the first. Read-only: resolves
|
|
/// credentials but destroys nothing.
|
|
/// `res_fallback_w_id`: second workspace to resolve `$res:` catalog/storage paths against
|
|
/// when the row's own workspace no longer has them — the retry path runs AFTER the fork and
|
|
/// its resources were deleted. Fork resources are clones of a parent's, so the workspace
|
|
/// being forked again is the natural donor. None on live-workspace paths.
|
|
pub(crate) async fn prepare_fork_ducklake_cleanups(
|
|
db: &DB,
|
|
w_id: &str,
|
|
res_fallback_w_id: Option<&str>,
|
|
) -> Result<Vec<PreparedForkDucklakeCleanup>> {
|
|
let rows = sqlx::query_as!(
|
|
ForkDucklakeNamespaceRow,
|
|
r#"SELECT ducklake_name AS "ducklake_name!", metadata_schema AS "metadata_schema!",
|
|
catalog AS "catalog!", storage AS "storage!",
|
|
storage_ref AS "storage_ref!", data_path AS "data_path!",
|
|
schema_dropped AS "schema_dropped!"
|
|
FROM fork_ducklake_namespace WHERE workspace_id = $1"#,
|
|
w_id
|
|
)
|
|
.fetch_all(db)
|
|
.await?;
|
|
let mut prepared = Vec::with_capacity(rows.len());
|
|
for ns in rows {
|
|
let catalog_pg = if ns.schema_dropped {
|
|
// Schema phase already done — the retry needs no catalog connection at all
|
|
// (its credentials may be unresolvable for good with the fork's resources gone).
|
|
Err("unused: metadata schema already dropped".to_string())
|
|
} else {
|
|
resolve_fork_catalog_pg(db, w_id, res_fallback_w_id, &ns.ducklake_name, &ns.catalog)
|
|
.await
|
|
.map_err(|e| e.to_string())
|
|
};
|
|
let storage = Some(ns.storage.as_str()).filter(|s| !s.is_empty());
|
|
let store = resolve_fork_storage(db, w_id, res_fallback_w_id, storage, &ns.storage_ref)
|
|
.await
|
|
.map_err(|e| e.to_string());
|
|
prepared.push(PreparedForkDucklakeCleanup { ns, catalog_pg, store });
|
|
}
|
|
Ok(prepared)
|
|
}
|
|
|
|
/// Drop the prepared namespaces' metadata schemas + data files, deleting each registry row
|
|
/// only after both succeed (a no-op when the row already cascaded away with the workspace).
|
|
/// Returns per-namespace error strings; never fails as a whole.
|
|
/// One failed step of a fork ducklake cleanup. `blocking` = the metadata schema (or its
|
|
/// guards) failed, so the namespace is still attachable and a same-id fork must NOT be
|
|
/// created. Non-blocking = the schema is gone and only data files (or the registry-row
|
|
/// delete) failed — inert storage leftovers, tracked by the surviving row and swept by the
|
|
/// next successful cleanup of the same prefix.
|
|
pub(crate) struct ForkDucklakeCleanupIssue {
|
|
pub(crate) blocking: bool,
|
|
pub(crate) msg: String,
|
|
}
|
|
|
|
pub(crate) async fn cleanup_fork_ducklake_namespaces(
|
|
db: &DB,
|
|
w_id: &str,
|
|
prepared: Vec<PreparedForkDucklakeCleanup>,
|
|
) -> Vec<ForkDucklakeCleanupIssue> {
|
|
// The registration once-cache must not outlive the rows it mirrors: a same-id fork
|
|
// recreated within the TTL would otherwise skip re-registration, and ITS eventual
|
|
// deletion would find no rows — orphaning the deterministic namespace for the next
|
|
// same-id fork to silently reattach.
|
|
windmill_common::workspaces::invalidate_fork_ducklake_registration_cache(w_id);
|
|
let mut errors: Vec<ForkDucklakeCleanupIssue> = Vec::new();
|
|
for PreparedForkDucklakeCleanup { ns, catalog_pg, store } in prepared {
|
|
// Hard guards mirroring the forked-datatable drop: never touch a schema outside the
|
|
// fork prefix, never delete outside the fork data dir — even if a registry row was
|
|
// somehow tampered with.
|
|
if !ns
|
|
.metadata_schema
|
|
.starts_with(windmill_common::workspaces::FORK_DUCKLAKE_SCHEMA_PREFIX)
|
|
{
|
|
errors.push(ForkDucklakeCleanupIssue {
|
|
blocking: true,
|
|
msg: format!(
|
|
"Refusing to drop schema '{}' for ducklake://{}: name does not start with '{}'",
|
|
ns.metadata_schema,
|
|
ns.ducklake_name,
|
|
windmill_common::workspaces::FORK_DUCKLAKE_SCHEMA_PREFIX
|
|
),
|
|
});
|
|
continue;
|
|
}
|
|
// The fork's directory segment, NOT the raw workspace id: ids are only
|
|
// git-branch-safe and may contain `/`, which raw would let one fork's prefix nest
|
|
// inside a sibling's (`wm-fork-a/b` under `wm-fork-a`) and be swept by its cleanup.
|
|
let expected_prefix = format!(
|
|
"{}/{}/",
|
|
windmill_common::workspaces::FORK_DUCKLAKE_DATA_DIR,
|
|
windmill_common::workspaces::fork_data_dir_segment(w_id)
|
|
);
|
|
if !format!("{}/", ns.data_path.trim_end_matches('/')).starts_with(&expected_prefix) {
|
|
errors.push(ForkDucklakeCleanupIssue {
|
|
blocking: true,
|
|
msg: format!(
|
|
"Refusing to delete data path '{}' for ducklake://{}: not under '{}'",
|
|
ns.data_path, ns.ducklake_name, expected_prefix
|
|
),
|
|
});
|
|
continue;
|
|
}
|
|
|
|
let drop_res = if ns.schema_dropped {
|
|
// Recorded as already dropped by a prior partial cleanup; skipping means no
|
|
// catalog credentials are needed. Registration resets the flag when a live fork
|
|
// re-attaches (recreating the schema).
|
|
Ok(())
|
|
} else {
|
|
match catalog_pg {
|
|
Ok(pg) => drop_fork_ducklake_metadata_schema(db, pg, &ns.metadata_schema).await,
|
|
Err(e) => Err(Error::internal_err(e)),
|
|
}
|
|
};
|
|
if let Err(e) = drop_res {
|
|
errors.push(ForkDucklakeCleanupIssue {
|
|
blocking: true,
|
|
msg: format!(
|
|
"Could not drop metadata schema '{}' for ducklake://{}: {e}",
|
|
ns.metadata_schema, ns.ducklake_name
|
|
),
|
|
});
|
|
continue;
|
|
}
|
|
let delete_res = match store {
|
|
Ok((lfs, resource_value)) => {
|
|
delete_fork_ducklake_data(lfs, resource_value, &ns.data_path).await
|
|
}
|
|
Err(e) => Err(Error::internal_err(e)),
|
|
};
|
|
if let Err(e) = delete_res {
|
|
// Record the completed schema phase so retries never need catalog credentials
|
|
// again (best effort — a failed update just means the next retry re-drops an
|
|
// absent schema, which requires the catalog to be reachable).
|
|
sqlx::query!(
|
|
"UPDATE fork_ducklake_namespace SET schema_dropped = true
|
|
WHERE workspace_id = $1 AND ducklake_name = $2 AND catalog = $3
|
|
AND storage = $4 AND storage_ref = $5 AND data_path = $6",
|
|
w_id,
|
|
&ns.ducklake_name,
|
|
&ns.catalog,
|
|
&ns.storage,
|
|
&ns.storage_ref,
|
|
&ns.data_path,
|
|
)
|
|
.execute(db)
|
|
.await
|
|
.ok();
|
|
errors.push(ForkDucklakeCleanupIssue {
|
|
blocking: false,
|
|
msg: format!(
|
|
"Dropped metadata schema but could not delete data files under '{}' for ducklake://{}: {e}",
|
|
ns.data_path, ns.ducklake_name
|
|
),
|
|
});
|
|
continue;
|
|
}
|
|
sqlx::query!(
|
|
"DELETE FROM fork_ducklake_namespace
|
|
WHERE workspace_id = $1 AND ducklake_name = $2 AND catalog = $3
|
|
AND storage = $4 AND storage_ref = $5 AND data_path = $6",
|
|
w_id,
|
|
&ns.ducklake_name,
|
|
&ns.catalog,
|
|
&ns.storage,
|
|
&ns.storage_ref,
|
|
&ns.data_path,
|
|
)
|
|
.execute(db)
|
|
.await
|
|
.map_err(|e| {
|
|
errors.push(ForkDucklakeCleanupIssue {
|
|
blocking: false,
|
|
msg: format!(
|
|
"Cleaned ducklake://{} but could not delete its registry row: {e}",
|
|
ns.ducklake_name
|
|
),
|
|
})
|
|
})
|
|
.ok();
|
|
}
|
|
errors
|
|
}
|
|
|
|
/// Drop the fork's metadata schema in the catalog database recorded by the registry row —
|
|
/// NOT whatever the fork's settings point at by now: a drifted catalog resource must not make
|
|
/// cleanup drop a schema in the wrong database while orphaning the real one. The pg schema
|
|
/// holds only DuckLake metadata tables (auto-created at first fork attach), so a plain
|
|
/// `DROP SCHEMA … CASCADE` on the catalog connection removes the whole fork namespace.
|
|
/// Resolve the catalog connection recorded in the registry row (read-only): instance
|
|
/// identities rebuild instance creds; resource identities resolve their `$res:` in the fork's
|
|
/// workspace — which is why this must run BEFORE the workspace (and its resources) are
|
|
/// deleted. Mysql never registers (rejected at resolution).
|
|
async fn resolve_fork_catalog_pg(
|
|
db: &DB,
|
|
w_id: &str,
|
|
res_fallback_w_id: Option<&str>,
|
|
ducklake_name: &str,
|
|
catalog: &str,
|
|
) -> Result<windmill_common::PgDatabase> {
|
|
let (resource_type, resource_path) = catalog.split_once(':').ok_or_else(|| {
|
|
Error::internal_err(format!(
|
|
"ducklake://{ducklake_name}: malformed registry catalog identity `{catalog}`"
|
|
))
|
|
})?;
|
|
let catalog_resource = if resource_type == "instance" {
|
|
let mut pg_creds = windmill_common::PgDatabase::parse_uri(
|
|
&windmill_common::get_database_url().await?.as_str().await,
|
|
)?;
|
|
pg_creds.dbname = resource_path.to_string();
|
|
pg_creds.user = Some("custom_instance_user".to_string());
|
|
pg_creds.password =
|
|
Some(windmill_common::utils::get_custom_pg_instance_password(db).await?);
|
|
serde_json::to_value(&pg_creds)
|
|
.map_err(|e| Error::internal_err(format!("serializing pg creds: {e}")))?
|
|
} else {
|
|
resolve_res_with_fallback(db, w_id, res_fallback_w_id, resource_path).await?
|
|
};
|
|
serde_json::from_value(catalog_resource).map_err(|e| {
|
|
Error::internal_err(format!(
|
|
"ducklake://{ducklake_name}: catalog resource is not a postgres database: {e}"
|
|
))
|
|
})
|
|
}
|
|
|
|
async fn drop_fork_ducklake_metadata_schema(
|
|
db: &DB,
|
|
pg: windmill_common::PgDatabase,
|
|
metadata_schema: &str,
|
|
) -> Result<()> {
|
|
let (client, connection) = pg.connect(Some(db)).await?;
|
|
let join_handle = tokio::spawn(async move { connection.await });
|
|
let res = client
|
|
.execute(
|
|
&format!(
|
|
"DROP SCHEMA IF EXISTS \"{}\" CASCADE",
|
|
metadata_schema.replace('"', "\"\"")
|
|
),
|
|
&[],
|
|
)
|
|
.await;
|
|
drop(client);
|
|
let _ = join_handle.await;
|
|
res.map_err(|e| Error::internal_err(format!("{e:#}")))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Resolve the storage identified by the registry's `storage_ref` (read-only) — the storage
|
|
/// that was active when the fork's data was WRITTEN, not whatever the logical storage name
|
|
/// points at by deletion time (a repointed storage must not orphan the original fork data,
|
|
/// nor get a colliding prefix deleted). `storage_ref` = '' falls back to resolving the
|
|
/// logical name against current settings (registration couldn't identify the storage — best
|
|
/// effort). Resolves the `$res:` in the fork's workspace, which is why this must run BEFORE
|
|
/// the workspace is deleted.
|
|
async fn resolve_fork_storage(
|
|
db: &DB,
|
|
w_id: &str,
|
|
res_fallback_w_id: Option<&str>,
|
|
storage: Option<&str>,
|
|
storage_ref: &str,
|
|
) -> Result<(windmill_types::s3::LargeFileStorage, serde_json::Value)> {
|
|
use windmill_types::s3::{
|
|
AzureBlobStorage, FilesystemStorage, GoogleCloudStorage, LargeFileStorage, S3Storage,
|
|
};
|
|
|
|
let lfs: LargeFileStorage = if let Some((typ, path)) = storage_ref.split_once(':') {
|
|
// Rebuild the LFS entry from the registered identity; only the variant (which
|
|
// resource parser applies) and the path matter to `lfs_to_object_store_resource`.
|
|
let s3 = |p: &str| S3Storage {
|
|
s3_resource_path: p.to_string(),
|
|
public_resource: None,
|
|
advanced_permissions: None,
|
|
};
|
|
match typ {
|
|
"S3Storage" => LargeFileStorage::S3Storage(s3(path)),
|
|
"S3AwsOidc" => LargeFileStorage::S3AwsOidc(s3(path)),
|
|
"AzureBlobStorage" => LargeFileStorage::AzureBlobStorage(AzureBlobStorage {
|
|
azure_blob_resource_path: path.to_string(),
|
|
public_resource: None,
|
|
advanced_permissions: None,
|
|
}),
|
|
"AzureWorkloadIdentity" => LargeFileStorage::AzureWorkloadIdentity(AzureBlobStorage {
|
|
azure_blob_resource_path: path.to_string(),
|
|
public_resource: None,
|
|
advanced_permissions: None,
|
|
}),
|
|
"GoogleCloudStorage" => LargeFileStorage::GoogleCloudStorage(GoogleCloudStorage {
|
|
gcs_resource_path: path.to_string(),
|
|
public_resource: None,
|
|
advanced_permissions: None,
|
|
}),
|
|
"FilesystemStorage" => LargeFileStorage::FilesystemStorage(FilesystemStorage {
|
|
root_path: path.to_string(),
|
|
public_resource: None,
|
|
advanced_permissions: None,
|
|
}),
|
|
other => {
|
|
return Err(Error::internal_err(format!(
|
|
"unknown registered storage type `{other}`"
|
|
)))
|
|
}
|
|
}
|
|
} else {
|
|
let lfs_json = sqlx::query_scalar!(
|
|
"SELECT large_file_storage FROM workspace_settings WHERE workspace_id = $1",
|
|
w_id
|
|
)
|
|
.fetch_optional(db)
|
|
.await?
|
|
.flatten()
|
|
.ok_or_else(|| Error::BadRequest("workspace has no storage configured".to_string()))?;
|
|
// Named storages live under `secondary_storage`; `None`/`_default_` is the primary.
|
|
match storage.filter(|s| *s != "_default_") {
|
|
None => serde_json::from_value(lfs_json.clone())
|
|
.map_err(|e| Error::internal_err(format!("parsing large_file_storage: {e}")))?,
|
|
Some(name) => serde_json::from_value(
|
|
lfs_json
|
|
.get("secondary_storage")
|
|
.and_then(|s| s.get(name))
|
|
.cloned()
|
|
.ok_or_else(|| {
|
|
Error::BadRequest(format!("workspace has no storage named {name}"))
|
|
})?,
|
|
)
|
|
.map_err(|e| Error::internal_err(format!("parsing storage {name}: {e}")))?,
|
|
}
|
|
};
|
|
// Filesystem storage stores a direct path (`lfs_to_object_store_resource` ignores the
|
|
// resource value); everything else references a resource whose stored path may or may not
|
|
// carry the `$res:` prefix — same normalization as `get_workspace_s3_resource_from_lfs`.
|
|
let resource_value = if matches!(lfs, LargeFileStorage::FilesystemStorage(_)) {
|
|
serde_json::Value::Null
|
|
} else {
|
|
let path = lfs.get_s3_resource_path();
|
|
let path = path.strip_prefix("$res:").unwrap_or(path);
|
|
resolve_res_with_fallback(db, w_id, res_fallback_w_id, path).await?
|
|
};
|
|
Ok((lfs, resource_value))
|
|
}
|
|
|
|
/// Resolve a `$res:` path in `w_id`, falling back to the same path in `res_fallback_w_id`
|
|
/// when the first lookup fails — retry-path cleanups run after the fork workspace (and its
|
|
/// cloned resource rows) were deleted, and the fork's resources were clones of a parent's.
|
|
async fn resolve_res_with_fallback(
|
|
db: &DB,
|
|
w_id: &str,
|
|
res_fallback_w_id: Option<&str>,
|
|
resource_path: &str,
|
|
) -> Result<serde_json::Value> {
|
|
let res = windmill_common::workspaces::transform_json_value_unchecked(
|
|
&serde_json::Value::String(format!("$res:{resource_path}")),
|
|
w_id,
|
|
db,
|
|
)
|
|
.await;
|
|
match (res, res_fallback_w_id) {
|
|
(Ok(v), _) => Ok(v),
|
|
(Err(e), None) => Err(e),
|
|
(Err(_), Some(fb)) => {
|
|
windmill_common::workspaces::transform_json_value_unchecked(
|
|
&serde_json::Value::String(format!("$res:{resource_path}")),
|
|
fb,
|
|
db,
|
|
)
|
|
.await
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Delete every object under the fork's data prefix in the pre-resolved storage. Requires the
|
|
/// `parquet` (object store) feature; without it the metadata schema is still dropped and the
|
|
/// unreachable data files are left for manual cleanup.
|
|
#[cfg(feature = "parquet")]
|
|
async fn delete_fork_ducklake_data(
|
|
lfs: windmill_types::s3::LargeFileStorage,
|
|
resource_value: serde_json::Value,
|
|
data_path: &str,
|
|
) -> Result<()> {
|
|
use futures::{StreamExt, TryStreamExt};
|
|
|
|
let store = windmill_object_store::build_object_store_client(
|
|
&windmill_object_store::lfs_to_object_store_resource(&lfs, resource_value)?,
|
|
)
|
|
.await?;
|
|
|
|
let prefix = windmill_object_store::object_store_reexports::Path::from(
|
|
data_path.trim_matches('/').to_string(),
|
|
);
|
|
let locations: Vec<_> = store
|
|
.list(Some(&prefix))
|
|
.map_ok(|m| m.location)
|
|
.try_collect()
|
|
.await
|
|
.map_err(windmill_object_store::object_store_error_to_error)?;
|
|
// The object_store crate evaluates list prefixes on a path-SEGMENT basis (`a/b` does not
|
|
// match `a/bc/…`), so sibling fork segments sharing a string prefix are already excluded.
|
|
// Filter anyway — deletion must not depend on a listing implementation detail.
|
|
let boundary = format!("{}/", prefix.as_ref());
|
|
let locations: Vec<_> = locations
|
|
.into_iter()
|
|
.filter(|l| l.as_ref().starts_with(&boundary))
|
|
.collect();
|
|
// 1000-object chunks: S3 DeleteObjects caps a batch at 1000 keys.
|
|
for chunk in locations.chunks(1000) {
|
|
store
|
|
.delete_stream(futures::stream::iter(chunk.iter().cloned().map(Ok)).boxed())
|
|
.try_collect::<Vec<_>>()
|
|
.await
|
|
.map_err(windmill_object_store::object_store_error_to_error)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(not(feature = "parquet"))]
|
|
async fn delete_fork_ducklake_data(
|
|
_lfs: windmill_types::s3::LargeFileStorage,
|
|
_resource_value: serde_json::Value,
|
|
_data_path: &str,
|
|
) -> Result<()> {
|
|
Err(Error::internal_err(
|
|
"object storage support (parquet feature) is not compiled in".to_string(),
|
|
))
|
|
}
|
|
|
|
/// 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`
|
|
/// so the two gates cannot drift: the sidebar calls the drop endpoint BEFORE deleteWorkspace,
|
|
/// and a weaker gate on the drop would let a non-prod-admin dev owner destroy the live dev's
|
|
/// materializations and then have the deletion itself rejected. No-op for non-dev workspaces.
|
|
async fn require_prod_admin_for_dev_workspace(
|
|
db: &DB,
|
|
authed: &ApiAuthed,
|
|
w_id: &str,
|
|
) -> Result<()> {
|
|
if let Some(prod) = sqlx::query_scalar!(
|
|
"SELECT parent_workspace_id FROM workspace WHERE id = $1 AND is_dev_workspace",
|
|
w_id
|
|
)
|
|
.fetch_optional(db)
|
|
.await?
|
|
.flatten()
|
|
{
|
|
let is_prod_admin = sqlx::query_scalar!(
|
|
"SELECT is_admin FROM usr WHERE workspace_id = $1 AND email = $2",
|
|
&prod,
|
|
&authed.email
|
|
)
|
|
.fetch_optional(db)
|
|
.await?
|
|
.unwrap_or(false);
|
|
if !is_prod_admin && !is_super_admin_email(db, &authed.email).await? {
|
|
return Err(Error::PermissionDenied(format!(
|
|
"Destroying dev workspace '{w_id}' or its data requires being an admin of its parent prod workspace '{prod}' (or a superadmin)"
|
|
)));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn is_workspace_owner(
|
|
authed: &ApiAuthed,
|
|
w_id: &str,
|
|
tx: &mut Transaction<'_, Postgres>,
|
|
) -> Result<bool> {
|
|
let owner = sqlx::query_scalar!("SELECT owner FROM workspace WHERE id = $1", w_id)
|
|
.fetch_optional(&mut **tx)
|
|
.await?;
|
|
Ok(owner.map(|o| o == authed.email).unwrap_or(false))
|
|
}
|
|
|
|
/// Whether a workspace is a fork or dev workspace. Both forks and dev workspaces set
|
|
/// `parent_workspace_id`, but a `wm-fork-` workspace can outlive its parent (the FK is
|
|
/// `ON DELETE SET NULL`), so also treat the prefix as fork-ness — otherwise an orphaned fork would
|
|
/// lose owner-self-delete. Used to gate owner-self-delete, which is permitted for forks/dev
|
|
/// workspaces but requires superadmin otherwise.
|
|
async fn workspace_is_fork(db: &DB, w_id: &str) -> Result<bool> {
|
|
if w_id.starts_with(WM_FORK_PREFIX) {
|
|
return Ok(true);
|
|
}
|
|
Ok(sqlx::query_scalar!(
|
|
r#"SELECT (parent_workspace_id IS NOT NULL) AS "has_parent!" FROM workspace WHERE id = $1"#,
|
|
w_id
|
|
)
|
|
.fetch_optional(db)
|
|
.await?
|
|
.unwrap_or(false))
|
|
}
|