Files
windmill/frontend/src/lib/components/DeployWorkspace.svelte
T
2026-03-27 14:31:14 +01:00

579 lines
18 KiB
Svelte

<script lang="ts">
import { createEventDispatcher, untrack } from 'svelte'
import { wsBase } from '$lib/workspaceUrl'
import { enterpriseLicense, superadmin, workspaceStore } from '$lib/stores'
import {
AppService,
FlowService,
ResourceService,
ScheduleService,
UserService,
WorkspaceService
} from '$lib/gen'
import { getAllModules } from './flows/flowExplorer'
import Button from './common/button/Button.svelte'
import Tooltip from './Tooltip.svelte'
import Alert from './common/alert/Alert.svelte'
import { DiffIcon, FileJson, Loader2 } from 'lucide-svelte'
import Badge from './common/badge/Badge.svelte'
import DiffDrawer from './DiffDrawer.svelte'
import {
getTriggerDependency,
type AdditionalInformation,
type Kind
} from '$lib/utils_deployable'
import {
checkItemExists,
deployItem,
getItemValue,
getOnBehalfOf
} from '$lib/utils_workspace_deploy'
import type { App } from './apps/types'
import { getAllGridItems } from './apps/editor/appUtils'
import { isRunnableByPath } from './apps/inputType'
import type { Runnable } from './raw_apps/utils'
import WorkspaceDeployLayout from './WorkspaceDeployLayout.svelte'
import OnBehalfOfSelector, {
needsOnBehalfOfSelection,
type OnBehalfOfChoice,
type OnBehalfOfDetails
} from './OnBehalfOfSelector.svelte'
import ParentWorkspaceProtectionAlert from './ParentWorkspaceProtectionAlert.svelte'
const dispatch = createEventDispatcher()
interface Props {
kind: Kind
initialPath?: string
additionalInformation?: AdditionalInformation | undefined
workspaceToDeployTo?: string | undefined
hideButton?: boolean
canDeployToWorkspace?: boolean
}
let {
kind,
initialPath = '',
additionalInformation = undefined,
workspaceToDeployTo = $bindable(undefined),
hideButton = false,
canDeployToWorkspace = $bindable(true)
}: Props = $props()
let canSeeTarget: 'yes' | 'cant-deploy-to-workspace' | 'cant-see-all-deps' | undefined =
$state(undefined)
type Dependency = { kind: Kind; path: string; include: boolean }
let dependencies: Dependency[] | undefined = $state<Dependency[] | undefined>(undefined)
const allAlreadyExists: { [key: string]: boolean } = $state({})
let diffDrawer: DiffDrawer | undefined = $state(undefined)
let notSet: boolean | undefined = $state(undefined)
let isFlow: boolean | undefined = $state(undefined)
// On-behalf-of tracking for flows and scripts
// Source workspace on_behalf_of emails (keyed by kind:path)
let sourceOnBehalfOfInfo = $state<Record<string, string | undefined>>({})
// Target workspace on_behalf_of emails (keyed by kind:path)
let targetOnBehalfOfInfo = $state<Record<string, string | undefined>>({})
let onBehalfOfChoice = $state<Record<string, OnBehalfOfChoice>>({})
let customOnBehalfOf = $state<Record<string, OnBehalfOfDetails>>({})
let canPreserveOnBehalfOf = $state(false)
// Check if an item needs on_behalf_of selection
function itemNeedsOnBehalfOfSelection(statusPath: string, kind: string): boolean {
return needsOnBehalfOfSelection(kind, sourceOnBehalfOfInfo[statusPath])
}
/**
* Get the on_behalf_of value for deployment based on user's choice.
* Returns an email for flows/scripts/apps, or permissioned_as (u/username, g/group) for triggers/schedules.
*/
function getOnBehalfOfForDeploy(statusPath: string, kind: Kind): string | undefined {
const choice = onBehalfOfChoice[statusPath]
if (choice === 'target') return targetOnBehalfOfInfo[statusPath]
if (choice === 'custom') {
const details = customOnBehalfOf[statusPath]
return kind === 'trigger' ? details?.permissionedAs : details?.email
}
// 'me' or undefined = don't pass, backend will use deploying user's identity
return undefined
}
async function reload(path: string) {
try {
if (!$superadmin) {
const targetUser = await UserService.whoami({ workspace: workspaceToDeployTo! })
canPreserveOnBehalfOf =
targetUser.is_admin || targetUser.groups?.includes('wm_deployers') || false
} else {
canPreserveOnBehalfOf = true
}
canSeeTarget = 'yes'
} catch {
canSeeTarget = 'cant-deploy-to-workspace'
canPreserveOnBehalfOf = false
return
}
let allDeps
try {
allDeps = await getDependencies(kind, path)
} catch {
canSeeTarget = 'cant-see-all-deps'
return
}
let sortedSet: { kind: Kind; path: string }[] = []
allDeps.forEach((x) => {
if (!sortedSet.find((y) => y.kind == x.kind && y.path == x.path)) {
sortedSet.push(x)
}
})
for (const dep of sortedSet) {
allAlreadyExists[computeStatusPath(dep.kind, dep.path)] = await checkAlreadyExists(
dep.kind,
dep.path
)
}
dependencies = sortedSet.map((x) => ({
...x,
include:
x.kind != 'variable' &&
x.kind != 'resource' &&
x.kind != 'resource_type' &&
(x.kind != 'folder' || !allAlreadyExists[computeStatusPath(x.kind, x.path)])
}))
// Fetch on_behalf_of_email for flows, scripts, apps, and triggers from both workspaces
for (const dep of sortedSet.filter((d) =>
['flow', 'script', 'app', 'trigger'].includes(d.kind)
)) {
const key = computeStatusPath(dep.kind, dep.path)
try {
sourceOnBehalfOfInfo[key] = await getOnBehalfOf(
dep.kind,
dep.path,
$workspaceStore!,
additionalInformation
)
} catch {
sourceOnBehalfOfInfo[key] = undefined
}
try {
targetOnBehalfOfInfo[key] = await getOnBehalfOf(
dep.kind,
dep.path,
workspaceToDeployTo!,
additionalInformation
)
} catch {
targetOnBehalfOfInfo[key] = undefined
}
}
}
async function getDependencies(
kind: Kind,
path: string
): Promise<{ kind: Kind; path: string }[]> {
async function rec(kind: Kind, path: string): Promise<{ kind: Kind; path: string }[]> {
if (kind == 'schedule') {
const schedule = await ScheduleService.getSchedule({ workspace: $workspaceStore!, path })
if (schedule.script_path && schedule.script_path != '') {
if (schedule.script_path) {
return [{ kind: 'script', path: schedule.script_path }]
} else {
return [{ kind: 'flow', path: schedule.script_path }]
}
} else {
return []
}
} else if (kind == 'flow') {
const flow = await FlowService.getFlowByPath({ workspace: $workspaceStore!, path })
return getAllModules(flow.value.modules, flow.value.failure_module).flatMap((x) => {
let result: { kind: Kind; path: string }[] = []
if (x.value.type == 'script' || x.value.type == 'rawscript' || x.value.type == 'flow') {
Object.values(x.value.input_transforms).forEach((y) => {
if (y.type == 'static' && typeof y.value == 'string') {
if (y.value.startsWith('$res:')) {
result.push({ kind: 'resource', path: y.value.substring(5) })
} else if (y.value.startsWith('$var:')) {
result.push({ kind: 'variable', path: y.value.substring(5) })
}
}
})
}
if (x.value.type == 'script') {
if (x.value.path && !x.value.path.startsWith('hub/')) {
result.push({ kind: 'script', path: x.value.path })
}
} else if (x.value.type == 'flow') {
if (x.value.path) {
result.push({ kind: 'flow', path: x.value.path })
}
}
return result
})
} else if (kind == 'app') {
const app = await AppService.getAppByPath({ workspace: $workspaceStore!, path })
let result: { kind: Kind; path: string }[] = []
if (app.raw_app) {
const rawAppValue = app.value as { runnables?: Record<string, Runnable> }
for (const runnable of Object.values(rawAppValue.runnables ?? {})) {
if (isRunnableByPath(runnable)) {
if (runnable.runType == 'script') {
result.push({ kind: 'script', path: runnable.path })
} else if (runnable.runType == 'flow') {
result.push({ kind: 'flow', path: runnable.path })
}
}
}
} else {
let appValue = app.value as App
getAllGridItems(appValue).forEach((gridItem) => {
const ci = gridItem.data.componentInput
if (ci?.type == 'runnable' && isRunnableByPath(ci.runnable)) {
if (ci.runnable.runType == 'script') {
result.push({ kind: 'script', path: ci.runnable.path })
} else if (ci.runnable.runType == 'flow') {
result.push({ kind: 'flow', path: ci.runnable.path })
}
}
})
}
return result
} else if (kind == 'resource') {
const res = await ResourceService.getResource({ workspace: $workspaceStore!, path })
function recObj(obj: any) {
if (typeof obj == 'string' && obj.startsWith('$var:')) {
return [{ kind: 'variable', path: obj.substring(5) }]
} else if (typeof obj == 'object') {
return Object.values(obj).flatMap((x) => recObj(x))
} else {
return []
}
}
return [...recObj(res.value), { kind: 'resource_type', path: res.resource_type }]
} else if (kind == 'trigger') {
if (additionalInformation?.triggers) {
return getTriggerDependency(additionalInformation.triggers.kind, path, $workspaceStore!)
}
throw new Error('Missing trigger information')
}
return []
}
let toProcess = [{ kind, path }]
let processed: { kind: Kind; path: string }[] = []
while (toProcess.length > 0) {
const { kind, path } = toProcess.pop()!
toProcess.push(...(await rec(kind, path)))
processed.push({ kind, path })
}
let folders: string[] = []
for (const p of processed) {
let split = p.path.split('/')
if (split.length > 2 && split[0] == 'f' && !folders.includes(split[1])) {
folders.push(split[1])
processed.push({ kind: 'folder', path: split[1] })
}
}
return processed
}
async function checkAlreadyExists(kind: Kind, path: string): Promise<boolean> {
return checkItemExists(kind, path, workspaceToDeployTo!, additionalInformation)
}
const deploymentStatus: Record<
string,
{ status: 'loading' | 'deployed' | 'failed'; error?: string }
> = $state({})
async function deploy(kind: Kind, path: string) {
const statusPath = computeStatusPath(kind, path)
deploymentStatus[statusPath] = { status: 'loading' }
const result = await deployItem({
kind,
path,
workspaceFrom: $workspaceStore!,
workspaceTo: workspaceToDeployTo!,
additionalInformation,
onBehalfOf: getOnBehalfOfForDeploy(statusPath, kind)
})
if (result.success) {
allAlreadyExists[statusPath] = true
deploymentStatus[statusPath] = { status: 'deployed' }
} else {
deploymentStatus[statusPath] = { status: 'failed', error: result.error }
}
}
export function deployAll() {
dependencies?.slice().forEach(async ({ kind, path, include }) => {
if (include) {
await deploy(kind, path)
}
})
dispatch('update', initialPath)
}
function computeStatusPath(kind: Kind, path: string) {
return `${kind}:${path}`
}
async function showDiff(kind: Kind, path: string) {
diffDrawer?.openDrawer()
let values = await Promise.all([
getItemValue(kind, path, workspaceToDeployTo!, additionalInformation),
getItemValue(kind, path, $workspaceStore!, additionalInformation)
])
diffDrawer?.setDiff({
mode: 'simple',
original: values?.[0] as any,
current: values?.[1] as any,
title: 'Staging/prod <> Dev'
})
}
$effect(() => {
WorkspaceService.getDeployTo({ workspace: $workspaceStore! }).then((x) => {
workspaceToDeployTo = x.deploy_to
if (x.deploy_to == undefined) {
notSet = true
}
})
})
$effect(() => {
workspaceToDeployTo && initialPath && untrack(() => reload(initialPath))
})
// Transform dependencies to deployable item format for the shared layout
let deployableItems = $derived(
(dependencies ?? []).map((dep) => ({
key: computeStatusPath(dep.kind, dep.path),
path: dep.path,
kind: dep.kind,
include: dep.include,
triggerKind: dep.kind === 'trigger' ? additionalInformation?.triggers?.kind : undefined
}))
)
let selectedItems = $derived<string[]>(
(dependencies ?? [])
.filter((dep) => dep.include)
.map((dep) => computeStatusPath(dep.kind, dep.path))
)
let allSelected = $derived(
dependencies != null && dependencies.length > 0 && dependencies.every((dep) => dep.include)
)
// Check if all required on_behalf_of selections are made
let hasUnselectedOnBehalfOf = $derived(
selectedItems.some((statusPath) => {
const dep = dependencies?.find((d) => computeStatusPath(d.kind, d.path) === statusPath)
if (!dep) return false
return (
itemNeedsOnBehalfOfSelection(statusPath, dep.kind) &&
onBehalfOfChoice[statusPath] === undefined
)
})
)
function toggleItem(item: { key: string }) {
if (dependencies) {
const idx = dependencies.findIndex(
(dep) => computeStatusPath(dep.kind, dep.path) === item.key
)
if (idx !== -1) {
dependencies[idx].include = !dependencies[idx].include
}
}
}
function selectAll() {
if (dependencies) {
dependencies = dependencies.map((dep) => ({ ...dep, include: true }))
}
}
function deselectAll() {
if (dependencies) {
dependencies = dependencies.map((dep) => ({ ...dep, include: false }))
}
}
</script>
<div class="mt-6"></div>
{#if !$enterpriseLicense}
<Alert type="warning" title="Enterprise license required"
>Deploy to staging/prod from the web UI is only available with an enterprise license</Alert
>
{:else if notSet == true}
<Alert type="error" title="Staging/Prod deploy not set up"
>As an admin, go to Settings {'->'} Workspace {'->'} Deployment UI</Alert
>
{:else}
<Alert type="info" title="Shareable page"
>Share this <a href="{$wsBase}/deploy/{kind}/{initialPath}">link</a> to have another properly permissioned
user do the deployment</Alert
>
<h3 class="mb-2 mt-8"
>Destination Workspace&nbsp; <Tooltip
>Workspace to deploy to is set in the workspace settings</Tooltip
></h3
>
<input class="max-w-xs" type="text" disabled value={workspaceToDeployTo} />
{#if workspaceToDeployTo}
<ParentWorkspaceProtectionAlert
parentWorkspaceId={workspaceToDeployTo}
onUpdateCanDeploy={(canDeploy) => {
canDeployToWorkspace = canDeploy
}}
/>
{/if}
{#if canSeeTarget == undefined}
<div class="mt-6"></div>
<Loader2 class="animate-spin" />
{:else if canSeeTarget == 'yes'}
<h3 class="mb-6 mt-16">All related deployable items</h3>
<DiffDrawer bind:this={diffDrawer} {isFlow} />
<WorkspaceDeployLayout
items={deployableItems}
{selectedItems}
{deploymentStatus}
selectablePredicate={() => true}
{allSelected}
onToggleItem={toggleItem}
onSelectAll={selectAll}
onDeselectAll={deselectAll}
emptyMessage="No deployable items found"
>
{#snippet itemActions(item)}
{@const statusPath = item.key}
{@const exists = allAlreadyExists[statusPath]}
{@const status = deploymentStatus[statusPath]}
{@const targetValue = targetOnBehalfOfInfo[statusPath]}
<!-- On-behalf-of selector -->
{#if itemNeedsOnBehalfOfSelection(statusPath, item.kind)}
<OnBehalfOfSelector
targetWorkspace={workspaceToDeployTo!}
{targetValue}
selected={onBehalfOfChoice[statusPath]}
onSelect={(choice, details) => {
onBehalfOfChoice[statusPath] = choice
if (details) customOnBehalfOf[statusPath] = details
}}
kind={item.kind}
canPreserve={canPreserveOnBehalfOf}
customValue={customOnBehalfOf[statusPath]?.permissionedAs}
/>
{/if}
{#if item.kind === 'raw_app'}
<Badge small icon={{ icon: FileJson }}>Raw</Badge>
{/if}
{#if exists === false}
{#if item.include}
<Badge
>New <Tooltip
>This {item.kind} doesn't exist yet on the target and will be created by the deployment</Tooltip
></Badge
>
{:else}
<Badge color="red">
Missing
<Tooltip
>{#if item.kind == 'resource_type'}
Resource types are not re-deployed by default. We strongly recommend to add shared
resource types in 'admin' workspace, which will have them be shared to every
workspace.
{:else}
This {item.kind} doesn't exist and is not included in the deployment. Variables and
Resources are considered to be workspace specific and are never included by default.
{/if}</Tooltip
>
</Badge>
{/if}
{:else if exists === true && !status}
<Button
size="xs"
variant="subtle"
onclick={() => {
showDiff(item.kind, item.path)
isFlow = item.kind === 'flow'
}}
>
<DiffIcon class="w-3 h-3" />
Show diff
</Button>
{/if}
{#if !status}
<Button
color="light"
size="xs"
disabled={!canDeployToWorkspace ||
(itemNeedsOnBehalfOfSelection(statusPath, item.kind) &&
onBehalfOfChoice[statusPath] === undefined)}
onclick={() => deploy(item.kind, item.path)}>Deploy</Button
>
{/if}
{/snippet}
{#snippet footer()}
<div class="flex flex-col items-end gap-2">
{#if !hideButton}
<Button on:click={deployAll} disabled={!canDeployToWorkspace || hasUnselectedOnBehalfOf}
>Deploy all toggled</Button
>
{/if}
{#if hasUnselectedOnBehalfOf}
<span class="text-xs text-yellow-600">
{#if kind === 'trigger'}
You must set the "permissioned as" user for all triggers before deploying
<Tooltip class="text-yellow-600">
The "permissioned as" field defines which user's permissions will be applied when
the trigger fires. Make sure this is set appropriately before deploying.
</Tooltip>
{:else}
You must set the "on behalf of" user for all items before deploying
<Tooltip class="text-yellow-600">
The "run on behalf of" field defines which user's permissions will be applied
during execution. Make sure this is set to an appropriate user before deploying.
</Tooltip>
{/if}
</span>
{/if}
</div>
{/snippet}
</WorkspaceDeployLayout>
{:else if canSeeTarget == 'cant-see-all-deps'}
<div class="my-2"></div>
<Alert type="error" title="User doesn't have visibility over all dependencies"
>You do not have visibility over some of the dependencies of this item. Ask a permissioned
user to deploy this item using the shareable link or get the proper permissions on the
dependencies</Alert
>
{:else}
<div class="my-2"></div>
<Alert type="error" title="User not allowed to deploy to this workspace"
>Ask a permissioned user to deploy this item using the shareable link or get the proper
permissions on the target workspace</Alert
>
{/if}
{/if}