mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-21 16:02:28 +00:00
feat: validate and make explicit the hub project's resource type export (#10388)
* feat: make hub resource type export explicit and validated * fix: skip resource type validation when the workspace has no type catalog * fix: snapshot the export opt-in and exclude s3_object unconditionally * fix: capture the export opt-in at publish click, not after the draft request * docs: correct the publish-snapshot rationale
This commit is contained in:
@@ -287,8 +287,9 @@
|
||||
{/if}
|
||||
<Tooltip>
|
||||
Resource types the selected items depend on (whether passed as inputs or
|
||||
referenced by a hardcoded path). Synced to the Hub so a fork knows what
|
||||
credentials it needs to fill.
|
||||
referenced by a hardcoded path). A stub resource of each type is synced to the
|
||||
Hub so a fork knows what credentials it needs to fill. Publishing a type's own
|
||||
definition is opt-in — tick it in the details drawer.
|
||||
</Tooltip>
|
||||
</span>
|
||||
{#if s.dependencyTypes.length === 0}
|
||||
@@ -303,6 +304,9 @@
|
||||
: 'bg-surface'}"
|
||||
>
|
||||
{r.resource_type}
|
||||
{#if s.exportedResourceTypes.has(r.resource_type)}
|
||||
<Badge color="blue" size="xs">exported</Badge>
|
||||
{/if}
|
||||
</span>
|
||||
{/each}
|
||||
<Button
|
||||
@@ -311,7 +315,9 @@
|
||||
wrapperClasses="ml-auto"
|
||||
onclick={() => resourceDrawer?.openDrawer()}
|
||||
>
|
||||
View details
|
||||
{s.exportedDependencyTypes.length > 0
|
||||
? `View details (${s.exportedDependencyTypes.length} type definition${s.exportedDependencyTypes.length > 1 ? 's' : ''} exported)`
|
||||
: 'View details'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -830,12 +836,19 @@
|
||||
<DrawerContent title="Resource dependencies" on:close={() => resourceDrawer?.closeDrawer()}>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-xs text-secondary">
|
||||
Resource types the selected items depend on. Each is synced to the Hub so a fork knows
|
||||
what credentials it needs to fill. <span class="font-semibold">Input</span> means the
|
||||
item takes the resource as a parameter;
|
||||
Resource types the selected items depend on. A stub resource of each type is synced to
|
||||
the Hub so a fork knows what credentials it needs to fill.
|
||||
<span class="font-semibold">Input</span> means the item takes the resource as a
|
||||
parameter;
|
||||
<span class="font-semibold">hardcoded path</span> means the item pins a specific resource
|
||||
path in its code.
|
||||
</p>
|
||||
<p class="text-xs text-secondary">
|
||||
Publishing a type's own <span class="font-semibold">definition</span> (its schema and
|
||||
description) is a separate, explicit choice: tick
|
||||
<span class="font-semibold">Export type definition</span> only for custom types the Hub doesn't
|
||||
already know. Standard types are already defined on the Hub and need no export.
|
||||
</p>
|
||||
{#if s.dependencyTypes.length === 0}
|
||||
<span class="text-xs text-hint">No resource references in the current selection.</span>
|
||||
{:else}
|
||||
@@ -852,6 +865,19 @@
|
||||
<span class="text-[11px] text-hint">
|
||||
{r.usages.length} usage{r.usages.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
<div class="ml-auto shrink-0">
|
||||
<Toggle
|
||||
size="xs"
|
||||
checked={s.exportedResourceTypes.has(r.resource_type)}
|
||||
disabled={s.deploying}
|
||||
on:change={() => s.toggleResourceTypeExport(r.resource_type)}
|
||||
options={{
|
||||
right: 'Export type definition',
|
||||
rightTooltip:
|
||||
'Publishes this resource type (name, schema, description) to the Hub project. Leave off if the Hub already defines it.'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each r.usages as u, ui (ui)}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { canRecordSession, mergeAppTableOrigin, type DeployItem } from './deployToHubItems'
|
||||
import {
|
||||
canRecordSession,
|
||||
inputResourceTypes,
|
||||
mergeAppTableOrigin,
|
||||
type DeployItem
|
||||
} from './deployToHubItems'
|
||||
|
||||
function item(over: Partial<DeployItem> & Pick<DeployItem, 'key' | 'path' | 'kind'>): DeployItem {
|
||||
return { rec: 'none', ...over }
|
||||
@@ -34,3 +39,26 @@ describe('mergeAppTableOrigin', () => {
|
||||
).toBe(orphan)
|
||||
})
|
||||
})
|
||||
|
||||
describe('inputResourceTypes', () => {
|
||||
const schema = {
|
||||
properties: {
|
||||
db: { format: 'resource-postgresql' },
|
||||
file: { format: 'resource-s3_object' },
|
||||
typo: { format: 'resource-postgres' },
|
||||
theme: { format: 'resource-app_theme' },
|
||||
name: { format: 'email' }
|
||||
}
|
||||
}
|
||||
it('keeps only formats the workspace declares as a resource type', () => {
|
||||
expect(inputResourceTypes(schema, new Set(['postgresql', 'stripe']))).toEqual(['postgresql'])
|
||||
})
|
||||
// Undefined (still loading) and empty (a workspace that never synced the Hub's
|
||||
// types) are both "no catalog" — validating would drop every legitimate type.
|
||||
// `s3_object` is never a resource type, so it stays out even here.
|
||||
it('falls back to every non-hidden format without a type catalog', () => {
|
||||
const all = ['postgresql', 'postgres']
|
||||
expect(inputResourceTypes(schema, undefined)).toEqual(all)
|
||||
expect(inputResourceTypes(schema, new Set())).toEqual(all)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,6 +18,43 @@ export interface DeployItem {
|
||||
|
||||
export const canRecord = (k: Kind) => k === 'script' || k === 'flow'
|
||||
|
||||
// `s3_object` is a built-in file-picker format, never a resource type (it is not
|
||||
// on the Hub's type list), so it is excluded even when there is no catalog to
|
||||
// validate against.
|
||||
export const HIDDEN_RESOURCE_TYPES = new Set(['app_theme', 'state', 'cache', 's3_object'])
|
||||
|
||||
/**
|
||||
* Resource types an item takes as an input, read off its schema's
|
||||
* `resource-<type>` arg formats.
|
||||
*
|
||||
* A `resource-<x>` format is not a type declaration: stale and misspelled ones
|
||||
* pass through unchanged. Publishing those to the Hub would push an empty-schema
|
||||
* type and a stub resource nothing can ever fill, so an input-derived type only
|
||||
* counts once the workspace declares it — the same bar `ArgInput` applies before
|
||||
* rendering an arg as a resource picker.
|
||||
*
|
||||
* `known` is only authoritative once it holds something: undefined (still
|
||||
* loading, or the call failed) and empty (a workspace whose type catalog was
|
||||
* never synced from the Hub) both mean "no catalog to validate against", and
|
||||
* filtering on one would drop every legitimate type instead.
|
||||
*/
|
||||
export function inputResourceTypes(schema: unknown, known: Set<string> | undefined): string[] {
|
||||
const validate = known !== undefined && known.size > 0
|
||||
const out = new Set<string>()
|
||||
const props = (schema as any)?.properties
|
||||
if (props && typeof props === 'object') {
|
||||
for (const key of Object.keys(props)) {
|
||||
const fmt = props[key]?.format
|
||||
if (typeof fmt !== 'string' || !fmt.startsWith('resource-')) continue
|
||||
const type = fmt.slice('resource-'.length)
|
||||
if (HIDDEN_RESOURCE_TYPES.has(type)) continue
|
||||
if (validate && !known.has(type)) continue
|
||||
out.add(type)
|
||||
}
|
||||
}
|
||||
return [...out]
|
||||
}
|
||||
|
||||
// A raw app has no run to capture: its demo is a recorded session of someone
|
||||
// using it, driven in the record drawer and replayed on the Hub page. Legacy raw
|
||||
// apps live only in the `raw_app` table, and the record surface loads the app
|
||||
|
||||
@@ -38,7 +38,9 @@ import type { Kind } from '$lib/utils_deployable'
|
||||
import {
|
||||
canRecord,
|
||||
canRecordSession,
|
||||
inputResourceTypes,
|
||||
mergeAppTableOrigin,
|
||||
HIDDEN_RESOURCE_TYPES,
|
||||
type DeployItem
|
||||
} from './deployToHubItems'
|
||||
import type { AssetGraphResponse } from '$lib/components/assets/AssetGraph/types'
|
||||
@@ -92,8 +94,6 @@ const ITEM_KIND_ROUTE: Record<ItemKind, string> = {
|
||||
raw_app: 'apps_raw/get'
|
||||
}
|
||||
|
||||
const HIDDEN_RESOURCE_TYPES = new Set(['app_theme', 'state', 'cache'])
|
||||
|
||||
// Prune a folder's asset graph to a set of scripts so a pipeline recording only
|
||||
// runs, renders and samples the project's included members — a deselected branch
|
||||
// (its nodes, code, logs/results and table samples) never enters the recording.
|
||||
@@ -114,20 +114,6 @@ function pruneGraphToScripts(graph: AssetGraphResponse, scripts: Set<string>): A
|
||||
return { assets, runnables, edges, triggers, macro_edges, test_edges }
|
||||
}
|
||||
|
||||
function typesFromSchema(schema: any): string[] {
|
||||
const out = new Set<string>()
|
||||
const props = schema?.properties
|
||||
if (props && typeof props === 'object') {
|
||||
for (const key of Object.keys(props)) {
|
||||
const fmt = props[key]?.format
|
||||
if (typeof fmt === 'string' && fmt.startsWith('resource-')) {
|
||||
out.add(fmt.slice('resource-'.length))
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...out]
|
||||
}
|
||||
|
||||
type DependencyUsage =
|
||||
| { role: 'input'; label: string; kind: ItemKind; itemPath: string }
|
||||
| { role: 'hardcoded'; label: string; kind: ItemKind; path: string; itemPath: string }
|
||||
@@ -219,6 +205,14 @@ export class DeployToHubSession {
|
||||
// they pick up the fresh SQL (Monaco doesn't sync external `code` changes).
|
||||
migrationsGeneration = $state(0)
|
||||
|
||||
// Resource type names declared by the workspace, used to tell a real type from
|
||||
// an arbitrary `resource-<x>` arg format. Stays undefined until the list loads.
|
||||
resourceTypeNames = $state<Set<string> | undefined>(undefined)
|
||||
// Resource types the user explicitly opted into publishing. Opt-in, never
|
||||
// derived from the selection: exporting a type definition to the Hub is a
|
||||
// deliberate act, so the default is to export none.
|
||||
exportedResourceTypes = $state<Set<string>>(new Set())
|
||||
|
||||
bundlePreview = $state<ProjectBundle | undefined>(undefined)
|
||||
detectingResources = $state(false)
|
||||
// Data tables (→ tables) the current selection reads/writes, detected off the
|
||||
@@ -257,11 +251,26 @@ export class DeployToHubSession {
|
||||
|
||||
load() {
|
||||
void this.#loadWorkspace()
|
||||
void this.#loadResourceTypeNames()
|
||||
void this.#loadTriggers()
|
||||
void this.rehydrateFromHub()
|
||||
void this.#loadPipelineGraph()
|
||||
}
|
||||
|
||||
// Deliberately not `resourceTypesStore.getResourceTypes()`: its error path
|
||||
// resolves to a non-empty `['error_fetching_names']`, which here would read as
|
||||
// a real catalog and filter out every legitimate type. A failure must leave the
|
||||
// catalog unset so validation stays off.
|
||||
async #loadResourceTypeNames() {
|
||||
try {
|
||||
const names = await ResourceService.listResourceTypeNames({ workspace: this.workspace })
|
||||
if (this.#disposed) return
|
||||
this.resourceTypeNames = new Set(names)
|
||||
} catch (e: any) {
|
||||
console.error('failed to load resource type names, resource type validation is off', e)
|
||||
}
|
||||
}
|
||||
|
||||
filteredWorkspaceItems = $derived(
|
||||
this.workspaceItems.filter((i) => i.path.startsWith(this.selectedFolder + '/'))
|
||||
)
|
||||
@@ -372,8 +381,7 @@ export class DeployToHubSession {
|
||||
itemPath: it.path
|
||||
})
|
||||
}
|
||||
for (const t of typesFromSchema(it.schema)) {
|
||||
if (HIDDEN_RESOURCE_TYPES.has(t)) continue
|
||||
for (const t of inputResourceTypes(it.schema, this.resourceTypeNames)) {
|
||||
ensure(t).usages.push({ role: 'input', label, kind: it.kind, itemPath: it.path })
|
||||
}
|
||||
}
|
||||
@@ -400,6 +408,22 @@ export class DeployToHubSession {
|
||||
return [...byType.values()].sort((a, b) => a.resource_type.localeCompare(b.resource_type))
|
||||
})
|
||||
|
||||
// Only the types the user ticked, restricted to what the current selection
|
||||
// actually depends on — deselecting the last item that used a type drops it
|
||||
// from the export without the user having to untick it.
|
||||
exportedDependencyTypes = $derived(
|
||||
this.dependencyTypes
|
||||
.map((d) => d.resource_type)
|
||||
.filter((rt) => this.exportedResourceTypes.has(rt))
|
||||
)
|
||||
|
||||
toggleResourceTypeExport = (resource_type: string) => {
|
||||
const next = new Set(this.exportedResourceTypes)
|
||||
if (next.has(resource_type)) next.delete(resource_type)
|
||||
else next.add(resource_type)
|
||||
this.exportedResourceTypes = next
|
||||
}
|
||||
|
||||
toggleItem = (item: { key: string }) => {
|
||||
const next = new Set(this.manualDeselected)
|
||||
if (next.has(item.key)) next.delete(item.key)
|
||||
@@ -827,9 +851,13 @@ export class DeployToHubSession {
|
||||
if (this.deploying || this.triggersLoading || this.triggerDiscoveryFailed) return
|
||||
this.deploying = true
|
||||
try {
|
||||
// Captured at click time rather than read in #deployAll, which only runs
|
||||
// after the draft request resolves: what gets published must be what was
|
||||
// ticked on confirmation, whatever mutates `exportedResourceTypes` after.
|
||||
const exportedTypes = new Set(this.exportedResourceTypes)
|
||||
if (!(await this.#createDraft())) return
|
||||
onDraftCreated?.()
|
||||
await this.#deployAll()
|
||||
await this.#deployAll(exportedTypes)
|
||||
} finally {
|
||||
this.deploying = false
|
||||
}
|
||||
@@ -1048,7 +1076,7 @@ export class DeployToHubSession {
|
||||
return results.reduce((a: number, b) => a + b, 0)
|
||||
}
|
||||
|
||||
async #deployAll() {
|
||||
async #deployAll(exportedTypes: Set<string>) {
|
||||
const slug = this.hubSlug
|
||||
// Snapshot the selection up-front: `selectedItems`/`relevantTriggers` are
|
||||
// derived from live workspace data and `migrationDrafts` is edited in the
|
||||
@@ -1096,14 +1124,22 @@ export class DeployToHubSession {
|
||||
// was replaced (workspace/folder switch) in the meantime.
|
||||
if (this.#disposed) return
|
||||
|
||||
// Types come from $res: stubs AND schema inputs (resource-<type>).
|
||||
const inputTypes = bundle.items
|
||||
.flatMap((i) => typesFromSchema(i.schema))
|
||||
.filter((t) => !HIDDEN_RESOURCE_TYPES.has(t))
|
||||
// Types come from $res: stubs AND schema inputs (resource-<type>). A stub's
|
||||
// type is declared by an existing resource, so it needs no validation; an
|
||||
// input's is a free-form format string, so it does.
|
||||
const inputTypes = bundle.items.flatMap((i) =>
|
||||
inputResourceTypes(i.schema, this.resourceTypeNames)
|
||||
)
|
||||
const types = [
|
||||
...new Set([...bundle.resourceStubs.map((s) => s.resource_type), ...inputTypes])
|
||||
]
|
||||
const depFailures = await this.#pushResourceTypes(slug, types)
|
||||
// Only the types the user ticked are published. The others still get their
|
||||
// stub below, so a fork knows which credential to fill; only the type
|
||||
// definition itself stays out of the Hub.
|
||||
const depFailures = await this.#pushResourceTypes(
|
||||
slug,
|
||||
types.filter((t) => exportedTypes.has(t))
|
||||
)
|
||||
|
||||
// Input-type deps with no path get a conventional f/<slug>/<type> stub.
|
||||
const stubsByPath = new Map<string, { path: string; resource_type: string }>()
|
||||
@@ -1185,7 +1221,10 @@ export class DeployToHubSession {
|
||||
this.hubHasRemoteLogo = this.hubLogo !== null
|
||||
this.hubLogo = undefined
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Logo ${this.hubLogo ? 'upload' : 'removal'} failed: ${e?.message ?? e}`, true)
|
||||
sendUserToast(
|
||||
`Logo ${this.hubLogo ? 'upload' : 'removal'} failed: ${e?.message ?? e}`,
|
||||
true
|
||||
)
|
||||
failures++
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user