Files
windmill/backend/migrations/20260730080304_unify_deploy_to_into_parent.down.sql
81b23a2ba0 feat: make the fork lineage the only deploy relationship (#10410)
* feat: make the fork lineage the only deploy relationship

`workspace_settings.deploy_to` (2023) and `workspace.parent_workspace_id` (2025)
both expressed "which workspace does this one deploy into". Fork creation and
dev-workspace attach seeded both, but nothing kept them in agreement, so every
reader picked one and they disagreed.

Drop `deploy_to`. A migration folds surviving pairs into the lineage: a sole
claimant on a target with no dev workspace becomes that target's dev workspace
and keeps its own job tags, while many-to-one pairs become plain forks. Pairs
that the lineage cannot express -- dangling target, self-reference, chain,
mutual -- are reported and left unlinked.

Job tags were never lineage-aware: `per_workspace_tag` mapped any parented
workspace to its parent while `$workspace` interpolated the raw id, so a fork
running a script tagged `<tag>-$workspace` produced a tag no worker serves and
the job queued forever. Both paths now resolve to the nearest ancestor whose id
an admin would provision workers for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: preserve unconvertible deploy links and sweep tag caches on reparent

Review findings on the deploy_to unification:

- convert chains instead of discarding them, and keep whatever the lineage
  cannot express in workspace_deploy_to_unmigrated so the down migration can
  restore it
- ignore soft-deleted workspaces when choosing between a dev workspace and a
  plain fork; an archived claimant was demoting live pairs
- mirror attach_dev_workspace's git-sync strip, which the migration skipped
- sweep the tag cache over whole subtrees on rename and delete: tag resolution
  now walks ancestors, so a nested fork kept a tag nothing serves
- call a dev workspace a dev workspace in the settings copy
- redirect a root away from ?tab=deploy_to instead of rendering an empty target

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: detect lineage cycles and record archived links in the deploy_to migration

Second review round on the unification:

- detect cycles over the lineage as it would exist after conversion, not over
  the deploy_to graph alone: a root whose target was one of its own forks
  closed a loop that no deploy_to edge revealed
- record an archived source's link instead of filtering it out entirely, which
  dropped it with the column
- treat a fork whose deploy_to merely repeats its parent as redundant rather
  than reporting every pre-existing fork as unmigrated
- read the row count from the lineage update rather than the git-sync one
- sweep the tag cache when archiving a dev workspace, the last site that
  mutates is_dev_workspace without one

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: resolve $workspace on preprocessed flow tags regardless of $args

Third review round on the unification:

- a flow tag containing only `$workspace` skipped interpolation entirely on the
  preprocessed path, because the branch that ran it keys on `$args`. The raw
  tag was written back and named a queue no worker serves. Resolve `$workspace`
  before the branch and leave `$args` to it.
- record the new table's foreign key in the schema summary
- describe what the archive tag sweep actually does: the dev flag is cleared for
  any archived workspace, which is why it is unconditional

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep the deploy_to leftovers table only when it holds something

* fix: sweep tag caches on archive only where the dev flag actually changes

* feat: broadcast lineage changes and walk ws_specific ancestors only

- propagate tag-cache invalidation across processes over notify_events: the
  cache is per-process, so replicas kept resolving stale lineage for the TTL.
  The listener clears the whole cache rather than tracking ids, since a single
  mutation invalidates an unbounded set of descendants and lineage changes are
  rare admin actions.
- narrow list_ws_specific_versions to ancestors: walking down as well made a
  root fan out over its entire live fork subtree, and each member costs an
  identity lookup plus an RLS switch and probe. Ancestors are bounded by the
  fork depth limit.
- probe the leftovers table unqualified so rollback restores on a PG_SCHEMA
  install, where search_path is not public
- drop the nativets client method for the removed edit_deploy_to endpoint

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: let a prod see its dev workspace in ws_specific, and stop the walk oscillating

Descending into plain forks made a root fan out over its whole live fork
subtree, but a dev workspace is the paired editable environment rather than a
throwaway copy, so a prod should still see it. There is at most one per parent
and attach rejects nested dev chains, so that edge stays bounded.

The edges run both ways, so the recursion never converged: it bounced
parent<->dev until the depth cap on every call, 33 rows for a two-member set.
A visited-path guard ends the walk when nothing new is reachable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep dev pairings unnested, gate the delete broadcast, cover the ws_specific walk

Fifth review round:

- a root that already owns a dev workspace no longer converts: linking it under
  its deploy target would leave that dev nested beneath a fork, the shape
  attach_dev_workspace refuses to create. The link is preserved instead.
- broadcast a lineage change on delete only when descendants are orphaned.
  Deleting a leaf, which ephemeral fork churn does constantly, changes nobody
  else's resolution and was making every replica drop its whole tag cache.
- call list_ws_specific_versions in a test. plpgsql defers everything past a raw
  parse to the first call, so replaying the migration only proved it parses.
- use unwrap_or_default for the descendant sweeps, which run after the
  transaction has committed; a transient failure must not fail the request
- trim the traversal comment to the four-line limit

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: cache the renamed tally query and clear instance alerts on conversion

The integration test's query was never cached: `cargo sqlx prepare` without
--all-targets skips test targets entirely, and renaming its fixture workspace
changed the query text. Regenerated with --all-targets --features
all_sqlx_features,private, which is what lets the EE-gated otel test compile.

Also from review:
- clear error_handler_fallback_to_instance_alerts on converted workspaces.
  Dispatch ignores it once a parent exists, but the settings page keeps
  submitting the stored true, which the API rejects on a fork.
- restore the schema summary row to the file's name: columns format and put it
  back in alphabetical order

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: never cache an unresolvable tag workspace, and unadvertise the removed endpoint

- lookup_tag_workspace cached a "no row" result as self-resolution. A rename
  resolves the new id before its row lands, so a fork could be pinned to its own
  wm-fork-* id -- which nothing serves -- for the whole TTL, and its schedules
  kept re-pushing onto that dead tag. Fall back for the call without caching,
  matching how the error path already behaved.
- change_workspace_id swept its children but never itself. Sweep the new and old
  ids and broadcast unconditionally, since a rename always changes lineage.
- openapi-deref.{json,yaml} are served to clients via include_str!, so they were
  advertising edit_deploy_to after it started 404ing. The audit-action enum
  keeps the entry: historical rows still carry it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: align the served YAML spec with the JSON one and correct two comments

- the YAML deref lost the removed path but kept deploy_to on get_settings,
  so the two served specs disagreed. Both are now identical.
- the rename-sweep comment blamed cached-unresolvable lookups, which the same
  commit stopped caching. The real reason is that workspace ids are
  reclaimable, so a new id can carry a previous occupant's resolution.
- the instance-alert comment claimed the settings page submits the stored true
  and gets a 400. It hides the option on a fork and sends false; the hazard is
  the value outliving the pairing and re-enabling alerts after a detach.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to 82da6cb2bafeda18acd6b70c599013a12117ecb0

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

Previous ee-repo-ref: f9ddf6a75aa13d1c13a3d7216a361a96f75ca435

New ee-repo-ref: 82da6cb2bafeda18acd6b70c599013a12117ecb0

Automated by sync-ee-ref workflow.

* fix: grant the deploy_to preservation table to the windmill roles

* test: drop the one-shot migration tests, keep the ws_specific execution guard

The two conversion tests replayed the migration against the fully-migrated
schema, which is not how it runs -- in production it runs mid-sequence against
the schema as of that point. A later migration touching workspace or
workspace_settings would break them without breaking anything real, and sqlx
checksums already freeze a released migration. They earned their keep finding
the archived-claimant and nested-dev cases during development; there is nothing
left for them to guard.

list_ws_specific_versions is different: it is live, no caller exercises it, and
plpgsql only parses a function body until first call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: invalidate a reclaimed fork id cluster-wide without flushing every entry

Gating the delete broadcast on orphaned descendants stopped leaf churn flushing
every replica, but fork ids are reclaimable: the deleting process invalidated
locally while every other replica kept the old parent for the TTL, so a job
pushed in a recreated fork routed to the previous parent's tag.

The broadcast payload now carries meaning. A workspace id drops that one entry,
used for leaf deletion where exactly one id changed what it denotes. The `*`
sentinel drops everything, used for attach, detach, archive, rename and
deletions that orphan descendants -- reshaping a subtree no single id names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: name the right broadcast for each invalidation case

* docs: attach does invalidate the tag cache; the resolver walks the whole chain

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-07-30 14:20:27 +00:00

176 lines
6.7 KiB
PL/PgSQL

-- Restores the column and the `deploy_to`-based traversal.
--
-- Every fork and dev workspace had `deploy_to` seeded to its parent, so re-deriving it from
-- `parent_workspace_id` restores those rows exactly, and the links the up migration could not
-- convert come back from `workspace_deploy_to_unmigrated`. What this does not undo is the up
-- migration's conversions: a legacy pair that became a fork keeps its `parent_workspace_id`, so it
-- reads as a fork afterwards rather than as the standalone workspace it once was.
ALTER TABLE workspace_settings ADD COLUMN deploy_to VARCHAR(255);
UPDATE workspace_settings ws
SET deploy_to = w.parent_workspace_id
FROM workspace w
WHERE w.id = ws.workspace_id
AND w.parent_workspace_id IS NOT NULL;
-- The up migration keeps this table only when it preserved something, so restore from it
-- conditionally. plpgsql plans a branch's statements only when it runs, so the references below
-- are safe when the table was dropped. The probe is unqualified so it resolves through
-- `search_path`, which a `PG_SCHEMA` install points at its own schema rather than `public`.
DO $$
BEGIN
IF to_regclass('workspace_deploy_to_unmigrated') IS NOT NULL THEN
UPDATE workspace_settings ws
SET deploy_to = u.deploy_to
FROM workspace_deploy_to_unmigrated u
WHERE u.workspace_id = ws.workspace_id;
DROP TABLE workspace_deploy_to_unmigrated;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS workspace_settings_deploy_to_idx
ON workspace_settings (deploy_to)
WHERE deploy_to IS NOT NULL;
CREATE OR REPLACE FUNCTION list_ws_specific_versions(
seed_workspace TEXT,
user_email TEXT,
item_kind TEXT,
item_path TEXT
) RETURNS TABLE(ws VARCHAR) AS $$
DECLARE
rel RECORD;
usr_row RECORD;
user_perms TEXT[];
groups_csv TEXT;
pgroups_csv TEXT;
folders_read_csv TEXT;
folders_write_csv TEXT;
item_exists BOOLEAN;
is_super BOOLEAN;
BEGIN
IF item_kind NOT IN ('resource', 'variable') THEN
RAISE EXCEPTION 'Invalid kind: %', item_kind;
END IF;
SELECT COALESCE(super_admin, false) INTO is_super
FROM password WHERE email = user_email;
is_super := COALESCE(is_super, false);
BEGIN
FOR rel IN
WITH RECURSIVE related_workspaces(ws_id, depth) AS (
SELECT seed_workspace::VARCHAR, 0
UNION
SELECT CASE
WHEN ws.workspace_id = r.ws_id THEN ws.deploy_to
ELSE ws.workspace_id
END, r.depth + 1
FROM workspace_settings ws, related_workspaces r
WHERE r.depth < 32
AND ((ws.workspace_id = r.ws_id AND ws.deploy_to IS NOT NULL)
OR ws.deploy_to = r.ws_id)
)
SELECT DISTINCT r.ws_id
FROM related_workspaces r
INNER JOIN workspace w ON w.id = r.ws_id AND w.deleted = false
LOOP
SELECT u.username, u.is_admin
INTO usr_row
FROM usr u
WHERE u.email = user_email
AND u.workspace_id = rel.ws_id
AND u.disabled = false;
IF NOT FOUND AND NOT is_super THEN
CONTINUE;
END IF;
IF NOT FOUND THEN
-- super admin without a usr row in this workspace: synthesize an
-- admin identity so RLS is bypassed (windmill_admin role).
usr_row.username := user_email;
usr_row.is_admin := true;
groups_csv := '';
pgroups_csv := '';
folders_read_csv := '';
folders_write_csv := '';
ELSE
SELECT
COALESCE(string_agg(g, ','), ''),
COALESCE(string_agg('g/' || g, ','), '')
INTO groups_csv, pgroups_csv
FROM (
SELECT group_ AS g FROM usr_to_group
WHERE usr_to_group.usr = usr_row.username
AND usr_to_group.workspace_id = rel.ws_id
UNION ALL
SELECT igroup FROM email_to_igroup WHERE email = user_email
) gs;
user_perms := ARRAY['u/' || usr_row.username] || ARRAY(
SELECT 'g/' || g FROM (
SELECT group_ AS g FROM usr_to_group
WHERE usr = usr_row.username AND workspace_id = rel.ws_id
UNION ALL
SELECT igroup FROM email_to_igroup WHERE email = user_email
) gs2
);
-- folders_read: every folder the user can see (write implies read);
-- folders_write: only those granting write access.
WITH user_folders AS (
SELECT name, EXISTS (
SELECT 1 FROM jsonb_each_text(extra_perms) t
WHERE t.key = ANY(user_perms) AND t.value::boolean IS true
) AS is_write
FROM folder
WHERE extra_perms ?| user_perms AND folder.workspace_id = rel.ws_id
)
SELECT
COALESCE(string_agg(name, ','), ''),
COALESCE(string_agg(name, ',') FILTER (WHERE is_write), '')
INTO folders_read_csv, folders_write_csv
FROM user_folders;
IF is_super THEN
usr_row.is_admin := true;
END IF;
END IF;
PERFORM set_session_context(
usr_row.is_admin,
usr_row.username,
groups_csv,
pgroups_csv,
folders_read_csv,
folders_write_csv
);
EXECUTE format(
'SELECT EXISTS(SELECT 1 FROM %I WHERE workspace_id = $1 AND path = $2)',
item_kind
)
INTO item_exists
USING rel.ws_id, item_path;
IF item_exists THEN
ws := rel.ws_id;
RETURN NEXT;
END IF;
END LOOP;
EXCEPTION WHEN OTHERS THEN
-- Reset to a deny-default state before re-raising so a half-set
-- session context can't leak past the failed call.
PERFORM set_session_context(false, '', '', '', '', '');
RAISE;
END;
-- Reset to a deny-default state on the happy path too. SET LOCAL is
-- transaction-scoped so this also unwinds at transaction end, but
-- being explicit defends against the function being called inside a
-- longer outer transaction.
PERFORM set_session_context(false, '', '', '', '', '');
END;
$$ LANGUAGE plpgsql;