mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-30 00:16:53 +00:00
* feat(forks): handle triggers and schedules in wmill workspace merge Closes #9001. Brings CLI parity with the merge UI by routing trigger and schedule diffs through the existing workspace_diff tally infrastructure and lifting the deploy logic into the shared windmill-utils-internal module. - Backend: extend tally + compare to all 10 trigger kinds + schedule; new compare_two_trigger_or_schedule helper using to_jsonb minus runtime ignore set; CompareSummary gains schedules_changed/triggers_changed. - Operational-state invariant: fork operations never flip target's mode/enabled. Triggers strip mode/enabled in both UI and CLI deploy payloads (preserved by is_mode_unspecified on backend). Schedules drop the setScheduleEnabled mirror entirely on merge — EditSchedule lacks enabled by design. - Shared module: DeployKind extended with schedule + per-kind triggers; DeployProvider gains per-kind dispatch methods. - Frontend: ~600 lines of client-side trigger-diff machinery deleted; rows flow through comparison.diffs like every other kind. Diff drawer returns full GET response stripped of runtime fields, matching backend semantics. Default selection excludes triggers/schedules (opt-in). - CLI (merge.ts): per-kind provider, GCP-specific transforms (audience reset, base_endpoint with /api stripped to match frontend), summary table rows for Schedules/Triggers, default-deselect mirroring the UI. - Bumps windmill-utils-internal to 1.5.0 (new exports for trigger per-kind dispatch); frontend depends on ^1.5.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(enterprise): clarify [ee] prefix applies whenever an EE companion PR exists Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref to 6ee680c25e3413d928fc22002be6deb118092668 This commit updates the EE repository reference after PR #557 was merged in windmill-ee-private. Previous ee-repo-ref: ad35a056627656fd426fb19856ea945955d4727f New ee-repo-ref: 6ee680c25e3413d928fc22002be6deb118092668 Automated by sync-ee-ref workflow. * fix(forks): preserve target state on merge update, mirror source on create Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(forks): strip server-managed trigger fields and honor --include with --skip-conflicts Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com> Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
385 lines
13 KiB
TypeScript
385 lines
13 KiB
TypeScript
import {
|
|
AppService,
|
|
AzureTriggerService,
|
|
EmailTriggerService,
|
|
FlowService,
|
|
FolderService,
|
|
GcpTriggerService,
|
|
HttpTriggerService,
|
|
KafkaTriggerService,
|
|
MqttTriggerService,
|
|
NatsTriggerService,
|
|
PostgresTriggerService,
|
|
ResourceService,
|
|
ScheduleService,
|
|
ScriptService,
|
|
SqsTriggerService,
|
|
VariableService,
|
|
WebsocketTriggerService
|
|
} from '$lib/gen'
|
|
import {
|
|
existsTrigger,
|
|
getTriggersDeployData,
|
|
getTriggerPermissionedAs,
|
|
getTriggerValue,
|
|
stripOperationalState,
|
|
type AdditionalInformation,
|
|
type Kind
|
|
} from '$lib/utils_deployable'
|
|
|
|
import {
|
|
deployItem as sharedDeployItem,
|
|
deleteItemInWorkspace as sharedDeleteItem,
|
|
checkItemExists as sharedCheckItemExists,
|
|
getOnBehalfOf as sharedGetOnBehalfOf,
|
|
getItemValue as sharedGetItemValue,
|
|
type DeployProvider,
|
|
type DeployKind,
|
|
type DeployResult,
|
|
type TriggerDeployKind
|
|
} from 'windmill-utils-internal'
|
|
|
|
export type { DeployResult, DeployKind, TriggerDeployKind }
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Provider adapter — wraps frontend's class-based services
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Map a shared `TriggerDeployKind` (e.g. `kafka_trigger`) to the per-kind
|
|
* service class. Per-kind dispatch lives in the adapter so the shared
|
|
* `deployItem` only needs to know "trigger" vs "not trigger".
|
|
*/
|
|
function triggerServiceFor(kind: TriggerDeployKind) {
|
|
switch (kind) {
|
|
case 'http_trigger':
|
|
return {
|
|
exists: HttpTriggerService.existsHttpTrigger,
|
|
get: HttpTriggerService.getHttpTrigger,
|
|
create: HttpTriggerService.createHttpTrigger,
|
|
update: HttpTriggerService.updateHttpTrigger,
|
|
delete: HttpTriggerService.deleteHttpTrigger
|
|
}
|
|
case 'websocket_trigger':
|
|
return {
|
|
exists: WebsocketTriggerService.existsWebsocketTrigger,
|
|
get: WebsocketTriggerService.getWebsocketTrigger,
|
|
create: WebsocketTriggerService.createWebsocketTrigger,
|
|
update: WebsocketTriggerService.updateWebsocketTrigger,
|
|
delete: WebsocketTriggerService.deleteWebsocketTrigger
|
|
}
|
|
case 'kafka_trigger':
|
|
return {
|
|
exists: KafkaTriggerService.existsKafkaTrigger,
|
|
get: KafkaTriggerService.getKafkaTrigger,
|
|
create: KafkaTriggerService.createKafkaTrigger,
|
|
update: KafkaTriggerService.updateKafkaTrigger,
|
|
delete: KafkaTriggerService.deleteKafkaTrigger
|
|
}
|
|
case 'nats_trigger':
|
|
return {
|
|
exists: NatsTriggerService.existsNatsTrigger,
|
|
get: NatsTriggerService.getNatsTrigger,
|
|
create: NatsTriggerService.createNatsTrigger,
|
|
update: NatsTriggerService.updateNatsTrigger,
|
|
delete: NatsTriggerService.deleteNatsTrigger
|
|
}
|
|
case 'postgres_trigger':
|
|
return {
|
|
exists: PostgresTriggerService.existsPostgresTrigger,
|
|
get: PostgresTriggerService.getPostgresTrigger,
|
|
create: PostgresTriggerService.createPostgresTrigger,
|
|
update: PostgresTriggerService.updatePostgresTrigger,
|
|
delete: PostgresTriggerService.deletePostgresTrigger
|
|
}
|
|
case 'mqtt_trigger':
|
|
return {
|
|
exists: MqttTriggerService.existsMqttTrigger,
|
|
get: MqttTriggerService.getMqttTrigger,
|
|
create: MqttTriggerService.createMqttTrigger,
|
|
update: MqttTriggerService.updateMqttTrigger,
|
|
delete: MqttTriggerService.deleteMqttTrigger
|
|
}
|
|
case 'sqs_trigger':
|
|
return {
|
|
exists: SqsTriggerService.existsSqsTrigger,
|
|
get: SqsTriggerService.getSqsTrigger,
|
|
create: SqsTriggerService.createSqsTrigger,
|
|
update: SqsTriggerService.updateSqsTrigger,
|
|
delete: SqsTriggerService.deleteSqsTrigger
|
|
}
|
|
case 'gcp_trigger':
|
|
return {
|
|
exists: GcpTriggerService.existsGcpTrigger,
|
|
get: GcpTriggerService.getGcpTrigger,
|
|
create: GcpTriggerService.createGcpTrigger,
|
|
update: GcpTriggerService.updateGcpTrigger,
|
|
delete: GcpTriggerService.deleteGcpTrigger
|
|
}
|
|
case 'azure_trigger':
|
|
return {
|
|
exists: AzureTriggerService.existsAzureTrigger,
|
|
get: AzureTriggerService.getAzureTrigger,
|
|
create: AzureTriggerService.createAzureTrigger,
|
|
update: AzureTriggerService.updateAzureTrigger,
|
|
delete: AzureTriggerService.deleteAzureTrigger
|
|
}
|
|
case 'email_trigger':
|
|
return {
|
|
exists: EmailTriggerService.existsEmailTrigger,
|
|
get: EmailTriggerService.getEmailTrigger,
|
|
create: EmailTriggerService.createEmailTrigger,
|
|
update: EmailTriggerService.updateEmailTrigger,
|
|
delete: EmailTriggerService.deleteEmailTrigger
|
|
}
|
|
default: {
|
|
// Exhaustiveness guard: extending TriggerDeployKind without a case here
|
|
// produces a compile error rather than a silent runtime failure.
|
|
const _exhaustive: never = kind
|
|
throw new Error(`Unhandled trigger kind: ${_exhaustive}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Map the shared `TriggerDeployKind` to the legacy frontend `TriggerKind`
|
|
* used by helpers in `utils_deployable.ts`.
|
|
*/
|
|
function legacyTriggerKind(kind: TriggerDeployKind) {
|
|
const map = {
|
|
http_trigger: 'routes',
|
|
websocket_trigger: 'websockets',
|
|
kafka_trigger: 'kafka',
|
|
nats_trigger: 'nats',
|
|
postgres_trigger: 'postgres',
|
|
mqtt_trigger: 'mqtt',
|
|
sqs_trigger: 'sqs',
|
|
gcp_trigger: 'gcp',
|
|
azure_trigger: 'azure',
|
|
email_trigger: 'emails'
|
|
} as const
|
|
return map[kind]
|
|
}
|
|
|
|
function makeProvider(): DeployProvider {
|
|
return {
|
|
existsFlowByPath: (p) => FlowService.existsFlowByPath(p),
|
|
existsScriptByPath: (p) => ScriptService.existsScriptByPath(p),
|
|
existsApp: (p) => AppService.existsApp(p),
|
|
existsVariable: (p) => VariableService.existsVariable(p),
|
|
existsResource: (p) => ResourceService.existsResource(p),
|
|
existsResourceType: (p) => ResourceService.existsResourceType(p),
|
|
existsFolder: (p) => FolderService.existsFolder(p),
|
|
getFlowByPath: (p) => FlowService.getFlowByPath(p),
|
|
createFlow: (p) => FlowService.createFlow(p),
|
|
updateFlow: (p) => FlowService.updateFlow(p),
|
|
archiveFlowByPath: (p) => FlowService.archiveFlowByPath(p),
|
|
getScriptByPath: (p) => ScriptService.getScriptByPath(p),
|
|
createScript: (p) => ScriptService.createScript(p),
|
|
archiveScriptByPath: (p) => ScriptService.archiveScriptByPath(p),
|
|
getAppByPath: (p) => AppService.getAppByPath(p),
|
|
createApp: (p) => AppService.createApp(p),
|
|
updateApp: (p) => AppService.updateApp(p),
|
|
createAppRaw: (p) => AppService.createAppRaw(p),
|
|
updateAppRaw: (p) => AppService.updateAppRaw(p),
|
|
getPublicSecretOfLatestVersionOfApp: (p) => AppService.getPublicSecretOfLatestVersionOfApp(p),
|
|
getRawAppData: (p) => AppService.getRawAppData(p),
|
|
deleteApp: (p) => AppService.deleteApp(p),
|
|
getVariable: (p) => VariableService.getVariable(p),
|
|
createVariable: (p) => VariableService.createVariable(p),
|
|
updateVariable: (p) => VariableService.updateVariable(p),
|
|
deleteVariable: (p) => VariableService.deleteVariable(p),
|
|
getResource: (p) => ResourceService.getResource(p),
|
|
createResource: (p) => ResourceService.createResource(p),
|
|
updateResource: (p) => ResourceService.updateResource(p),
|
|
deleteResource: (p) => ResourceService.deleteResource(p),
|
|
getResourceType: (p) => ResourceService.getResourceType(p),
|
|
createResourceType: (p) => ResourceService.createResourceType(p),
|
|
updateResourceType: (p) => ResourceService.updateResourceType(p),
|
|
deleteResourceType: (p) => ResourceService.deleteResourceType(p),
|
|
getFolder: (p) => FolderService.getFolder(p),
|
|
createFolder: (p) => FolderService.createFolder(p),
|
|
updateFolder: (p) => FolderService.updateFolder(p),
|
|
deleteFolder: (p) => FolderService.deleteFolder(p),
|
|
// Triggers
|
|
existsTriggerByKind: (kind, p) => triggerServiceFor(kind).exists(p),
|
|
getTriggerForDeploy: async (kind, p) => {
|
|
// Reuses the existing per-kind transform map (e.g. GCP wipes
|
|
// subscription_id and computes base_endpoint from window.location).
|
|
// Operational-state strip is applied by the shared `deployItem`
|
|
// after this returns.
|
|
const { data } = await getTriggersDeployData(
|
|
legacyTriggerKind(kind),
|
|
p.path,
|
|
p.workspace,
|
|
p.onBehalfOf
|
|
)
|
|
return data
|
|
},
|
|
createTriggerByKind: (kind, p) => triggerServiceFor(kind).create(p as any),
|
|
updateTriggerByKind: (kind, p) => triggerServiceFor(kind).update(p as any),
|
|
deleteTriggerByKind: (kind, p) => triggerServiceFor(kind).delete(p),
|
|
getTriggerValue: (kind, p) => getTriggerValue(legacyTriggerKind(kind), p.path, p.workspace),
|
|
getTriggerPermissionedAs: async (kind, p) => {
|
|
const trigger = await triggerServiceFor(kind).get(p)
|
|
return (trigger as any)?.permissioned_as
|
|
},
|
|
// Schedules
|
|
existsSchedule: (p) => ScheduleService.existsSchedule(p),
|
|
getSchedule: (p) => ScheduleService.getSchedule(p),
|
|
createSchedule: (p) => ScheduleService.createSchedule(p),
|
|
updateSchedule: (p) => ScheduleService.updateSchedule(p),
|
|
deleteSchedule: (p) => ScheduleService.deleteSchedule(p)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public API — thin wrappers over the shared dispatch
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface DeployItemParams {
|
|
kind: Kind
|
|
path: string
|
|
workspaceFrom: string
|
|
workspaceTo: string
|
|
/**
|
|
* Carries the trigger sub-kind for the legacy generic `kind: 'trigger'` path
|
|
* used by `DeployWorkspace.svelte`. Not needed for the new per-kind names
|
|
* (`http_trigger`, `kafka_trigger`, …) returned by the fork-merge compare API.
|
|
*/
|
|
additionalInformation?: AdditionalInformation
|
|
/**
|
|
* The value to use for on_behalf_of when deploying.
|
|
* Format varies by item kind:
|
|
* - For flows/scripts/apps: an email address (on_behalf_of_email)
|
|
* - For triggers/schedules: permissioned_as format (u/username or g/group)
|
|
* If set, preserve_on_behalf_of / preserve_permissioned_as will be true.
|
|
* If undefined, the deploying user's identity is used.
|
|
*/
|
|
onBehalfOf?: string
|
|
}
|
|
|
|
/**
|
|
* Deploy an item from one workspace to another. Handles every kind in the shared
|
|
* `DeployKind` union plus the legacy generic `'trigger'` from `DeployWorkspace.svelte`,
|
|
* which carries its sub-kind in `additionalInformation`.
|
|
*/
|
|
export async function deployItem(params: DeployItemParams): Promise<DeployResult> {
|
|
const { kind, path, workspaceFrom, workspaceTo, additionalInformation, onBehalfOf } = params
|
|
|
|
if (kind === 'trigger') {
|
|
// Legacy path: `DeployWorkspace.svelte` doesn't know the per-kind trigger
|
|
// name when building dependency graphs, so it passes `kind: 'trigger'` and
|
|
// the actual sub-kind in `additionalInformation`. Translate to per-kind.
|
|
if (!additionalInformation?.triggers) {
|
|
return { success: false, error: 'Missing triggers kind' }
|
|
}
|
|
try {
|
|
const alreadyExists = await existsTrigger(
|
|
{ workspace: workspaceTo, path },
|
|
additionalInformation.triggers.kind
|
|
)
|
|
const { data, createFn, updateFn } = await getTriggersDeployData(
|
|
additionalInformation.triggers.kind,
|
|
path,
|
|
workspaceFrom,
|
|
onBehalfOf
|
|
)
|
|
if (alreadyExists) {
|
|
// Strip operational state so the update doesn't flip the target's
|
|
// existing enabled/mode flag — preserved via `is_mode_unspecified()`
|
|
// on the backend. Mirrors the shared `stripOperationalStateOnUpdate`
|
|
// in the merge-deploy path.
|
|
const stripped = stripOperationalState(data)
|
|
await updateFn({ path, workspace: workspaceTo, requestBody: stripped } as any)
|
|
} else {
|
|
// Create — pass source's `mode`/`enabled` through so a new
|
|
// trigger lands with the state the source workspace had.
|
|
await createFn({ workspace: workspaceTo, requestBody: data } as any)
|
|
}
|
|
return { success: true }
|
|
} catch (e: any) {
|
|
return { success: false, error: e.body || e.message || String(e) }
|
|
}
|
|
}
|
|
|
|
return sharedDeployItem(
|
|
makeProvider(),
|
|
kind as DeployKind,
|
|
path,
|
|
workspaceFrom,
|
|
workspaceTo,
|
|
onBehalfOf
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Delete/archive an item in a workspace.
|
|
* Used when deploying a deletion from one workspace to another.
|
|
* Scripts and flows are archived (reversible). Other types are deleted.
|
|
*/
|
|
export async function deleteItemInWorkspace(
|
|
kind: Kind,
|
|
path: string,
|
|
workspace: string
|
|
): Promise<DeployResult> {
|
|
return sharedDeleteItem(makeProvider(), kind as DeployKind, path, workspace)
|
|
}
|
|
|
|
/**
|
|
* Check if an item already exists in the target workspace.
|
|
*/
|
|
export async function checkItemExists(
|
|
kind: Kind,
|
|
path: string,
|
|
workspace: string,
|
|
additionalInformation?: AdditionalInformation
|
|
): Promise<boolean> {
|
|
if (kind === 'trigger') {
|
|
if (!additionalInformation?.triggers) {
|
|
throw new Error('Missing triggers kind for legacy trigger deploy')
|
|
}
|
|
return existsTrigger({ workspace, path }, additionalInformation.triggers.kind)
|
|
}
|
|
return sharedCheckItemExists(makeProvider(), kind as DeployKind, path, workspace)
|
|
}
|
|
|
|
/**
|
|
* Get the value of an item for diff comparison.
|
|
*/
|
|
export async function getItemValue(
|
|
kind: Kind,
|
|
path: string,
|
|
workspace: string,
|
|
additionalInformation?: AdditionalInformation
|
|
): Promise<unknown> {
|
|
if (kind === 'trigger') {
|
|
if (!additionalInformation?.triggers) return {}
|
|
try {
|
|
return await getTriggerValue(additionalInformation.triggers.kind, path, workspace)
|
|
} catch {
|
|
return {}
|
|
}
|
|
}
|
|
return sharedGetItemValue(makeProvider(), kind as DeployKind, path, workspace)
|
|
}
|
|
|
|
/**
|
|
* Get the on_behalf_of value for a deployable item.
|
|
*/
|
|
export async function getOnBehalfOf(
|
|
kind: Kind,
|
|
path: string,
|
|
workspace: string,
|
|
additionalInformation?: AdditionalInformation
|
|
): Promise<string | undefined> {
|
|
if (kind === 'trigger' && additionalInformation?.triggers) {
|
|
try {
|
|
return await getTriggerPermissionedAs(additionalInformation.triggers.kind, path, workspace)
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
return sharedGetOnBehalfOf(makeProvider(), kind as DeployKind, path, workspace)
|
|
}
|