mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 08:02:18 +00:00
Merge remote-tracking branch 'origin/glm/quick-datatable-onboarding' into glm/install-workspace-picker
This commit is contained in:
@@ -1 +1 @@
|
||||
bd4de74eb37b32a2b6c7c69f6dedac031ef8436b
|
||||
483513b70979aa9497cab869837108d948449984
|
||||
|
||||
@@ -1123,12 +1123,18 @@ async fn get_datatable_resource_inner(
|
||||
serde_json::to_value(&pg_creds)
|
||||
.map_err(|e| Error::internal_err(format!("Error serializing pg creds: {}", e)))?
|
||||
} else {
|
||||
// Name the data table too: the caller asked for one by name, and a bare
|
||||
// "resource f/x/y does not exist" leaves them to work out which one points at it.
|
||||
transform_json_unchecked(
|
||||
&serde_json::Value::String(format!("$res:{}", datatable.database.resource_path)),
|
||||
w_id,
|
||||
db,
|
||||
)
|
||||
.await?
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
Error::NotFound(m) => Error::NotFound(format!("data table {name}: {m}")),
|
||||
e => e,
|
||||
})?
|
||||
};
|
||||
|
||||
Ok(db_resource)
|
||||
@@ -2105,25 +2111,32 @@ async fn transform_json_unchecked(
|
||||
serde_json::Value::Array(transformed_array)
|
||||
}
|
||||
serde_json::Value::String(s) if s.starts_with("$res:") => {
|
||||
// A reference to something that was deleted is the common failure here, and
|
||||
// `fetch_one` reports it as "no rows returned by a query that expected to
|
||||
// return at least one row" -- which names neither what was missing nor where.
|
||||
let path = &s[5..];
|
||||
let resource = sqlx::query_scalar!(
|
||||
"SELECT value AS \"value!: _\" FROM resource WHERE workspace_id = $1 AND path = $2",
|
||||
&w_id,
|
||||
&s[5..]
|
||||
path
|
||||
)
|
||||
.fetch_one(db)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.map_err(to_anyhow)?;
|
||||
.map_err(to_anyhow)?
|
||||
.ok_or_else(|| Error::NotFound(format!("resource {path} does not exist")))?;
|
||||
transform_json_unchecked(&resource, w_id, db).await?
|
||||
}
|
||||
serde_json::Value::String(s) if s.starts_with("$var:") => {
|
||||
let path = &s[5..];
|
||||
let (value, is_secret): (String, bool) = sqlx::query_as(
|
||||
"SELECT value, is_secret FROM variable WHERE workspace_id = $1 AND path = $2",
|
||||
)
|
||||
.bind(&w_id)
|
||||
.bind(&s[5..])
|
||||
.fetch_one(db)
|
||||
.bind(path)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.map_err(to_anyhow)?;
|
||||
.map_err(to_anyhow)?
|
||||
.ok_or_else(|| Error::NotFound(format!("variable {path} does not exist")))?;
|
||||
let value = if is_secret {
|
||||
if is_external_stored_value(&value) {
|
||||
get_secret_value(db, w_id, &s[5..], &value).await?
|
||||
|
||||
@@ -11,12 +11,14 @@
|
||||
import Button from './common/button/Button.svelte'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { base } from '$lib/base'
|
||||
import GitHubAppIntegration from './GitHubAppIntegration.svelte'
|
||||
import BedrockCredentialsCheck from './BedrockCredentialsCheck.svelte'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import ResourceGen from './copilot/ResourceGen.svelte'
|
||||
import SyncResourceTypes from './SyncResourceTypes.svelte'
|
||||
import { base } from '$lib/base'
|
||||
import { isDataTableWizardEnabled } from './workspaceSettings/utils.svelte'
|
||||
import { parsePostgresConnectionString } from '$lib/utils/postgresConnectionString'
|
||||
|
||||
interface Props {
|
||||
resourceType: string
|
||||
@@ -98,35 +100,42 @@
|
||||
let connectionString = $state('')
|
||||
let validConnectionString = $state(true)
|
||||
function parseConnectionString(close: (_: any) => void) {
|
||||
const regex =
|
||||
/postgres(?:ql)?:\/\/(?<user>[^:@]+)(?::(?<password>[^@]+))?@(?<host>[^:\/?]+)(?::(?<port>\d+))?\/(?<dbname>[^\?]+)?(?:\?.*sslmode=(?<sslmode>[^&]+))?/
|
||||
const match = connectionString.match(regex)
|
||||
if (match) {
|
||||
validConnectionString = true
|
||||
const { user, password, host, port, dbname, sslmode } = match.groups!
|
||||
rawCode = JSON.stringify(
|
||||
{
|
||||
...args,
|
||||
user,
|
||||
password: password || args?.password,
|
||||
host,
|
||||
port: (port ? Number(port) : undefined) || args?.port,
|
||||
dbname: dbname || args?.dbname,
|
||||
sslmode: sslmode || args?.sslmode
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
rawCodeEditor?.setCode(rawCode)
|
||||
close(null)
|
||||
} else {
|
||||
const parts = parsePostgresConnectionString(connectionString)
|
||||
if (!parts) {
|
||||
validConnectionString = false
|
||||
return
|
||||
}
|
||||
validConnectionString = true
|
||||
rawCode = JSON.stringify(
|
||||
{
|
||||
...args,
|
||||
user: parts.user,
|
||||
password: parts.password || args?.password,
|
||||
host: parts.host,
|
||||
port: parts.port || args?.port,
|
||||
dbname: parts.dbname || args?.dbname,
|
||||
sslmode: parts.sslmode || args?.sslmode
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
rawCodeEditor?.setCode(rawCode)
|
||||
close(null)
|
||||
}
|
||||
|
||||
let rawCodeEditor: { setCode: (code: string) => void } | undefined = $state(undefined)
|
||||
let textFileContent: string | undefined = $state(undefined)
|
||||
|
||||
// The wizard's Supabase entry point is opt-in for now; without it the form keeps the link
|
||||
// that hands the whole leg over to the resources page.
|
||||
const wizardEnabled = isDataTableWizardEnabled()
|
||||
|
||||
function applySupabasePick(value: Record<string, any>) {
|
||||
args = { ...(args ?? {}), ...value }
|
||||
rawCode = JSON.stringify(args, null, 2)
|
||||
rawCodeEditor?.setCode(rawCode)
|
||||
}
|
||||
|
||||
function parseTextFileContent() {
|
||||
args = {
|
||||
content: textFileContent
|
||||
@@ -172,7 +181,7 @@
|
||||
}}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<Button spacingSize="sm" size="xs" variant="default" nonCaptureEvent>
|
||||
<Button spacingSize="sm" size="xs" unifiedSize="md" variant="default" nonCaptureEvent>
|
||||
From connection string
|
||||
</Button>
|
||||
{/snippet}
|
||||
@@ -206,14 +215,28 @@
|
||||
</Popover>
|
||||
{/if}
|
||||
{#if resourceType == 'postgresql' && supabaseWizard}
|
||||
<a
|
||||
target="_blank"
|
||||
href="{base}/api/oauth/connect/supabase_wizard"
|
||||
class="border rounded-lg flex flex-row gap-2 items-center text-xs px-3 py-1.5 h-8 bg-[#F1F3F5] hover:bg-[#E6E8EB] dark:bg-[#1C1C1C] dark:hover:bg-black"
|
||||
>
|
||||
<SupabaseIcon height="16px" width="16px" />
|
||||
<div class="text-[#11181C] dark:text-[#EDEDED] font-semibold">Connect Supabase</div>
|
||||
</a>
|
||||
{#if wizardEnabled}
|
||||
<!-- Imported here rather than at the top so the wizard's Supabase graph stays out of
|
||||
this form's chunk, which loads on the resources page and in every resource drawer. -->
|
||||
{#await import('./workspaceSettings/SupabaseResourceConnect.svelte')}
|
||||
<Loader2 class="animate-spin" />
|
||||
{:then Module}
|
||||
<Module.default onPicked={applySupabasePick} />
|
||||
{/await}
|
||||
{:else}
|
||||
<!-- `noopener` is what the callback reads to tell this leg from the wizard's popup,
|
||||
which hands its token back through `window.opener`. Browsers imply it for
|
||||
`target="_blank"`, but only since 2021 -- stating it keeps older ones on this path. -->
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
href="{base}/api/oauth/connect/supabase_wizard"
|
||||
class="border rounded-lg flex flex-row gap-2 items-center text-xs px-3 py-1.5 h-8 bg-[#F1F3F5] hover:bg-[#E6E8EB] dark:bg-[#1C1C1C] dark:hover:bg-black"
|
||||
>
|
||||
<SupabaseIcon height="16px" width="16px" />
|
||||
<div class="text-[#11181C] dark:text-[#EDEDED] font-semibold">Connect Supabase</div>
|
||||
</a>
|
||||
{/if}
|
||||
{/if}
|
||||
<GitHubAppIntegration
|
||||
{resourceType}
|
||||
|
||||
@@ -1241,7 +1241,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{:else if step == 2 && manual}
|
||||
<div class="flex flex-col gap-8">
|
||||
<div class="flex flex-col gap-4">
|
||||
{#if !emptyString(resourceTypeInfo?.description)}
|
||||
<GfmMarkdown md={urlize(resourceTypeInfo?.description ?? '', 'md')} prose="sm" noPadding />
|
||||
{/if}
|
||||
@@ -1332,18 +1332,22 @@
|
||||
Acquire the token automatically via client credentials instead
|
||||
</button>
|
||||
{/if}
|
||||
{#key resourceTypeInfo}
|
||||
<ApiConnectForm
|
||||
bind:linkedSecrets
|
||||
bind:description
|
||||
{linkedSecretCandidates}
|
||||
{resourceType}
|
||||
{resourceTypeInfo}
|
||||
bind:args
|
||||
bind:isValid
|
||||
onSynced={getResourceTypeInfo}
|
||||
/>
|
||||
{/key}
|
||||
<!-- The form is a section of its own, not just the next field: it needs more of a break
|
||||
from the description than the uniform gap gives. -->
|
||||
<div class="mt-2">
|
||||
{#key resourceTypeInfo}
|
||||
<ApiConnectForm
|
||||
bind:linkedSecrets
|
||||
bind:description
|
||||
{linkedSecretCandidates}
|
||||
{resourceType}
|
||||
{resourceTypeInfo}
|
||||
bind:args
|
||||
bind:isValid
|
||||
onSynced={getResourceTypeInfo}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
</div>
|
||||
{:else if step == 2 && !manual}
|
||||
{#if manual == false && resourceType != ''}
|
||||
|
||||
@@ -85,6 +85,10 @@
|
||||
* workspace when the editor operates on a workspace other than the one the
|
||||
* top nav points at (see the sessions preview / dev-workspace flows). */
|
||||
workspaceOverride?: string
|
||||
/** One path that does not count as taken, for a caller creating something that may
|
||||
* already have written there itself — a setup flow correcting its own failed attempt.
|
||||
* Every other existing path is still refused. */
|
||||
allowedExistingPath?: string
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -102,7 +106,8 @@
|
||||
disableEditing = false,
|
||||
size = 'md',
|
||||
drawerOffset = 0,
|
||||
workspaceOverride = undefined
|
||||
workspaceOverride = undefined,
|
||||
allowedExistingPath = undefined
|
||||
}: Props = $props()
|
||||
|
||||
let ws = $derived(workspaceOverride ?? $workspaceStore)
|
||||
@@ -240,6 +245,7 @@
|
||||
}
|
||||
validateTimeout = setTimeout(async () => {
|
||||
if (
|
||||
path !== allowedExistingPath &&
|
||||
(path == '' || checkInitialPathExistence || path != initialPath) &&
|
||||
(await pathExists(path, kind))
|
||||
) {
|
||||
@@ -420,8 +426,12 @@
|
||||
})
|
||||
}
|
||||
})
|
||||
// Nothing depends on an item that does not exist yet, so editing a *suggested* path is not a
|
||||
// rename. `checkInitialPathExistence` is what callers set when they are creating something,
|
||||
// which is the same question asked the other way round.
|
||||
let displayPathChangedWarning = $derived(
|
||||
(['flow', 'script', 'resource', 'variable'] as PathKind[]).includes(kind) &&
|
||||
!checkInitialPathExistence &&
|
||||
initialPath &&
|
||||
initialPath !== path
|
||||
)
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
</script>
|
||||
|
||||
{#if ips}
|
||||
<div class="mt-4"></div>
|
||||
<Alert size="xs" type="info" title="IPs to whitelist">
|
||||
<span class="text-primary">If necessary, the workers IPs to whitelist are:</span>
|
||||
{ips.join(', ')}
|
||||
|
||||
@@ -54,6 +54,10 @@
|
||||
}
|
||||
|
||||
const SvelteComponent = $derived(icons[type])
|
||||
|
||||
// A blank title would still occupy a text line and push the body down, leaving an alert
|
||||
// that is visibly top-heavy. Body-only alerts skip the row, and the gap under it, entirely.
|
||||
const hasTitleRow = $derived(!!title || collapsible || tooltip != '' || !!documentationLink)
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -75,44 +79,41 @@
|
||||
/>
|
||||
</div>
|
||||
<div class={twMerge('ml-1 w-full')}>
|
||||
<div class={twMerge('w-full flex flex-row items-center justify-between')}>
|
||||
<span
|
||||
class={twMerge('text-xs font-semibold', classes[type].titleClass, titleClass)}
|
||||
style={titleStyle}
|
||||
>
|
||||
{title}
|
||||
{#if tooltip != '' || documentationLink}
|
||||
<Tooltip {documentationLink}>{tooltip}</Tooltip>
|
||||
{/if}
|
||||
</span>
|
||||
{#if collapsible}
|
||||
<button class="cursor-pointer" onclick={toggleCollapse}>
|
||||
{#if isCollapsed}
|
||||
<ChevronDown size={16} />
|
||||
{:else}
|
||||
<ChevronUp size={16} />
|
||||
{#if hasTitleRow}
|
||||
<div class={twMerge('w-full flex flex-row items-center justify-between')}>
|
||||
<span
|
||||
class={twMerge('text-xs font-semibold', classes[type].titleClass, titleClass)}
|
||||
style={titleStyle}
|
||||
>
|
||||
{title}
|
||||
{#if tooltip != '' || documentationLink}
|
||||
<Tooltip {documentationLink}>{tooltip}</Tooltip>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if children && !isCollapsed}
|
||||
<div transition:slide|local={{ duration: 200 }} class="mt-2">
|
||||
<div
|
||||
class={twMerge('text-xs', classes[type].descriptionClass, descriptionClass)}
|
||||
style={descriptionStyle}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</span>
|
||||
{#if collapsible}
|
||||
<button class="cursor-pointer" onclick={toggleCollapse}>
|
||||
{#if isCollapsed}
|
||||
<ChevronDown size={16} />
|
||||
{:else}
|
||||
<ChevronUp size={16} />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if children && !collapsible}
|
||||
<div class="mb-2">
|
||||
<div
|
||||
class={twMerge('text-xs', classes[type].descriptionClass, descriptionClass)}
|
||||
style={descriptionStyle}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if children && (!collapsible || !isCollapsed)}
|
||||
<div
|
||||
transition:slide|local={{ duration: 200 }}
|
||||
class={twMerge(
|
||||
'text-xs',
|
||||
hasTitleRow ? 'mt-1' : '',
|
||||
classes[type].descriptionClass,
|
||||
descriptionClass
|
||||
)}
|
||||
style={descriptionStyle}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
* and clicks "outside" the child would otherwise propagate
|
||||
* here and close the underlying modal. */
|
||||
closeOnOutsideClick?: boolean
|
||||
/** Wider side padding and a lighter title, for a dialog whose body is a form rather
|
||||
* than a list. Opt-in: every other Modal2 keeps the padding and heading it had. */
|
||||
formStyling?: boolean
|
||||
headerLeft?: import('svelte').Snippet
|
||||
headerRight?: import('svelte').Snippet
|
||||
children?: import('svelte').Snippet
|
||||
@@ -43,6 +46,7 @@
|
||||
fixedHeight = 'md',
|
||||
contentClasses = '',
|
||||
closeOnOutsideClick = true,
|
||||
formStyling = false,
|
||||
headerLeft,
|
||||
headerRight,
|
||||
children
|
||||
@@ -91,7 +95,9 @@
|
||||
// Elevate above the AI chat panel (zIndexes.aiChat) while chat is open so
|
||||
// the dialog isn't hidden behind it; otherwise keep the default modal
|
||||
// stacking just above disposables (zIndexes.disposables).
|
||||
const overlayZIndex = $derived(chatState.size > 0 ? zIndexes.aiChat + 1 : zIndexes.disposables + 10)
|
||||
const overlayZIndex = $derived(
|
||||
chatState.size > 0 ? zIndexes.aiChat + 1 : zIndexes.disposables + 10
|
||||
)
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeyDown} />
|
||||
@@ -109,7 +115,8 @@
|
||||
heightMap[fixedHeight] ? `height: ${heightMap[fixedHeight]}; ` : ''
|
||||
}${css?.popup?.style || ''}`}
|
||||
class={twMerge(
|
||||
'max-h-screen-80 max-w-screen-80 rounded-lg relative bg-surface p-4',
|
||||
'max-h-screen-80 max-w-screen-80 rounded-lg relative bg-surface',
|
||||
formStyling ? 'py-4 px-6' : 'p-4',
|
||||
css?.popup?.class,
|
||||
'wm-modal-form-popup'
|
||||
)}
|
||||
@@ -120,7 +127,7 @@
|
||||
<List gap="md">
|
||||
<div class="flex w-full">
|
||||
<List horizontal justify="between">
|
||||
<h3>{title}</h3>
|
||||
<h3 class={formStyling ? 'font-semibold' : undefined}>{title}</h3>
|
||||
<div class="grow w-min-0">
|
||||
<List horizontal justify="between">
|
||||
<div class="min-w-0 grow">
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
interface Props {
|
||||
tabs: string[];
|
||||
selectedIndex?: number;
|
||||
maxReachedIndex?: number;
|
||||
statusByStep?: Array<'success' | 'error' | 'pending'>;
|
||||
hasValidations?: boolean;
|
||||
allowStepNavigation?: boolean;
|
||||
tabs: string[]
|
||||
selectedIndex?: number
|
||||
maxReachedIndex?: number
|
||||
statusByStep?: Array<'success' | 'error' | 'pending'>
|
||||
hasValidations?: boolean
|
||||
allowStepNavigation?: boolean
|
||||
/** Compact variant, for steering a dialog rather than a full page. */
|
||||
small?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -18,8 +20,9 @@
|
||||
maxReachedIndex = -1,
|
||||
statusByStep = [],
|
||||
hasValidations = false,
|
||||
allowStepNavigation = false
|
||||
}: Props = $props();
|
||||
allowStepNavigation = false,
|
||||
small = false
|
||||
}: Props = $props()
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
@@ -63,13 +66,20 @@
|
||||
</script>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<ol class="relative z-20 flex justify-between items-centers text-sm font-medium text-primary">
|
||||
<ol
|
||||
class={classNames(
|
||||
'relative z-20 flex justify-between items-centers font-medium text-primary',
|
||||
small ? 'text-xs' : 'text-sm'
|
||||
)}
|
||||
>
|
||||
{#each tabs ?? [] as step, index}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<li
|
||||
class={classNames(
|
||||
'flex items-center gap-2 px-2 py-1 hover:bg-gray-1200 rounded-md m-0.5',
|
||||
small
|
||||
? 'flex items-center gap-1.5 px-1.5 py-0.5 hover:bg-gray-1200 rounded-md m-0.5'
|
||||
: 'flex items-center gap-2 px-2 py-1 hover:bg-gray-1200 rounded-md m-0.5',
|
||||
index <= maxReachedIndex || allowStepNavigation ? 'cursor-pointer' : 'cursor-not-allowed'
|
||||
)}
|
||||
onclick={() => {
|
||||
@@ -77,11 +87,13 @@
|
||||
}}
|
||||
>
|
||||
{#if statusByStep[index] === 'pending'}
|
||||
<Loader2 class="h-6 w-6 animate-spin" />
|
||||
<Loader2 class={classNames(small ? 'h-4 w-4' : 'h-6 w-6', 'animate-spin')} />
|
||||
{:else}
|
||||
<span
|
||||
class={classNames(
|
||||
'h-6 w-6 rounded-full flex items-center justify-center text-xs',
|
||||
small
|
||||
? 'h-4 w-4 rounded-full flex items-center justify-center text-2xs'
|
||||
: 'h-6 w-6 rounded-full flex items-center justify-center text-xs',
|
||||
getStepColor(index, selectedIndex, statusByStep, maxReachedIndex)
|
||||
)}
|
||||
class:font-bold={selectedIndex === index}
|
||||
@@ -101,7 +113,7 @@
|
||||
</li>
|
||||
{#if index !== (tabs ?? []).length - 1}
|
||||
<li class="flex items-center">
|
||||
<div class="h-0.5 w-4 bg-blue-200"></div>
|
||||
<div class={classNames('h-0.5 bg-blue-200', small ? 'w-2' : 'w-4')}></div>
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
<Button
|
||||
color={genLoading ? 'red' : 'light'}
|
||||
size="xs"
|
||||
unifiedSize="md"
|
||||
nonCaptureEvent={!genLoading}
|
||||
startIcon={{ icon: Wand2 }}
|
||||
iconOnly
|
||||
|
||||
@@ -2,13 +2,28 @@
|
||||
interface Props {
|
||||
height?: string
|
||||
width?: string
|
||||
/** Accepting `size` is what makes this usable as a Button `startIcon`. */
|
||||
size?: number
|
||||
class?: string
|
||||
}
|
||||
|
||||
let { height = '24px', width = '24px' }: Props = $props()
|
||||
let {
|
||||
size,
|
||||
height = size ? `${size}px` : '24px',
|
||||
width = size ? `${size}px` : '24px',
|
||||
class: className
|
||||
}: Props = $props()
|
||||
</script>
|
||||
|
||||
<!-- #3ECF8E per supabase.com/brand-assets. Forbids modifying or recolouring the mark. -->
|
||||
<svg {width} {height} viewBox="0 0 168 168" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg
|
||||
{width}
|
||||
{height}
|
||||
class={className}
|
||||
viewBox="0 0 168 168"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M97.4434 164.242C93.2027 169.582 84.6042 166.656 84.502 159.837L83.0078 60.1013H150.07C162.217 60.1013 168.992 74.1309 161.439 83.644L97.4434 164.242Z"
|
||||
fill="url(#paint0_linear_210_201)"
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
<script module lang="ts">
|
||||
export function firstEmptyStepIsError<Step extends { status?: LoggedWizardStatus }>(
|
||||
steps: Step[],
|
||||
error: string | undefined
|
||||
): (Step & { status: LoggedWizardStatus })[] {
|
||||
let convertedSteps = [...steps]
|
||||
let alreadyFoundEmpty = false
|
||||
for (let step of convertedSteps) {
|
||||
if (!step.status) {
|
||||
if (!alreadyFoundEmpty) {
|
||||
alreadyFoundEmpty = true
|
||||
step.status = error !== undefined ? 'FAIL' : 'SKIP'
|
||||
} else {
|
||||
step.status = 'SKIP'
|
||||
}
|
||||
}
|
||||
}
|
||||
return convertedSteps as any
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { LoggedWizardStatus } from '$lib/gen'
|
||||
import { CircleCheck, Circle, CircleX, ChevronDown } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import ResizeTransitionWrapper from '../common/ResizeTransitionWrapper.svelte'
|
||||
|
||||
type Props = {
|
||||
steps: { title?: string; status: LoggedWizardStatus; description?: string }[]
|
||||
class?: string
|
||||
}
|
||||
|
||||
let { steps, class: className = '' }: Props = $props()
|
||||
|
||||
let openedDescriptions: Record<number, true> = $state({})
|
||||
|
||||
$effect(() => {
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
let step = steps[i]
|
||||
if (step.status == 'FAIL') {
|
||||
openedDescriptions[i] = true
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class={twMerge('flex flex-col gap-2', className)}>
|
||||
{#each steps as step, i}
|
||||
{@const descriptionOpened = openedDescriptions[i] ?? false}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="flex flex-col bg-surface rounded-md py-1.5 pr-2 cursor-pointer"
|
||||
role=""
|
||||
onclick={() => {
|
||||
if (step.description) {
|
||||
if (descriptionOpened) delete openedDescriptions[i]
|
||||
else openedDescriptions[i] = true
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="flex gap-3">
|
||||
<span class="inline-flex w-5 h-10 shrink-0 justify-center items-center">
|
||||
{#if step.status == 'SKIP'}
|
||||
<Circle size={20} class="inline text-hint/50" />
|
||||
{:else if step.status == 'FAIL'}
|
||||
<CircleX size={20} class="inline text-red-500" />
|
||||
{:else if step.status == 'OK'}
|
||||
<CircleCheck size={20} class="inline text-green-500" />
|
||||
{/if}
|
||||
</span>
|
||||
<div class="flex-1 my-2">
|
||||
<span
|
||||
class={twMerge(
|
||||
'font-medium flex justify-between items-center',
|
||||
{
|
||||
SKIP: 'text-hint/75',
|
||||
FAIL: 'text-red-400',
|
||||
OK: 'text-green-600 dark:text-green-400'
|
||||
}[step.status]
|
||||
)}
|
||||
>
|
||||
{i + 1}. {step.title}
|
||||
{#if step.description}
|
||||
<ChevronDown
|
||||
class={twMerge(
|
||||
'text-hint transition-transform',
|
||||
descriptionOpened ? 'rotate-180' : ''
|
||||
)}
|
||||
size={16}
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
<ResizeTransitionWrapper vertical class="text-xs text-secondary">
|
||||
{#if descriptionOpened}
|
||||
<div
|
||||
class="whitespace-pre-wrap cursor-default mt-1.5"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{step.description}
|
||||
</div>
|
||||
{/if}
|
||||
</ResizeTransitionWrapper>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script lang="ts" module>
|
||||
export type SetupStepStatus = 'pending' | 'running' | 'done' | 'failed' | 'skipped'
|
||||
|
||||
export type SetupStep = {
|
||||
title: string
|
||||
status: SetupStepStatus
|
||||
/** Shown when the row is expanded, and opened automatically when the step fails. */
|
||||
description?: string
|
||||
/** The checks this step is made of, when the caller knows them. Always visible: they
|
||||
* are the step's progress, not detail to go looking for. */
|
||||
substeps?: SetupStep[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A backend that only reports once it is done leaves every step blank while it works.
|
||||
* Drive the list off that: the first unreported step is the one in flight.
|
||||
*/
|
||||
export function runningFrom(steps: SetupStep[], running: boolean): SetupStep[] {
|
||||
if (!running) return steps
|
||||
const next = steps.findIndex((s) => s.status === 'pending')
|
||||
return steps.map((s, i) => (i === next ? { ...s, status: 'running' } : s))
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { Circle, CircleCheck, CircleX, ChevronDown, Loader2 } from 'lucide-svelte'
|
||||
import Self from './SetupChecklist.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import ResizeTransitionWrapper from '../common/ResizeTransitionWrapper.svelte'
|
||||
|
||||
type Props = {
|
||||
steps: SetupStep[]
|
||||
class?: string
|
||||
}
|
||||
|
||||
let { steps, class: className = '' }: Props = $props()
|
||||
|
||||
/**
|
||||
* Only the steps the user has actually toggled. A failed step opens itself, so recording
|
||||
* the open state instead would need something to force it open on every update -- and
|
||||
* every progress update would then reopen a description the user had just closed.
|
||||
*/
|
||||
let userToggled: Record<number, boolean> = $state({})
|
||||
|
||||
const descriptionOpen = (i: number, status: SetupStepStatus) =>
|
||||
userToggled[i] ?? status === 'failed'
|
||||
|
||||
function toggleDescription(i: number, status: SetupStepStatus) {
|
||||
userToggled[i] = !descriptionOpen(i, status)
|
||||
}
|
||||
|
||||
const titleRowClass = (status: SetupStepStatus) =>
|
||||
twMerge('text-xs font-medium flex justify-between items-center', titleClass[status])
|
||||
|
||||
const titleClass: Record<SetupStepStatus, string> = {
|
||||
pending: 'text-hint/75',
|
||||
running: 'text-primary',
|
||||
done: 'text-green-600 dark:text-green-400',
|
||||
failed: 'text-red-400',
|
||||
skipped: 'text-hint/75'
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={twMerge('flex flex-col gap-0.5', className)}>
|
||||
{#each steps as step, i}
|
||||
{@const descriptionOpened = descriptionOpen(i, step.status)}
|
||||
<div class="flex flex-col bg-surface rounded-md py-1 pr-2">
|
||||
<div class="flex gap-2">
|
||||
<span class="inline-flex w-4 h-5 shrink-0 justify-center items-center">
|
||||
{#if step.status === 'running'}
|
||||
<Loader2 size={16} class="inline animate-spin text-blue-500" />
|
||||
{:else if step.status === 'done'}
|
||||
<CircleCheck size={16} class="inline text-green-500" />
|
||||
{:else if step.status === 'failed'}
|
||||
<CircleX size={16} class="inline text-red-500" />
|
||||
{:else}
|
||||
<Circle size={16} class="inline text-hint/50" />
|
||||
{/if}
|
||||
</span>
|
||||
<div class="flex-1 my-0.5">
|
||||
<!-- The title is the whole interactive surface, so a step without a description
|
||||
stays inert rather than offering a focus stop that does nothing. -->
|
||||
{#if step.description}
|
||||
<button
|
||||
type="button"
|
||||
class={twMerge(titleRowClass(step.status), 'w-full text-left cursor-pointer')}
|
||||
onclick={() => toggleDescription(i, step.status)}
|
||||
>
|
||||
{step.title}
|
||||
<ChevronDown
|
||||
class={twMerge(
|
||||
'text-hint transition-transform',
|
||||
descriptionOpened ? 'rotate-180' : ''
|
||||
)}
|
||||
size={14}
|
||||
/>
|
||||
</button>
|
||||
{:else}
|
||||
<span class={titleRowClass(step.status)}>{step.title}</span>
|
||||
{/if}
|
||||
<ResizeTransitionWrapper vertical class="text-2xs text-secondary">
|
||||
{#if descriptionOpened}
|
||||
<div class="whitespace-pre-wrap mt-1.5">
|
||||
{step.description}
|
||||
</div>
|
||||
{/if}
|
||||
</ResizeTransitionWrapper>
|
||||
</div>
|
||||
</div>
|
||||
{#if step.substeps?.length}
|
||||
<div class="ml-6">
|
||||
<Self steps={step.substeps} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@
|
||||
import { slide } from 'svelte/transition'
|
||||
import Modal2 from '../common/modal/Modal2.svelte'
|
||||
import Alert from '../common/alert/Alert.svelte'
|
||||
import LoggedWizardResult, { firstEmptyStepIsError } from '../wizards/LoggedWizardResult.svelte'
|
||||
import SetupChecklist from '../wizards/SetupChecklist.svelte'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { isCustomInstanceDbEnabled } from './utils.svelte'
|
||||
@@ -20,6 +20,7 @@
|
||||
import { truncate } from '$lib/utils'
|
||||
import Tooltip from '../meltComponents/Tooltip.svelte'
|
||||
import { superadmin } from '$lib/stores'
|
||||
import { instanceSetupSteps } from './instanceDbSteps'
|
||||
|
||||
type Props = {
|
||||
customInstanceDbs: ResourceReturn<ListCustomInstanceDbsResponse>
|
||||
@@ -45,6 +46,7 @@
|
||||
<Modal2
|
||||
bind:isOpen={() => !!opened, (v) => !v && !preventClose && (opened = undefined)}
|
||||
target="#content"
|
||||
formStyling
|
||||
title={'Custom Instance Database Setup'}
|
||||
contentClasses="flex flex-col"
|
||||
fixedWidth="md"
|
||||
@@ -59,7 +61,7 @@
|
||||
<div class="basis-2/5 grow-0 shrink-0 flex flex-col">
|
||||
<div class="flex-1 flex flex-col">
|
||||
<span class="text-sm font-bold mb-2 overflow break-all">{dbname}</span>
|
||||
<span class="text-sm">
|
||||
<span class="text-xs font-normal text-secondary">
|
||||
Custom instance databases are databases created in the Windmill PostgreSQL instance.
|
||||
Their credentials are automatically managed by Windmill and are never exposed to users.
|
||||
Only super admins can create them.
|
||||
@@ -127,68 +129,8 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<LoggedWizardResult
|
||||
steps={firstEmptyStepIsError(
|
||||
[
|
||||
{
|
||||
title: 'Super admin required',
|
||||
status: status?.logs.super_admin,
|
||||
description:
|
||||
'You need to be a super admin to create a new database in the Windmill PostgreSQL instance'
|
||||
},
|
||||
{
|
||||
title: 'Retrieve and parse database credentials',
|
||||
status: status?.logs.database_credentials,
|
||||
description:
|
||||
'Windmill uses the DATABASE_URL or DATABASE_URL_FILE environment variable to connect to the PostgreSQL instance. Make sure it is correctly set'
|
||||
},
|
||||
{
|
||||
title: 'Database name is valid',
|
||||
status: status?.logs.valid_dbname,
|
||||
description:
|
||||
'The database name must be alphanumeric (underscores and hyphens allowed) and cannot be named the same as the Windmill database (usually "windmill")'
|
||||
},
|
||||
{
|
||||
title:
|
||||
'Create database' +
|
||||
(status?.logs.created_database === 'SKIP' ? ' (already exists, skipped)' : ''),
|
||||
status: status?.logs.created_database,
|
||||
description: `In the Windmill PostgreSQL instance, run: CREATE DATABASE "${dbname}".`
|
||||
},
|
||||
{
|
||||
title: `Connect to the ${dbname} database`,
|
||||
status: status?.logs.db_connect,
|
||||
description:
|
||||
"Connect to the newly created database with the default admin user (the one in DATABASE_URL, usually 'postgres') to run the next commands"
|
||||
},
|
||||
{
|
||||
title: 'Grant permissions to custom_instance_user',
|
||||
status: status?.logs.grant_permissions,
|
||||
description:
|
||||
'Gives custom_instance_user the required permissions to use the database. custom_instance_user is already created during a migration and has an auto-generated password stored in global_settings.custom_instance_pg_databases.user_pwd. These are the commands : \n\n' +
|
||||
`GRANT CONNECT ON DATABASE "${dbname}" TO custom_instance_user;\n` +
|
||||
'GRANT USAGE ON SCHEMA public TO custom_instance_user;\n' +
|
||||
'GRANT CREATE ON SCHEMA public TO custom_instance_user;\n' +
|
||||
`GRANT CREATE ON DATABASE "${dbname}" TO custom_instance_user;\n` +
|
||||
'ALTER DEFAULT PRIVILEGES IN SCHEMA public \n' +
|
||||
' GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES\n TO custom_instance_user;\n' +
|
||||
'ALTER ROLE custom_instance_user CREATEROLE;'
|
||||
},
|
||||
{
|
||||
title: 'Grant replication to custom_instance_replication_user',
|
||||
status: status?.logs.replication_user,
|
||||
description:
|
||||
'Postgres triggers on custom-instance datatables connect as custom_instance_replication_user, whose password is stored in global_settings.custom_instance_replication_pwd. The role is cluster-wide, so it is created on the Windmill PostgreSQL instance rather than on this database : \n\n' +
|
||||
'ALTER ROLE custom_instance_replication_user REPLICATION;\n' +
|
||||
'GRANT custom_instance_user TO custom_instance_replication_user;\n\n' +
|
||||
'Setting REPLICATION requires a superuser on PostgreSQL 15 and older. Managed instances never grant one, so on AWS RDS Windmill falls back to GRANT rds_replication TO custom_instance_replication_user. The database stays usable for datatables if this step fails, but postgres triggers on them do not.' +
|
||||
(status?.logs.replication_user_error
|
||||
? `\n\nError: ${status.logs.replication_user_error}`
|
||||
: '')
|
||||
}
|
||||
],
|
||||
status?.error ?? undefined
|
||||
)}
|
||||
<SetupChecklist
|
||||
steps={instanceSetupSteps(dbname, status, customInstanceDbSetupIsRunning)}
|
||||
/>
|
||||
</div>
|
||||
{#if $superadmin}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<script lang="ts">
|
||||
import Alert from '../common/alert/Alert.svelte'
|
||||
import type { TestDataTableConnectionResponse } from '$lib/gen'
|
||||
|
||||
type Props = {
|
||||
/** What the report is about: a data table, a Supabase project, a database name. */
|
||||
name: string
|
||||
report?: TestDataTableConnectionResponse | undefined
|
||||
error?: string | undefined
|
||||
bgClass?: string
|
||||
class?: string
|
||||
}
|
||||
|
||||
let { name, report, error, bgClass, class: className }: Props = $props()
|
||||
|
||||
let fullyPrivileged = $derived(!!report?.can_create_table && !!report?.can_create_schema)
|
||||
</script>
|
||||
|
||||
{#if error}
|
||||
<Alert type="error" title="Could not connect to {name}" size="xs" {bgClass} class={className}>
|
||||
{error}
|
||||
</Alert>
|
||||
{:else if report}
|
||||
<Alert
|
||||
type={fullyPrivileged ? 'success' : 'warning'}
|
||||
title={fullyPrivileged
|
||||
? `${name} is reachable and its user can create tables and schemas`
|
||||
: `${name} is reachable but its user is missing privileges`}
|
||||
size="xs"
|
||||
{bgClass}
|
||||
class={className}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div>
|
||||
Connects as <span class="font-mono">{report.user}</span>{#if report.schema}, resolving
|
||||
unqualified statements to schema <span class="font-mono">{report.schema}</span>{/if}.
|
||||
</div>
|
||||
{#if report.suggested_search_path}
|
||||
<div>
|
||||
Its search_path resolves to no schema, so unqualified statements fail with
|
||||
<span class="font-mono">no schema has been selected to create in</span> whatever
|
||||
privileges the role holds. Point it at one, e.g.
|
||||
<span class="font-mono select-all">{report.suggested_search_path}</span>.
|
||||
</div>
|
||||
{/if}
|
||||
<ul class="list-disc list-inside">
|
||||
<li>
|
||||
Create tables{report.schema ? ` in ${report.schema}` : ''}:
|
||||
<span class="font-semibold">{report.can_create_table ? 'yes' : 'no'}</span>
|
||||
</li>
|
||||
<li>
|
||||
Create schemas:
|
||||
<span class="font-semibold">{report.can_create_schema ? 'yes' : 'no'}</span>
|
||||
</li>
|
||||
<li>
|
||||
Migration bookkeeping table exists:
|
||||
<span class="font-semibold">{report.migrations_table_exists ? 'yes' : 'no'}</span>
|
||||
</li>
|
||||
</ul>
|
||||
{#if report.suggested_grants.length > 0}
|
||||
<div>
|
||||
Windmill connects as the role that lacks these privileges, so it cannot grant them itself.
|
||||
Run as a schema owner or superuser on that database:
|
||||
</div>
|
||||
<pre class="whitespace-pre-wrap select-all text-xs"
|
||||
>{report.suggested_grants.map((g) => `${g};`).join('\n')}</pre
|
||||
>
|
||||
{#if report.schema && !report.can_create_table && !report.migrations_table_exists}
|
||||
<div>
|
||||
Alternatively, create the <span class="font-mono">_wm_migrations</span> bookkeeping table
|
||||
yourself and grant only SELECT, INSERT, UPDATE, DELETE on it.
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</Alert>
|
||||
{/if}
|
||||
@@ -66,7 +66,11 @@
|
||||
import Row from '../table/Row.svelte'
|
||||
import TextInput from '../text_input/TextInput.svelte'
|
||||
import Tooltip from '../Tooltip.svelte'
|
||||
import { isCustomInstanceDbEnabled, getUnusedInstanceDbName } from './utils.svelte'
|
||||
import {
|
||||
isCustomInstanceDbEnabled,
|
||||
getUnusedInstanceDbName,
|
||||
isDataTableWizardEnabled
|
||||
} from './utils.svelte'
|
||||
import { random_adj } from '../random_positive_adjetive'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import {
|
||||
@@ -89,6 +93,10 @@
|
||||
import Alert from '../common/alert/Alert.svelte'
|
||||
import MissingWorkerTagAlert from '../jobs/MissingWorkerTagAlert.svelte'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import AddDataTableWizard from './AddDataTableWizard.svelte'
|
||||
import { takeParkedWizard, type WizardResume } from './wizardParking'
|
||||
import { Database } from 'lucide-svelte'
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
type Props = {
|
||||
dataTableSettings: DataTableSettingsType
|
||||
@@ -156,6 +164,8 @@
|
||||
return getUnusedInstanceDbName('dt', $workspaceStore ?? '', usedNames)
|
||||
}
|
||||
|
||||
// Kept for the flag-off path: adding a data table is a row in this table that the user
|
||||
// fills in and saves, rather than a wizard.
|
||||
function onNewDataTable() {
|
||||
const name = tempSettings.dataTables.some((d) => d.name === 'main')
|
||||
? `${random_adj()}_datatable`
|
||||
@@ -211,6 +221,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
const wizardEnabled = isDataTableWizardEnabled()
|
||||
let wizardOpen = $state(false)
|
||||
/** Opened through the wizard's own `open()`, which is what sets a fresh run up. */
|
||||
let wizard: { open: (parked?: WizardResume) => void } | undefined = $state(undefined)
|
||||
let wizardResume: WizardResume | undefined = $state(undefined)
|
||||
|
||||
// Supabase sends the user back here after authorizing; pick the wizard back up where it
|
||||
// was rather than making them start again.
|
||||
onMount(() => {
|
||||
if (!wizardEnabled) return
|
||||
const parked = takeParkedWizard()
|
||||
if (parked) {
|
||||
wizardResume = parked
|
||||
// Handed in, not left to the `resume` prop: the wizard rebuilds the run synchronously
|
||||
// inside this call, and a parked run that arrived late would come back as a fresh one.
|
||||
wizard?.open(parked)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* The wizard persists what it creates, so the server is authoritative afterwards and the
|
||||
* whole baseline comes from it. `tempSettings` derives from that baseline, so this discards
|
||||
* uncommitted edits in the table -- which is why the wizard cannot be opened while there
|
||||
* are any (see the disabled entry points below).
|
||||
*/
|
||||
async function reloadAfterWizard() {
|
||||
const s = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
|
||||
dataTableSettings = convertDataTableSettingsFromBackend(s.datatable)
|
||||
wizardResume = undefined
|
||||
}
|
||||
|
||||
let confirmationModal = createAsyncConfirmationModal()
|
||||
let dirtyMap = $derived.by(() => {
|
||||
const map: Record<string, boolean> = {}
|
||||
@@ -241,7 +282,7 @@
|
||||
|
||||
<SettingsPageHeader
|
||||
title="Data tables"
|
||||
description="Store relational data out of the box. Interact with a fully managed PostgreSQL database directly from the Windmill SDK."
|
||||
description="Relational storage the whole workspace shares under one name. Scripts, flows and apps address it as <span class='font-mono'>datatable://main</span> instead of picking a PostgreSQL resource, so nobody needs access to the credentials to query it, and you can point that name at another database without touching a line of code. Browse and edit tables, and version schema changes as migrations, from here."
|
||||
link="https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables"
|
||||
/>
|
||||
|
||||
@@ -273,9 +314,37 @@
|
||||
<tbody class="divide-y bg-surface-tertiary">
|
||||
{#if tempSettings.dataTables.length == 0}
|
||||
<Row>
|
||||
<Cell colspan={tableHeadNames.length} class="text-center py-6">
|
||||
No data table in this workspace yet
|
||||
</Cell>
|
||||
{#if wizardEnabled}
|
||||
<Cell colspan={tableHeadNames.length} class="py-8">
|
||||
<div class="flex flex-col items-center gap-3 text-center">
|
||||
<Database size={24} class="text-secondary" />
|
||||
<div class="flex flex-col gap-1 items-center">
|
||||
<span class="font-semibold text-sm">No data table yet</span>
|
||||
<p class="text-xs text-secondary max-w-sm">
|
||||
Give your scripts a database to store and query data.
|
||||
{#if isCloudHosted()}
|
||||
Set one up free in about a minute.
|
||||
{:else}
|
||||
Use the Windmill database, or bring your own.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="accent"
|
||||
disabled={hasUnsavedChanges}
|
||||
title={hasUnsavedChanges ? 'Save or discard your changes first' : undefined}
|
||||
on:click={() => wizard?.open()}
|
||||
>
|
||||
Add a data table
|
||||
</Button>
|
||||
</div>
|
||||
</Cell>
|
||||
{:else}
|
||||
<Cell colspan={tableHeadNames.length} class="text-center py-6">
|
||||
No data table in this workspace yet
|
||||
</Cell>
|
||||
{/if}
|
||||
</Row>
|
||||
{/if}
|
||||
{#each tempSettings.dataTables as dataTable, dataTableIndex (dataTable.id)}
|
||||
@@ -383,15 +452,27 @@
|
||||
</Cell>
|
||||
</Row>
|
||||
{/each}
|
||||
<Row class="!border-0">
|
||||
<Cell colspan={tableHeadNames.length} class="pt-0 pb-2">
|
||||
<div class="flex justify-center">
|
||||
<Button size="sm" btnClasses="max-w-fit" variant="default" on:click={onNewDataTable}>
|
||||
<Plus /> New Data Table
|
||||
</Button>
|
||||
</div>
|
||||
</Cell>
|
||||
</Row>
|
||||
{#if !wizardEnabled || tempSettings.dataTables.length > 0}
|
||||
<Row class="!border-0">
|
||||
<Cell colspan={tableHeadNames.length} class="pt-0 pb-2">
|
||||
<div class="flex justify-center">
|
||||
<Button
|
||||
size="sm"
|
||||
btnClasses="max-w-fit"
|
||||
variant="default"
|
||||
disabled={wizardEnabled && hasUnsavedChanges}
|
||||
title={wizardEnabled && hasUnsavedChanges
|
||||
? 'Save or discard your changes first'
|
||||
: undefined}
|
||||
on:click={() => (wizardEnabled ? wizard?.open() : onNewDataTable())}
|
||||
>
|
||||
<Plus />
|
||||
{wizardEnabled ? 'Add a data table' : 'New Data Table'}
|
||||
</Button>
|
||||
</div>
|
||||
</Cell>
|
||||
</Row>
|
||||
{/if}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
|
||||
@@ -467,3 +548,28 @@
|
||||
/>
|
||||
|
||||
<ConfirmationModal {...confirmationModal.props} />
|
||||
|
||||
{#if wizardEnabled}
|
||||
<AddDataTableWizard
|
||||
bind:this={wizard}
|
||||
bind:opened={
|
||||
() => wizardOpen,
|
||||
(v) => {
|
||||
wizardOpen = v
|
||||
// Drop the parked run once the wizard closes: leaving it set would force the next
|
||||
// open straight back to the Supabase setup step.
|
||||
if (!v) wizardResume = undefined
|
||||
}
|
||||
}
|
||||
existingNames={tempSettings.dataTables.map((d) => d.name)}
|
||||
existingDataTables={tempSettings.dataTables.map((d) => ({
|
||||
name: d.name,
|
||||
resourcePath: d.database.resource_path
|
||||
}))}
|
||||
resume={wizardResume}
|
||||
onDone={reloadAfterWizard}
|
||||
{customInstanceDbs}
|
||||
{confirmationModal}
|
||||
{defaultInstanceDbName}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import { ChevronRight } from 'lucide-svelte'
|
||||
import type { SupabaseConnectionMode } from './supabaseProvisioning'
|
||||
|
||||
type Props = {
|
||||
mode: SupabaseConnectionMode
|
||||
onChange?: () => void
|
||||
}
|
||||
|
||||
let { mode = $bindable(), onChange }: Props = $props()
|
||||
|
||||
let open = $state(false)
|
||||
|
||||
function set(v: SupabaseConnectionMode) {
|
||||
if (v === mode) return
|
||||
mode = v
|
||||
onChange?.()
|
||||
}
|
||||
|
||||
const OPTIONS: { value: SupabaseConnectionMode; title: string; detail: string }[] = [
|
||||
{
|
||||
value: 'session',
|
||||
title: 'Session pooler',
|
||||
detail: 'Reaches Supabase over IPv4. Works from any worker.'
|
||||
},
|
||||
{
|
||||
value: 'direct',
|
||||
title: 'Direct connection',
|
||||
detail:
|
||||
'IPv6 only, unless the project has the IPv4 add-on. Workers on IPv4-only networks cannot reach it.'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<div class="border-t border-border-light pt-2">
|
||||
<button
|
||||
class="flex items-center gap-1 text-2xs text-secondary hover:text-primary"
|
||||
onclick={() => (open = !open)}
|
||||
>
|
||||
<ChevronRight size={12} class="transition-transform {open ? 'rotate-90' : ''}" />
|
||||
Connection mode: {mode === 'session' ? 'session pooler' : 'direct'}
|
||||
</button>
|
||||
{#if open}
|
||||
<div class="flex flex-col gap-1.5 mt-2">
|
||||
<!-- Not `RadioCard`: no radio dot, and selection reads through the accent surface. -->
|
||||
{#each OPTIONS as option (option.value)}
|
||||
{@const selected = mode === option.value}
|
||||
<button
|
||||
class="text-left border rounded-md p-2 transition-colors {selected
|
||||
? 'border-border-selected/50 bg-surface-accent-selected'
|
||||
: 'border-border-light hover:bg-surface-hover'}"
|
||||
onclick={() => set(option.value)}
|
||||
>
|
||||
<span class="text-xs font-medium {selected ? 'text-accent' : 'text-emphasis'}">
|
||||
{option.title}{option.value === 'session' ? ' · recommended' : ''}
|
||||
</span>
|
||||
<span class="block text-2xs text-secondary">{option.detail}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,290 @@
|
||||
<script lang="ts">
|
||||
import Alert from '../common/alert/Alert.svelte'
|
||||
import TextInput from '../text_input/TextInput.svelte'
|
||||
import Password from '../Password.svelte'
|
||||
import Select from '../select/Select.svelte'
|
||||
import { Database, Loader2, Plus } from 'lucide-svelte'
|
||||
import { tick } from 'svelte'
|
||||
import { resource } from 'runed'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import SupabaseConnectionMode from './SupabaseConnectionMode.svelte'
|
||||
import type { WizardState } from './addDataTableModel'
|
||||
import {
|
||||
getSupabaseOrgPlan,
|
||||
listSupabaseOrgs,
|
||||
listSupabaseProjects,
|
||||
orgSlug,
|
||||
projectOrg,
|
||||
projectRef,
|
||||
SUPABASE_REGIONS,
|
||||
type SupabaseOrg,
|
||||
type SupabaseProject
|
||||
} from './supabaseProvisioning'
|
||||
|
||||
type Props = {
|
||||
/** The wizard's Supabase slice. Collected only -- nothing here creates anything. */
|
||||
intent: WizardState['supabase']
|
||||
token: string
|
||||
/** Hides the create tab where provisioning a billed project is not on offer. */
|
||||
existingOnly?: boolean
|
||||
/** Fired whenever the choice changes, so the host can drop what it derived from it. */
|
||||
onIntentChange?: () => void
|
||||
}
|
||||
|
||||
let { intent = $bindable(), token, existingOnly = false, onIntentChange }: Props = $props()
|
||||
|
||||
let orgs: SupabaseOrg[] | undefined = $state(undefined)
|
||||
let projects: SupabaseProject[] | undefined = $state(undefined)
|
||||
let plans: Record<string, string> = $state({})
|
||||
|
||||
const listings = resource(
|
||||
() => token,
|
||||
async (t) => {
|
||||
if (!t) return
|
||||
try {
|
||||
orgs = await listSupabaseOrgs(t)
|
||||
projects = await listSupabaseProjects(t)
|
||||
// Someone who already has a Supabase database almost always means to connect it
|
||||
// rather than make a second one, so the step opens on the first of them. Decided
|
||||
// before anything renders, so no card visibly selects itself under the user.
|
||||
// Only seeds a choice that has not been made: this step is unmounted whenever the
|
||||
// wizard moves off it, so a user who picked "New project" and pressed Back would
|
||||
// otherwise come back to the first existing project instead.
|
||||
if (!intent.project && intent.mode !== 'create') {
|
||||
if (projects?.length) intent.project = projects[0]
|
||||
else if (!existingOnly) intent.mode = 'create'
|
||||
}
|
||||
// Seeded from the project, the way picking one does. Chosen independently, the
|
||||
// review step names whichever organization happens to be first while the database
|
||||
// under it belongs to another. A lookup that misses leaves it unset rather than
|
||||
// falling back to the first: `supabaseSummary` then shows the project's own
|
||||
// organization by identifier, which is right where a name would be wrong.
|
||||
const seeded = intent.project
|
||||
if (!intent.org) {
|
||||
intent.org = seeded
|
||||
? (orgs ?? []).find((o) => orgSlug(o) === projectOrg(seeded))
|
||||
: orgs?.[0]
|
||||
}
|
||||
// The plan decides who gets billed, and the list endpoint does not carry it.
|
||||
for (const o of orgs ?? []) {
|
||||
getSupabaseOrgPlan(t, orgSlug(o)).then((p) => {
|
||||
if (p) plans[orgSlug(o)] = p
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
sendUserToast(String(err), true)
|
||||
orgs = orgs ?? []
|
||||
}
|
||||
}
|
||||
)
|
||||
// Nothing but a spinner until *both* lists are in. Which mode to open on depends on the
|
||||
// projects, so clearing this when only the orgs have landed is what makes the toggle flip
|
||||
// under the user a moment later -- so it tracks the whole fetch, not each call in it.
|
||||
let loading = $derived(listings.loading)
|
||||
|
||||
/** Supabase statuses are SCREAMING_SNAKE; only surface one that is not the happy path. */
|
||||
function projectStatus(p: SupabaseProject): string | undefined {
|
||||
if (!p.status || p.status === 'ACTIVE_HEALTHY') return undefined
|
||||
return p.status === 'INACTIVE' ? 'paused' : p.status.toLowerCase().replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
// Exclusive with the project cards, and each owns what it produced: a picked project, and
|
||||
// whatever the host derived from it, must not survive into the project about to exist.
|
||||
function selectNewProject() {
|
||||
if (intent.mode === 'create') return
|
||||
intent.mode = 'create'
|
||||
intent.project = undefined
|
||||
intent.password = ''
|
||||
onIntentChange?.()
|
||||
}
|
||||
|
||||
/** Takes the picked project as a parameter: read directly off the prop inside a `$derived`,
|
||||
* the checker narrows its optional type to `never`. */
|
||||
function isSelected(picked: SupabaseProject | undefined, p: SupabaseProject): boolean {
|
||||
return !!picked && projectRef(picked) === projectRef(p)
|
||||
}
|
||||
|
||||
// The password field lives inside the card it belongs to, so it is scoped to one project:
|
||||
// carrying a value over to another card would show it already filled in. Selecting the last
|
||||
// card in a long list also grows it past the fold, hence the scroll once it has resized.
|
||||
async function selectProject(p: SupabaseProject, card: HTMLElement | null) {
|
||||
if (!isSelected(intent.project, p)) intent.password = ''
|
||||
intent.mode = 'existing'
|
||||
intent.project = p
|
||||
// So the review step can name the organization rather than print the project's slug.
|
||||
intent.org = (orgs ?? []).find((o) => orgSlug(o) === projectOrg(p)) ?? intent.org
|
||||
onIntentChange?.()
|
||||
await tick()
|
||||
card?.scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
|
||||
/** Built from parameters rather than read off the surrounding `$state(undefined)`, which a
|
||||
* `$derived` in the same scope narrows to `never`. */
|
||||
function orgOptions(
|
||||
all: SupabaseOrg[] | undefined,
|
||||
projs: SupabaseProject[] | undefined,
|
||||
plansBySlug: Record<string, string>
|
||||
) {
|
||||
return (all ?? []).map((o) => {
|
||||
const slug = orgSlug(o)
|
||||
const count = (projs ?? []).filter((p) => projectOrg(p) === slug).length
|
||||
return {
|
||||
label: o.name,
|
||||
value: slug,
|
||||
subtitle: [plansBySlug[slug], `${count} project${count === 1 ? '' : 's'}`]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let orgItems = $derived(orgOptions(orgs, projects, plans))
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center gap-2 text-xs text-secondary py-2">
|
||||
<Loader2 size={16} class="animate-spin" />
|
||||
Loading your Supabase projects...
|
||||
</div>
|
||||
{:else if (projects ?? []).length === 0 && existingOnly}
|
||||
<Alert type="info" size="xs" bgClass="border-0" title="">
|
||||
This Supabase account has no projects yet.
|
||||
</Alert>
|
||||
{:else}
|
||||
{#if (projects ?? []).length}
|
||||
<span class="text-xs font-semibold text-emphasis">Projects in your Supabase account</span>
|
||||
{:else}
|
||||
<p class="text-xs text-secondary">This Supabase account has no projects yet.</p>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-2 overflow-y-auto flex-1 min-h-24 pr-1">
|
||||
{#each projects ?? [] as p (projectRef(p))}
|
||||
{@const selected = intent.mode === 'existing' && isSelected(intent.project, p)}
|
||||
<!-- Not `RadioCard`: the selected card opens to hold a password field, and these carry
|
||||
a project icon and no radio dot. shrink-0 or the flex column squeezes the cards to
|
||||
fit instead of letting the list scroll, and the selected one loses its password
|
||||
field to the clip. -->
|
||||
<div
|
||||
class="shrink-0 border rounded-md overflow-hidden transition-colors {selected
|
||||
? 'border-border-selected/50 bg-surface-accent-selected'
|
||||
: 'border-border-light'}"
|
||||
>
|
||||
<button
|
||||
class="w-full text-left p-3 flex gap-3 items-start {selected
|
||||
? ''
|
||||
: 'hover:bg-surface-hover'}"
|
||||
onclick={(e) => selectProject(p, e.currentTarget.parentElement)}
|
||||
>
|
||||
<span class="mt-0.5 shrink-0"><Database size={18} class="text-secondary" /></span>
|
||||
<span class="flex flex-col gap-0.5 min-w-0">
|
||||
<span class="text-xs font-medium {selected ? 'text-accent' : 'text-emphasis'}"
|
||||
>{p.name}</span
|
||||
>
|
||||
<span class="text-xs text-secondary font-normal">
|
||||
{p.region}{projectOrg(p) ? ` · ${projectOrg(p)}` : ''}{projectStatus(p)
|
||||
? ` · ${projectStatus(p)}`
|
||||
: ''}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{#if selected}
|
||||
<div class="px-3 pb-3 flex flex-col gap-2">
|
||||
<div>
|
||||
<span class="text-xs font-semibold text-emphasis">Database password</span>
|
||||
<Password
|
||||
bind:password={
|
||||
() => intent.password, (v) => ((intent.password = v ?? ''), onIntentChange?.())
|
||||
}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<p class="text-2xs text-secondary mt-1">
|
||||
Supabase only shows this when the project is created, and never exposes it through
|
||||
its API. If you no longer have it, <a
|
||||
href="https://supabase.com/dashboard/project/{projectRef(p)}/database/settings"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-accent hover:underline">set a new one</a
|
||||
> — every existing connection to this project stops working when you do.
|
||||
</p>
|
||||
</div>
|
||||
<SupabaseConnectionMode bind:mode={intent.connectionMode} onChange={onIntentChange} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if !existingOnly}
|
||||
<div
|
||||
class="shrink-0 border rounded-md overflow-hidden transition-colors {intent.mode ===
|
||||
'create'
|
||||
? 'border-border-selected/50 bg-surface-accent-selected'
|
||||
: 'border-border-light'}"
|
||||
>
|
||||
<button
|
||||
class="w-full text-left p-3 flex gap-3 items-start {intent.mode === 'create'
|
||||
? ''
|
||||
: 'hover:bg-surface-hover'}"
|
||||
onclick={selectNewProject}
|
||||
>
|
||||
<span class="mt-0.5 shrink-0"><Plus size={18} class="text-secondary" /></span>
|
||||
<span class="flex flex-col gap-0.5 min-w-0">
|
||||
<span
|
||||
class="text-xs font-medium {intent.mode === 'create'
|
||||
? 'text-accent'
|
||||
: 'text-emphasis'}">New project</span
|
||||
>
|
||||
<span class="text-xs text-secondary font-normal"
|
||||
>Windmill creates it and stores its password</span
|
||||
>
|
||||
</span>
|
||||
</button>
|
||||
{#if intent.mode === 'create'}
|
||||
<div class="px-3 pb-3">{@render newProjectFields()}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#snippet newProjectFields()}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<span class="text-xs font-semibold text-emphasis">Organization</span>
|
||||
<!-- The list is keyed by slug because that is what the API takes; the whole
|
||||
organization is kept so the review step can name it. -->
|
||||
<Select
|
||||
items={orgItems}
|
||||
bind:value={
|
||||
() => (intent.org ? orgSlug(intent.org) : undefined),
|
||||
(v) => ((intent.org = (orgs ?? []).find((o) => orgSlug(o) === v)), onIntentChange?.())
|
||||
}
|
||||
placeholder={orgs === undefined ? 'Loading...' : 'Select'}
|
||||
/>
|
||||
<p class="text-2xs text-secondary mt-1">
|
||||
The project is created here and billed to this organization.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs font-semibold text-emphasis">Region</span>
|
||||
<Select
|
||||
items={SUPABASE_REGIONS.map((r) => ({ label: r.label, value: r.code }))}
|
||||
bind:value={() => intent.region, (v) => ((intent.region = v), onIntentChange?.())}
|
||||
placeholder="Region"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs font-semibold text-emphasis">Project name</span>
|
||||
<TextInput
|
||||
bind:value={
|
||||
() => intent.projectName, (v) => ((intent.projectName = String(v)), onIntentChange?.())
|
||||
}
|
||||
inputProps={{ placeholder: 'windmill-data' }}
|
||||
/>
|
||||
</div>
|
||||
<Alert type="info" size="xs" bgClass="border-0" title="">
|
||||
Windmill generates and stores the database password. A new project takes a minute or two to
|
||||
come up.
|
||||
</Alert>
|
||||
<SupabaseConnectionMode bind:mode={intent.connectionMode} onChange={onIntentChange} />
|
||||
</div>
|
||||
{/snippet}
|
||||
@@ -0,0 +1,120 @@
|
||||
<script lang="ts">
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import Modal2 from '../common/modal/Modal2.svelte'
|
||||
import SupabaseIcon from '../icons/SupabaseIcon.svelte'
|
||||
import SupabaseProjectStep from './SupabaseProjectStep.svelte'
|
||||
import { newWizardState } from './addDataTableModel'
|
||||
import { resolveSupabaseConnection, supabaseResourceValue } from './supabaseProvisioning'
|
||||
import { useSupabaseOauth } from './supabaseOauth.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
|
||||
type Props = {
|
||||
/** The `postgresql` resource value for the project that was picked. */
|
||||
onPicked: (value: Record<string, any>) => void
|
||||
}
|
||||
|
||||
let { onPicked }: Props = $props()
|
||||
|
||||
let open = $state(false)
|
||||
let busy = $state(false)
|
||||
// Only the intent a resource form can act on. Creating a project is a billed action and
|
||||
// belongs in the data table wizard, which can show what it is provisioning and record the
|
||||
// result; a resource form has nowhere to put either.
|
||||
let intent = $state(newWizardState({ name: '', projectName: '', folder: '' }).supabase)
|
||||
let awaiting = $state(false)
|
||||
|
||||
// Authorizing is not something to present a dialog about first: the button goes straight
|
||||
// to the popup, and the dialog opens on the way back, already holding the projects.
|
||||
// No `redirectIfBlocked`: navigating this tab away would take the half-filled resource form
|
||||
// with it, and there is nothing here to park and resume.
|
||||
const oauth = useSupabaseOauth({
|
||||
onFallbackBlocked: () => {
|
||||
awaiting = false
|
||||
sendUserToast('Allow pop-ups for this site to connect your Supabase account.', true)
|
||||
},
|
||||
onAbandoned: () => (awaiting = false),
|
||||
// Guarded: an authorization started somewhere else on the page reaches this listener too,
|
||||
// and it must not open a dialog nobody asked for.
|
||||
onAuthed: () => {
|
||||
if (!awaiting) return
|
||||
awaiting = false
|
||||
open = true
|
||||
}
|
||||
})
|
||||
|
||||
function connect() {
|
||||
if (oauth.authed) {
|
||||
open = true
|
||||
return
|
||||
}
|
||||
awaiting = true
|
||||
oauth.connect()
|
||||
}
|
||||
|
||||
// The resource is being edited by the user rather than created for them, so the project's
|
||||
// password goes straight into the form as a value. They can link it to a secret variable
|
||||
// with the same affordance every other password field has.
|
||||
async function apply() {
|
||||
const project = intent.project
|
||||
if (!project || !intent.password) return
|
||||
busy = true
|
||||
try {
|
||||
const connection = await resolveSupabaseConnection(
|
||||
oauth.token!,
|
||||
project,
|
||||
intent.connectionMode
|
||||
)
|
||||
onPicked({ ...supabaseResourceValue(project, '', connection), password: intent.password })
|
||||
open = false
|
||||
sendUserToast(
|
||||
connection.unavailable
|
||||
? `Filled in a direct connection for ${project.name}: ${connection.unavailable}`
|
||||
: `Filled in the connection for ${project.name}`,
|
||||
!!connection.unavailable
|
||||
)
|
||||
} catch (err) {
|
||||
sendUserToast(String(err), true)
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="default"
|
||||
startIcon={{ icon: SupabaseIcon }}
|
||||
loading={awaiting}
|
||||
onClick={connect}
|
||||
>
|
||||
Connect Supabase
|
||||
</Button>
|
||||
|
||||
<Modal2
|
||||
bind:isOpen={open}
|
||||
target="#content"
|
||||
formStyling
|
||||
title="Connect Supabase"
|
||||
contentClasses="flex flex-col"
|
||||
fixedWidth="md"
|
||||
fixedHeight="lg"
|
||||
>
|
||||
<div class="flex h-full flex-col gap-3">
|
||||
<div class="flex-1 flex flex-col gap-3 min-h-0">
|
||||
{#if oauth.token}
|
||||
<SupabaseProjectStep bind:intent token={oauth.token} existingOnly />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex justify-end pt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="accent"
|
||||
disabled={!intent.project || !intent.password}
|
||||
loading={busy}
|
||||
onClick={apply}
|
||||
>
|
||||
Use this project
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal2>
|
||||
@@ -0,0 +1,434 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const listSupabaseProjectsMock = vi.fn()
|
||||
const createSupabaseProjectMock = vi.fn()
|
||||
vi.mock('./supabaseProvisioning', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('./supabaseProvisioning')>()),
|
||||
listSupabaseProjects: (...a: any[]) => listSupabaseProjectsMock(...a),
|
||||
createSupabaseProject: (...a: any[]) => createSupabaseProjectMock(...a),
|
||||
generateDbPassword: () => 'generated-password',
|
||||
// Whatever the run does after creating a project is not what these tests are about, and the
|
||||
// real ones poll Supabase until it answers.
|
||||
waitUntilSupabaseHealthy: async (_t: string, _r: string) => ({ id: '2', name: 'later' }),
|
||||
resolveSupabaseConnection: async () => {
|
||||
throw new Error('stop the run here')
|
||||
}
|
||||
}))
|
||||
|
||||
const existsVariableMock = vi.fn()
|
||||
const getVariableMock = vi.fn()
|
||||
const getResourceMock = vi.fn()
|
||||
const createVariableMock = vi.fn()
|
||||
const getSettingsMock = vi.fn()
|
||||
const editDataTableConfigMock = vi.fn()
|
||||
const testDataTableConnectionMock = vi.fn()
|
||||
const setupCustomInstanceDbMock = vi.fn()
|
||||
vi.mock('$lib/gen', () => ({
|
||||
VariableService: {
|
||||
existsVariable: (...a: any[]) => existsVariableMock(...a),
|
||||
getVariable: (...a: any[]) => getVariableMock(...a),
|
||||
createVariable: (...a: any[]) => createVariableMock(...a),
|
||||
updateVariable: vi.fn()
|
||||
},
|
||||
ResourceService: {
|
||||
existsResource: vi.fn(),
|
||||
getResource: (...a: any[]) => getResourceMock(...a),
|
||||
createResource: vi.fn(),
|
||||
updateResource: vi.fn()
|
||||
},
|
||||
SettingService: { setupCustomInstanceDb: (...a: any[]) => setupCustomInstanceDbMock(...a) },
|
||||
WorkspaceService: {
|
||||
getSettings: (...a: any[]) => getSettingsMock(...a),
|
||||
editDataTableConfig: (...a: any[]) => editDataTableConfigMock(...a),
|
||||
testDataTableConnection: (...a: any[]) => testDataTableConnectionMock(...a)
|
||||
}
|
||||
}))
|
||||
|
||||
import {
|
||||
intentComplete,
|
||||
newResourceParts,
|
||||
newWizardState,
|
||||
runSetup,
|
||||
type WizardState
|
||||
} from './addDataTableModel'
|
||||
import { noClaims } from './setupClaims'
|
||||
|
||||
/** Nothing at the path: the reads that answer "is this ours?" find no object. */
|
||||
function nothingThere() {
|
||||
getVariableMock.mockRejectedValue(new Error('not found'))
|
||||
getResourceMock.mockRejectedValue(new Error('not found'))
|
||||
}
|
||||
|
||||
/** A resource that exists, with the timestamp the claim is marked by. */
|
||||
function resourceEditedAt(at: string) {
|
||||
getResourceMock.mockResolvedValue({ path: 'p', created_by: 'alice', edited_at: at })
|
||||
}
|
||||
|
||||
/** A wizard about to create the Supabase project `later`, in the organization `acme`. */
|
||||
function creating(): WizardState {
|
||||
const state = newWizardState({ name: 'main', projectName: 'later', folder: 'f/team' })
|
||||
state.provider = 'supabase'
|
||||
state.supabase.mode = 'create'
|
||||
state.supabase.org = 'acme'
|
||||
state.review.resourceName = 'db'
|
||||
return state
|
||||
}
|
||||
|
||||
/** The path `creating()` writes to, and where an earlier attempt's password would sit. */
|
||||
const MINTED_PATH = 'f/team/db'
|
||||
|
||||
const deps = (createdProjectName?: string, createdProjectPath = MINTED_PATH) => ({
|
||||
workspace: 'w',
|
||||
supabaseToken: 'token',
|
||||
onProgress: () => {},
|
||||
claims: noClaims,
|
||||
username: 'alice',
|
||||
createdProjects: createdProjectName
|
||||
? [{ name: createdProjectName, path: createdProjectPath }]
|
||||
: []
|
||||
})
|
||||
|
||||
// `writeSecret` overwrites in place, and Supabase never shows a project's password twice, so
|
||||
// minting a second one at the path where an earlier project's is stored destroys the only copy.
|
||||
describe('runSetup refusing to mint over a project it already created', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
existsVariableMock.mockResolvedValue(false)
|
||||
nothingThere()
|
||||
})
|
||||
|
||||
it('refuses while the earlier project is still there', async () => {
|
||||
listSupabaseProjectsMock.mockResolvedValue([
|
||||
{ id: '1', name: 'earlier', organization_id: 'acme' }
|
||||
])
|
||||
const result = await runSetup(creating(), deps('earlier'))
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.error).toContain('earlier')
|
||||
expect(createVariableMock).not.toHaveBeenCalled()
|
||||
expect(createSupabaseProjectMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The name is also recorded when a create could not be confirmed -- an expired token answers
|
||||
// neither the create nor the lookup. Refusing on that forever would strand the session.
|
||||
it('proceeds when no project by that name exists after all', async () => {
|
||||
listSupabaseProjectsMock.mockResolvedValue([])
|
||||
createSupabaseProjectMock.mockResolvedValue({ id: '2', name: 'later' })
|
||||
await runSetup(creating(), deps('earlier'))
|
||||
expect(createSupabaseProjectMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Connecting the created project as an existing one reaches the same secret by another
|
||||
// route: the project list on step 2 is where it now appears, so this is the likely move.
|
||||
it('refuses to write over the secret from the existing-project branch', async () => {
|
||||
const state = creating()
|
||||
state.supabase.mode = 'existing'
|
||||
state.supabase.project = { id: '1', name: 'earlier' } as any
|
||||
state.supabase.password = 'typed-by-hand'
|
||||
const result = await runSetup(state, deps('earlier'))
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.error).toContain(MINTED_PATH)
|
||||
expect(createVariableMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Aimed somewhere else, there is nothing to protect -- and over-refusing here would block
|
||||
// the ordinary way out of every refusal above, which is to choose another path.
|
||||
it('writes when the run is aimed at a different path', async () => {
|
||||
const state = creating()
|
||||
state.supabase.mode = 'existing'
|
||||
state.supabase.project = { id: '1', name: 'earlier' } as any
|
||||
state.supabase.password = 'typed-by-hand'
|
||||
await runSetup(state, deps('earlier', 'f/team/somewhere-else'))
|
||||
expect(createVariableMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The organization selected now is not the one the earlier project was created under, and
|
||||
// switching it is one of the ways to arrive here.
|
||||
it('refuses a project listed under a different organization', async () => {
|
||||
listSupabaseProjectsMock.mockResolvedValue([
|
||||
{ id: '1', name: 'earlier', organization_id: 'other-org' }
|
||||
])
|
||||
const result = await runSetup(creating(), deps('earlier'))
|
||||
expect(result.ok).toBe(false)
|
||||
expect(createSupabaseProjectMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// The instance branch is the one that has to write its row before it can probe it, since the
|
||||
// probe is by data table name. A database Windmill cannot store data in must not stay in the
|
||||
// config -- and a probe that throws leaves exactly the same unusable row as one that says no.
|
||||
describe('runSetup rolling the instance row back', () => {
|
||||
function usingInstanceDb(): WizardState {
|
||||
const state = newWizardState({ name: 'main', projectName: 'x', folder: 'f/team' })
|
||||
state.provider = 'instance'
|
||||
state.instance = { mode: 'existing', dbName: 'shared' }
|
||||
return state
|
||||
}
|
||||
|
||||
// The rollback reads the config back before deleting, so the config has to behave like one:
|
||||
// a mock that always answers empty would let a rollback that never finds its own row pass.
|
||||
let datatables: Record<string, any>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
datatables = {}
|
||||
getSettingsMock.mockImplementation(async () => ({ datatable: { datatables } }))
|
||||
editDataTableConfigMock.mockImplementation(async ({ requestBody }: any) => {
|
||||
datatables = { ...requestBody.settings.datatables }
|
||||
})
|
||||
setupCustomInstanceDbMock.mockResolvedValue({ success: true, logs: {} })
|
||||
nothingThere()
|
||||
})
|
||||
|
||||
// The pre-flight runs once, before a Supabase create that can take minutes, and every
|
||||
// wizard suggests the same `main` -- so the name can be taken by the time the row is
|
||||
// written. Repointing it would hand another admin's data table a database nobody chose.
|
||||
it('refuses a name that was taken while it was running', async () => {
|
||||
datatables = { main: { database: { resource_path: 'someone-else' } } }
|
||||
const result = await runSetup(usingInstanceDb(), {
|
||||
workspace: 'w',
|
||||
onProgress: () => {},
|
||||
claims: noClaims,
|
||||
username: 'alice',
|
||||
createdProjects: []
|
||||
} as any)
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.error).toContain('main')
|
||||
expect(editDataTableConfigMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Rolling back is just as dangerous once someone else owns the name: the row under it is
|
||||
// no longer the one this run wrote.
|
||||
it('leaves a row it no longer recognises alone', async () => {
|
||||
// Repointed by someone else while this run was probing it.
|
||||
testDataTableConnectionMock.mockImplementation(async () => {
|
||||
datatables = { main: { database: { resource_path: 'someone-else' } } }
|
||||
throw new Error('connection refused')
|
||||
})
|
||||
const result = await runSetup(usingInstanceDb(), {
|
||||
workspace: 'w',
|
||||
onProgress: () => {},
|
||||
claims: noClaims,
|
||||
username: 'alice',
|
||||
createdProjects: []
|
||||
} as any)
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.rowRolledBack).toBe(false)
|
||||
// One call: the write. The rollback found a row it did not write and left it.
|
||||
expect(editDataTableConfigMock).toHaveBeenCalledTimes(1)
|
||||
// And the name is not handed back as ours: claiming it would let Try again write over
|
||||
// the row the other admin now owns.
|
||||
expect(result.rowWritten).toBe(false)
|
||||
})
|
||||
|
||||
it('takes the row back out when the probe never answers', async () => {
|
||||
testDataTableConnectionMock.mockRejectedValue(new Error('connection refused'))
|
||||
const result = await runSetup(usingInstanceDb(), {
|
||||
workspace: 'w',
|
||||
supabaseToken: undefined,
|
||||
onProgress: () => {},
|
||||
claims: noClaims,
|
||||
username: 'alice',
|
||||
createdProjects: []
|
||||
} as any)
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.error).toContain('connection refused')
|
||||
expect(result.rowRolledBack).toBe(true)
|
||||
expect(result.rowWritten).toBe(false)
|
||||
const lastWrite = editDataTableConfigMock.mock.calls.at(-1)?.[0]
|
||||
expect(lastWrite.requestBody.settings.datatables).not.toHaveProperty('main')
|
||||
})
|
||||
})
|
||||
|
||||
// The fields are the connection; a connection string is a way of writing one down. Reading the
|
||||
// resource back out of the string is what let a URI grammar gap change what got saved.
|
||||
describe('newResourceParts', () => {
|
||||
function typedByHand(): WizardState {
|
||||
const state = newWizardState({ name: 'main', projectName: 'x', folder: 'f/team' })
|
||||
state.provider = 'resource'
|
||||
state.own.creating = true
|
||||
state.own.fields = {
|
||||
host: 'db.example.com',
|
||||
port: 5432,
|
||||
dbname: 'mydb',
|
||||
user: 'u',
|
||||
password: 'p',
|
||||
sslmode: 'prefer'
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
it('reads the fields whichever notation is on screen', () => {
|
||||
const state = typedByHand()
|
||||
state.own.form = 'string'
|
||||
state.own.connectionString = 'postgres://u:p@db.example.com:5432/mydb'
|
||||
// The string names no sslmode. The choice on the fields is what gets saved.
|
||||
expect(newResourceParts(state)?.sslmode).toBe('prefer')
|
||||
state.own.form = 'fields'
|
||||
expect(newResourceParts(state)?.sslmode).toBe('prefer')
|
||||
})
|
||||
|
||||
it('is unaffected by a string that cannot be parsed', () => {
|
||||
const state = typedByHand()
|
||||
state.own.form = 'string'
|
||||
state.own.connectionString = 'not a uri'
|
||||
expect(newResourceParts(state)?.host).toBe('db.example.com')
|
||||
})
|
||||
})
|
||||
|
||||
// `created_by` survives an update, so it cannot tell an edit by somebody else from no edit at
|
||||
// all. The claim is marked by `edited_at`, which moves on every write.
|
||||
describe('runSetup writing over a resource', () => {
|
||||
function ownResource(): WizardState {
|
||||
const state = newWizardState({ name: 'main', projectName: 'x', folder: 'f/team' })
|
||||
state.provider = 'resource'
|
||||
state.own.creating = true
|
||||
state.review.resourceName = 'db'
|
||||
state.own.fields = {
|
||||
host: 'h',
|
||||
port: 5432,
|
||||
dbname: 'd',
|
||||
user: 'u',
|
||||
password: 'p',
|
||||
sslmode: 'require'
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
existsVariableMock.mockResolvedValue(false)
|
||||
getVariableMock.mockRejectedValue(new Error('not found'))
|
||||
getSettingsMock.mockResolvedValue({ datatable: { datatables: {} } })
|
||||
editDataTableConfigMock.mockResolvedValue(undefined)
|
||||
testDataTableConnectionMock.mockResolvedValue({ can_create_table: true })
|
||||
})
|
||||
|
||||
it('refuses a resource edited since this run claimed it', async () => {
|
||||
resourceEditedAt('2026-01-02T00:00:00Z')
|
||||
const result = await runSetup(ownResource(), {
|
||||
workspace: 'w',
|
||||
onProgress: () => {},
|
||||
// Claimed when it looked like this; someone has written to it since.
|
||||
claims: [{ kind: 'resource' as const, path: 'f/team/db', mark: '2026-01-01T00:00:00Z' }],
|
||||
username: 'alice',
|
||||
createdProjects: []
|
||||
} as any)
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.error).toContain('f/team/db')
|
||||
})
|
||||
})
|
||||
|
||||
describe('runSetup writing over its own secret', () => {
|
||||
const ownDb = (): WizardState => {
|
||||
const state = newWizardState({ name: 'main', projectName: 'x', folder: 'f/team' })
|
||||
state.provider = 'resource'
|
||||
state.own.creating = true
|
||||
state.review.resourceName = 'db'
|
||||
state.own.fields = {
|
||||
host: 'h',
|
||||
port: 5432,
|
||||
dbname: 'd',
|
||||
user: 'u',
|
||||
password: 'p',
|
||||
sslmode: 'require'
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
getResourceMock.mockRejectedValue(new Error('not found'))
|
||||
getSettingsMock.mockResolvedValue({ datatable: { datatables: {} } })
|
||||
editDataTableConfigMock.mockResolvedValue(undefined)
|
||||
testDataTableConnectionMock.mockResolvedValue({ can_create_table: true })
|
||||
})
|
||||
|
||||
// The same person editing the variable in another tab leaves `edited_by` unchanged, so an
|
||||
// author is not enough to tell that write from none.
|
||||
it('refuses a secret edited since this run claimed it, even by the same user', async () => {
|
||||
getVariableMock.mockResolvedValue({ edited_by: 'alice', edited_at: '2026-01-02T00:00:00Z' })
|
||||
const result = await runSetup(ownDb(), {
|
||||
workspace: 'w',
|
||||
onProgress: () => {},
|
||||
claims: [{ kind: 'secret' as const, path: 'f/team/db', mark: '2026-01-01T00:00:00Z' }],
|
||||
username: 'alice',
|
||||
createdProjects: []
|
||||
} as any)
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.error).toContain('f/team/db')
|
||||
})
|
||||
|
||||
// A create whose confirmation also failed records the project name pessimistically. The
|
||||
// variable it wrote is still its own, and a retry has to be able to reuse the path.
|
||||
it('reuses the variable a previous attempt wrote when its project was never confirmed', async () => {
|
||||
getVariableMock.mockResolvedValue({ edited_by: 'alice', edited_at: '2026-01-01T00:00:00Z' })
|
||||
listSupabaseProjectsMock.mockResolvedValue([])
|
||||
createSupabaseProjectMock.mockResolvedValue({ id: '2', name: 'later' })
|
||||
const state = creating()
|
||||
const result = await runSetup(state, {
|
||||
...deps('later'),
|
||||
claims: [{ kind: 'secret' as const, path: MINTED_PATH, mark: '2026-01-01T00:00:00Z' }]
|
||||
} as any)
|
||||
expect(result.error ?? '').not.toContain('was created at')
|
||||
})
|
||||
})
|
||||
|
||||
// Editing a valid string into an invalid one keeps the fields, so they stay correctable. What
|
||||
// must not happen is testing or saving those fields while the string on screen says otherwise.
|
||||
describe('intentComplete with a connection string on screen', () => {
|
||||
function typed(connectionString: string): WizardState {
|
||||
const state = newWizardState({ name: 'main', projectName: 'x', folder: 'f/team' })
|
||||
state.provider = 'resource'
|
||||
state.own.creating = true
|
||||
state.own.form = 'string'
|
||||
state.own.connectionString = connectionString
|
||||
state.own.fields = {
|
||||
host: 'db.example.com',
|
||||
port: 5432,
|
||||
dbname: 'mydb',
|
||||
user: 'u',
|
||||
password: 'p',
|
||||
sslmode: 'require'
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
it('refuses a string that will not parse, whatever the fields still hold', () => {
|
||||
expect(intentComplete(typed('postgres://u:p@db.example.com:5432/mydb'))).toBe(true)
|
||||
expect(intentComplete(typed('postgres://u:p@db.exa'))).toBe(false)
|
||||
expect(intentComplete(typed(''))).toBe(false)
|
||||
})
|
||||
|
||||
it('is unaffected once the fields are the notation on screen', () => {
|
||||
const state = typed('nonsense')
|
||||
state.own.form = 'fields'
|
||||
expect(intentComplete(state)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Each created project guards its own path. Keeping only the latest let a second attempt at
|
||||
// another path unlock the first project's password, which Supabase will never show again.
|
||||
describe('runSetup guarding more than one created project', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
existsVariableMock.mockResolvedValue(false)
|
||||
nothingThere()
|
||||
})
|
||||
|
||||
it('still refuses the first project’s path after a second was created elsewhere', async () => {
|
||||
listSupabaseProjectsMock.mockResolvedValue([
|
||||
{ id: '1', name: 'first', organization_id: 'acme' }
|
||||
])
|
||||
const state = creating()
|
||||
const result = await runSetup(state, {
|
||||
...deps(),
|
||||
createdProjects: [
|
||||
{ name: 'first', path: MINTED_PATH },
|
||||
{ name: 'second', path: 'f/team/other' }
|
||||
]
|
||||
} as any)
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.error).toContain('first')
|
||||
expect(createVariableMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,853 @@
|
||||
/**
|
||||
* Everything the "add a data table" wizard collects, and the one function that acts on it.
|
||||
*
|
||||
* The wizard writes nothing until the user finishes: steps 1 and 2 gather intent, step 3
|
||||
* reviews it, and `runSetup` performs it. That ordering is what lets the review step show
|
||||
* the resource path before the resource exists.
|
||||
*
|
||||
* `runSetup` is also what Try again calls, so every step has to tolerate the results of a
|
||||
* previous attempt still being there.
|
||||
*/
|
||||
|
||||
import {
|
||||
ResourceService,
|
||||
SettingService,
|
||||
VariableService,
|
||||
WorkspaceService,
|
||||
type TestDataTableConnectionResponse
|
||||
} from '$lib/gen'
|
||||
import type { SetupStep } from '../wizards/SetupChecklist.svelte'
|
||||
import { instanceSetupSteps } from './instanceDbSteps'
|
||||
import { claim, stillOurs, type Claims } from './setupClaims'
|
||||
import { probeDatatableConnection } from './datatableProbe'
|
||||
import {
|
||||
DEFAULT_SSLMODE,
|
||||
parsePostgresConnectionString,
|
||||
unsupportedConnectionParam,
|
||||
type PostgresConnectionParts
|
||||
} from '$lib/utils/postgresConnectionString'
|
||||
import {
|
||||
createSupabaseProject,
|
||||
generateDbPassword,
|
||||
resolveSupabaseConnection,
|
||||
listSupabaseProjects,
|
||||
projectOrg,
|
||||
projectRef,
|
||||
orgSlug,
|
||||
supabaseResourceValue,
|
||||
waitUntilSupabaseHealthy,
|
||||
DEFAULT_SUPABASE_REGION,
|
||||
type SupabaseConnectionMode,
|
||||
type SupabaseOrg,
|
||||
type SupabaseProject
|
||||
} from './supabaseProvisioning'
|
||||
|
||||
export type Provider = 'supabase' | 'instance' | 'resource'
|
||||
|
||||
export type WizardState = {
|
||||
step: 1 | 2 | 3
|
||||
provider: Provider | undefined
|
||||
supabase: {
|
||||
mode: 'existing' | 'create'
|
||||
project: SupabaseProject | undefined
|
||||
password: string
|
||||
/**
|
||||
* The whole organization, not its slug: the API is called with the slug, but a slug is a
|
||||
* random string and the review step has a person reading it.
|
||||
*/
|
||||
org: SupabaseOrg | undefined
|
||||
region: string
|
||||
projectName: string
|
||||
connectionMode: SupabaseConnectionMode
|
||||
}
|
||||
instance: { mode: 'existing' | 'create'; dbName: string | undefined }
|
||||
/**
|
||||
* One list: the workspace's Postgres resources, plus the one about to exist. A
|
||||
* connection string is not an alternative to a resource, it is how one is written --
|
||||
* so `creating` and `resourcePath` are the two ways of answering the same question and
|
||||
* are never both set.
|
||||
*/
|
||||
own: {
|
||||
resourcePath: string | undefined
|
||||
creating: boolean
|
||||
/** Which notation the new resource is being entered in. Same object either way. */
|
||||
form: 'string' | 'fields'
|
||||
connectionString: string
|
||||
fields: PostgresConnectionParts
|
||||
/** The resource fields no URI can carry, so they belong to neither notation. */
|
||||
advanced: PostgresAdvanced
|
||||
}
|
||||
review: { name: string; folder: string; resourceName: string }
|
||||
/** Result of validating what step 2 collected. Cleared whenever its input changes. */
|
||||
probe: {
|
||||
checking: boolean
|
||||
report: TestDataTableConnectionResponse | undefined
|
||||
error: string | undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function newWizardState(defaults: {
|
||||
name: string
|
||||
projectName: string
|
||||
folder: string
|
||||
}): WizardState {
|
||||
return {
|
||||
step: 1,
|
||||
provider: undefined,
|
||||
supabase: {
|
||||
// Nothing is chosen yet; the step decides between the two once it knows whether the
|
||||
// account has any projects. `create` here would be indistinguishable from the user
|
||||
// having picked "New project", which is what survives a Back out of the step.
|
||||
mode: 'existing',
|
||||
project: undefined,
|
||||
password: '',
|
||||
org: undefined,
|
||||
region: DEFAULT_SUPABASE_REGION,
|
||||
projectName: defaults.projectName,
|
||||
connectionMode: 'session'
|
||||
},
|
||||
instance: { mode: 'create', dbName: undefined },
|
||||
own: {
|
||||
resourcePath: undefined,
|
||||
creating: false,
|
||||
form: 'string',
|
||||
connectionString: '',
|
||||
fields: emptyFields(),
|
||||
advanced: emptyAdvanced()
|
||||
},
|
||||
review: { name: defaults.name, folder: defaults.folder, resourceName: '' },
|
||||
probe: { checking: false, report: undefined, error: undefined }
|
||||
}
|
||||
}
|
||||
|
||||
export function clearProbe(state: WizardState) {
|
||||
state.probe = { checking: false, report: undefined, error: undefined }
|
||||
}
|
||||
|
||||
/** Path of the resource and secret variable the run will write. They share one. */
|
||||
export function resourcePathOf(state: WizardState): string {
|
||||
return `${state.review.folder}/${state.review.resourceName}`
|
||||
}
|
||||
|
||||
/** True once the branch has everything `runSetup` needs. */
|
||||
export function intentComplete(state: WizardState): boolean {
|
||||
if (state.provider === 'supabase') {
|
||||
return state.supabase.mode === 'create'
|
||||
? !!state.supabase.projectName.trim() && !!state.supabase.org
|
||||
: !!state.supabase.project && !!state.supabase.password
|
||||
}
|
||||
if (state.provider === 'instance') return !!state.instance.dbName?.trim()
|
||||
if (!state.own.creating) return !!state.own.resourcePath
|
||||
// Text that will not parse leaves the fields on their last good values, which is what makes
|
||||
// it correctable -- but the connection on screen is then not the one they describe, and
|
||||
// testing or saving the old one behind an unparseable string points the data table
|
||||
// somewhere nobody asked for.
|
||||
if (
|
||||
state.own.form === 'string' &&
|
||||
(!parsePostgresConnectionString(state.own.connectionString) ||
|
||||
unsupportedConnectionParam(state.own.connectionString))
|
||||
)
|
||||
return false
|
||||
return !!newResourceParts(state)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `postgresql` fields outside the connection-string vocabulary: TLS verification and
|
||||
* AWS IAM auth. Kept apart from the parts so composing a string cannot appear to drop them.
|
||||
*/
|
||||
export type PostgresAdvanced = {
|
||||
root_certificate_pem: string
|
||||
/**
|
||||
* Undefined is meaningful: the backend then verifies only when a root certificate is
|
||||
* present. Only ever set by an explicit choice.
|
||||
*/
|
||||
accept_invalid_certs: boolean | undefined
|
||||
use_iam_auth: boolean
|
||||
region: string
|
||||
}
|
||||
|
||||
function emptyAdvanced(): PostgresAdvanced {
|
||||
return {
|
||||
root_certificate_pem: '',
|
||||
accept_invalid_certs: undefined,
|
||||
use_iam_auth: false,
|
||||
region: ''
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether anything was set, so a notation that cannot show them can say they apply. */
|
||||
export function hasAdvanced(advanced: PostgresAdvanced): boolean {
|
||||
return (
|
||||
!!advanced.root_certificate_pem.trim() ||
|
||||
advanced.accept_invalid_certs !== undefined ||
|
||||
advanced.use_iam_auth ||
|
||||
!!advanced.region.trim()
|
||||
)
|
||||
}
|
||||
|
||||
function emptyFields(): PostgresConnectionParts {
|
||||
return {
|
||||
host: '',
|
||||
port: 5432,
|
||||
dbname: 'postgres',
|
||||
user: '',
|
||||
password: '',
|
||||
sslmode: DEFAULT_SSLMODE
|
||||
}
|
||||
}
|
||||
|
||||
const RESERVED_DB_NAMES = ['template0', 'template1', 'postgres']
|
||||
const VALID_DB_NAME = /^[a-zA-Z][a-zA-Z0-9_-]*$/
|
||||
|
||||
/**
|
||||
* Why `setup_custom_instance_db` would refuse this name, checked as it is typed. Deliberately
|
||||
* not exhaustive -- the backend stays the authority, this only catches what the browser
|
||||
* already knows. Empty is incomplete rather than wrong.
|
||||
*/
|
||||
export function instanceDbNameError(name: string, existing: Iterable<string>): string | undefined {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return undefined
|
||||
if (trimmed.length > 63) return 'A database name cannot exceed 63 characters.'
|
||||
if (!VALID_DB_NAME.test(trimmed))
|
||||
return 'Start with a letter, then letters, digits, underscores or hyphens only.'
|
||||
if (RESERVED_DB_NAMES.includes(trimmed.toLowerCase()))
|
||||
return `${trimmed} is a reserved PostgreSQL database name.`
|
||||
if (new Set(existing).has(trimmed))
|
||||
return `A database called ${trimmed} already exists on this instance.`
|
||||
return undefined
|
||||
}
|
||||
|
||||
const VALID_DATATABLE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_\-.]*$/
|
||||
|
||||
/**
|
||||
* Why `edit_datatable_config` would refuse this name, checked as it is typed because the write
|
||||
* is the *last* step of the run: by the time the backend rejects it a Supabase project may
|
||||
* have been billed. `existing` are the names already in the workspace.
|
||||
*/
|
||||
export function datatableNameError(name: string, existing: Iterable<string>): string | undefined {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return undefined
|
||||
if (new Set(existing).has(trimmed))
|
||||
return `A data table called ${trimmed} already exists in this workspace.`
|
||||
// `validate_datatable_path_segment` runs first on the backend and rejects `..` outright,
|
||||
// before the charset check the regex below mirrors.
|
||||
if (trimmed.includes('..')) return "A data table name cannot contain '..'."
|
||||
if (!VALID_DATATABLE_NAME.test(trimmed))
|
||||
return "Start with a letter or digit, then letters, digits, '_', '-' and '.' only — the name has to survive being synced to a git repository."
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* What the new resource describes. The fields are the connection; a connection string is a way
|
||||
* of writing one down, parsed into the fields as it is typed. Reading it back out here instead
|
||||
* would put every gap in the URI grammar between the user and what gets saved.
|
||||
*/
|
||||
export function newResourceParts(state: WizardState): PostgresConnectionParts | undefined {
|
||||
const fields = state.own.fields
|
||||
return fields.host.trim() && fields.user.trim() ? fields : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Those parts as a `postgresql` resource value -- the one shape everything downstream sees,
|
||||
* so nothing after this point knows which notation produced it. The password is the
|
||||
* caller's: the literal one when testing before anything is saved, a `$var:` reference once
|
||||
* it has somewhere to live.
|
||||
*/
|
||||
export function postgresResourceValue(
|
||||
parts: PostgresConnectionParts,
|
||||
password: string,
|
||||
advanced: PostgresAdvanced
|
||||
): Record<string, any> {
|
||||
return {
|
||||
host: parts.host,
|
||||
user: parts.user,
|
||||
port: parts.port ?? 5432,
|
||||
dbname: parts.dbname || 'postgres',
|
||||
sslmode: parts.sslmode || DEFAULT_SSLMODE,
|
||||
password,
|
||||
region: advanced.region,
|
||||
root_certificate_pem: advanced.root_certificate_pem,
|
||||
use_iam_auth: advanced.use_iam_auth,
|
||||
// Omitted rather than sent as false: absent is its own state, and the one every
|
||||
// resource that predates the flag is in.
|
||||
...(advanced.accept_invalid_certs !== undefined
|
||||
? { accept_invalid_certs: advanced.accept_invalid_certs }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The connection value a branch can be validated against before anything is saved.
|
||||
* Undefined for branches with nothing to validate yet: creating a Supabase project has no
|
||||
* database to reach, and an instance database does not exist until setup runs.
|
||||
*/
|
||||
export function probeValue(state: WizardState): Record<string, any> | undefined {
|
||||
if (state.provider !== 'resource' || !state.own.creating) return undefined
|
||||
const parts = newResourceParts(state)
|
||||
return parts ? postgresResourceValue(parts, parts.password ?? '', state.own.advanced) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the Supabase project will live, for the review step to state plainly. Read off
|
||||
* the project when it already exists, off what was picked when it is about to be created.
|
||||
*/
|
||||
export function supabaseSummary(state: WizardState): { org?: string; region?: string } {
|
||||
if (state.supabase.mode === 'create')
|
||||
return { org: state.supabase.org?.name, region: state.supabase.region }
|
||||
const project = state.supabase.project
|
||||
return {
|
||||
// The name when the organization is known, its identifier only as a last resort.
|
||||
org: state.supabase.org?.name ?? (project ? projectOrg(project) : undefined),
|
||||
region: project?.region
|
||||
}
|
||||
}
|
||||
|
||||
export type RunStepKey =
|
||||
| 'create_project'
|
||||
| 'wait_healthy'
|
||||
| 'save_credentials'
|
||||
| 'setup_instance'
|
||||
| 'check'
|
||||
|
||||
/**
|
||||
* The steps this branch will run, in order. The key drives the runner and the title only
|
||||
* the display, so rewording a step cannot change what it does.
|
||||
*/
|
||||
export function plan(state: WizardState): { key: RunStepKey; title: string }[] {
|
||||
const path = resourcePathOf(state)
|
||||
const steps: { key: RunStepKey; title: string }[] = []
|
||||
if (state.provider === 'supabase') {
|
||||
if (state.supabase.mode === 'create') {
|
||||
steps.push({
|
||||
key: 'create_project',
|
||||
title: `Creating ${state.supabase.projectName.trim()} on Supabase`
|
||||
})
|
||||
steps.push({ key: 'wait_healthy', title: 'Waiting for the database to start' })
|
||||
}
|
||||
steps.push({ key: 'save_credentials', title: `Saving credentials to ${path}` })
|
||||
} else if (state.provider === 'instance') {
|
||||
steps.push({
|
||||
key: 'setup_instance',
|
||||
title: `Setting up ${state.instance.dbName} in the Windmill database`
|
||||
})
|
||||
} else if (state.own.creating) {
|
||||
steps.push({ key: 'save_credentials', title: `Saving the connection to ${path}` })
|
||||
}
|
||||
steps.push({ key: 'check', title: 'Checking Windmill can store data' })
|
||||
return steps
|
||||
}
|
||||
|
||||
/** The same plan as a checklist, all pending. */
|
||||
export function planSteps(state: WizardState): SetupStep[] {
|
||||
return plan(state).map((s) => ({ title: s.title, status: 'pending' }))
|
||||
}
|
||||
|
||||
/** A Supabase project this session created, and the path holding its only password. */
|
||||
export type CreatedProject = { name: string; path: string }
|
||||
|
||||
export type RunDeps = {
|
||||
workspace: string
|
||||
/** Required for the Supabase branch. */
|
||||
supabaseToken?: string
|
||||
/** So the settings page's pool reflects a database this run created. */
|
||||
onInstanceDbsChanged?: () => Promise<void>
|
||||
onProgress: (steps: SetupStep[]) => void
|
||||
/** Session pooling was asked for but could not be read; a direct host was written. */
|
||||
onPoolerUnavailable?: (reason: string) => void
|
||||
/**
|
||||
* The Supabase project an earlier attempt in this session created. Minting a second password
|
||||
* over the first one's variable would lose the only copy of credentials Supabase will not
|
||||
* repeat, so a run that would do that refuses -- but only once it has seen that the project
|
||||
* is really there, since the name is also recorded when a create could not be confirmed.
|
||||
*/
|
||||
createdProjects: CreatedProject[]
|
||||
/**
|
||||
* What earlier attempts in this session wrote, and this one may therefore write over again.
|
||||
* The pre-flight checks the names are free, but the Supabase branch then spends minutes
|
||||
* provisioning, and every wizard suggests the same `main` -- so a second admin can take the
|
||||
* name or the path in between.
|
||||
*/
|
||||
claims: Claims
|
||||
/** Stands in as the mark where the object was written but its timestamp could not be read back. */
|
||||
username: string
|
||||
}
|
||||
|
||||
export type RunResult = {
|
||||
ok: boolean
|
||||
report?: TestDataTableConnectionResponse
|
||||
error?: string
|
||||
/**
|
||||
* The workspace config still holds this data table. False when the run never got that far,
|
||||
* and when a refused instance database was taken back out again -- so the name is free and
|
||||
* the caller must not claim it.
|
||||
*/
|
||||
rowWritten?: boolean
|
||||
/** A row this run had written is gone again, so a claim on the name has to go with it. */
|
||||
rowRolledBack?: boolean
|
||||
/**
|
||||
* Every project created this session, each guarding the path holding its only password.
|
||||
* Supabase never shows that password again, so the variable there is the only copy and no
|
||||
* later attempt may write over it.
|
||||
*/
|
||||
createdProjects: CreatedProject[]
|
||||
/** What this run holds now, for the next attempt to be given back. */
|
||||
claims: Claims
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a run will not write at a path that already holds a created project's password. Names
|
||||
* the path the password is actually at, which is not always the one the wizard is pointing at
|
||||
* now -- the review step can be edited after a failure.
|
||||
*/
|
||||
function createdSecretRefusal(projectName: string, passwordPath: string): string {
|
||||
return `The password of the Supabase project ${projectName}, which this setup created, is stored at ${passwordPath}. Writing here would replace it and Supabase cannot show that password again. Name the project ${projectName} again to carry on with it, or use a different path.`
|
||||
}
|
||||
|
||||
async function exists(kind: 'variable' | 'resource', workspace: string, path: string) {
|
||||
return kind === 'variable'
|
||||
? VariableService.existsVariable({ workspace, path })
|
||||
: ResourceService.existsResource({ workspace, path })
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the data table to the workspace config, once everything it points at exists.
|
||||
* `edit_datatable_config` replaces the whole map, so the rest is read back and sent with
|
||||
* it. Re-runnable: a second attempt overwrites the entry it wrote.
|
||||
*/
|
||||
async function writeRow(
|
||||
deps: RunDeps,
|
||||
claims: Claims,
|
||||
name: string,
|
||||
database: { resource_type: 'postgresql' | 'instance'; resource_path: string }
|
||||
): Promise<Claims> {
|
||||
const settings = await WorkspaceService.getSettings({ workspace: deps.workspace })
|
||||
const datatables: Record<string, any> = { ...(settings.datatable?.datatables ?? {}) }
|
||||
// Free when the pre-flight looked, taken by the time we write: repointing it here would
|
||||
// silently hand another admin's data table a database they never chose.
|
||||
if (
|
||||
datatables[name] &&
|
||||
!stillOurs(claims, 'row', name, datatables[name]?.database?.resource_path)
|
||||
) {
|
||||
throw new Error(
|
||||
`A data table called ${name} was created while this setup was running. Choose another name and try again.`
|
||||
)
|
||||
}
|
||||
datatables[name] = { ...(datatables[name] ?? {}), database }
|
||||
await WorkspaceService.editDataTableConfig({
|
||||
workspace: deps.workspace,
|
||||
requestBody: { settings: { datatables }, renames: [], deleted_datatables: [] }
|
||||
})
|
||||
return claim(claims, 'row', name, database.resource_path)
|
||||
}
|
||||
|
||||
/**
|
||||
* `removed` — the row this run wrote is gone. `kept` — the undo could not reach the server, so
|
||||
* it is still there and the caller has to keep saying so. `foreign` — the name now points
|
||||
* somewhere this run never wrote, so there is nothing of ours to take back.
|
||||
*/
|
||||
type Rollback = 'removed' | 'kept' | 'foreign'
|
||||
|
||||
async function removeRow(deps: RunDeps, claims: Claims, name: string): Promise<Rollback> {
|
||||
try {
|
||||
const settings = await WorkspaceService.getSettings({ workspace: deps.workspace })
|
||||
const datatables: Record<string, any> = { ...(settings.datatable?.datatables ?? {}) }
|
||||
// Only take back the row this run put there. Between writing it and probing it, another
|
||||
// admin can have pointed the same name somewhere else, and deleting that is worse than
|
||||
// leaving ours behind.
|
||||
if (!stillOurs(claims, 'row', name, datatables[name]?.database?.resource_path)) return 'foreign'
|
||||
delete datatables[name]
|
||||
// Not `deleted_datatables`: that exists to cascade migration bookkeeping and deployment
|
||||
// records for a data table that was really in use, and this one never got that far.
|
||||
await WorkspaceService.editDataTableConfig({
|
||||
workspace: deps.workspace,
|
||||
requestBody: { settings: { datatables }, renames: [], deleted_datatables: [] }
|
||||
})
|
||||
return 'removed'
|
||||
} catch {
|
||||
return 'kept'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The read answers both questions at once: whether anything is there, and who last wrote it.
|
||||
* Replacing this run's own work is required for Try again; replacing anyone else's loses a
|
||||
* generated Supabase password, which Supabase never shows twice.
|
||||
*/
|
||||
async function writeSecret(
|
||||
deps: RunDeps,
|
||||
claims: Claims,
|
||||
path: string,
|
||||
value: string,
|
||||
description: string
|
||||
): Promise<Claims> {
|
||||
const held = await secretMark(deps, path)
|
||||
if (held) {
|
||||
if (!stillOurs(claims, 'secret', path, held)) throw new Error(pathTakenLate('variable', path))
|
||||
await VariableService.updateVariable({
|
||||
workspace: deps.workspace,
|
||||
path,
|
||||
requestBody: { value, is_secret: true }
|
||||
})
|
||||
} else {
|
||||
await VariableService.createVariable({
|
||||
workspace: deps.workspace,
|
||||
requestBody: { path, value, is_secret: true, description, is_oauth: false }
|
||||
})
|
||||
}
|
||||
return claim(claims, 'secret', path, (await secretMark(deps, path)) ?? deps.username)
|
||||
}
|
||||
|
||||
/**
|
||||
* A revision, not an author: the same person editing the variable in another tab leaves
|
||||
* `edited_by` unchanged, and that write is no more ours to discard than a stranger's.
|
||||
* `undefined` when nothing is there.
|
||||
*/
|
||||
async function secretMark(deps: RunDeps, path: string): Promise<string | undefined> {
|
||||
// `decryptSecret` defaults to true, and the handler audit-logs a decryption when it does.
|
||||
// Only the timestamp is wanted, and it is on the response either way -- asking for the
|
||||
// plaintext records decrypting a secret nothing reads, including someone else's on the
|
||||
// retry that is about to refuse it.
|
||||
const held = await VariableService.getVariable({
|
||||
workspace: deps.workspace,
|
||||
path,
|
||||
decryptSecret: false
|
||||
}).catch(() => undefined)
|
||||
return held ? (held.edited_at ?? held.edited_by ?? '') : undefined
|
||||
}
|
||||
|
||||
function pathTakenLate(kind: 'variable' | 'resource', path: string): string {
|
||||
return `A ${kind} was created at ${path} while this setup was running. Choose another path and try again.`
|
||||
}
|
||||
|
||||
async function writeResource(
|
||||
deps: RunDeps,
|
||||
claims: Claims,
|
||||
path: string,
|
||||
value: Record<string, any>,
|
||||
description: string
|
||||
): Promise<Claims> {
|
||||
const held = await resourceMark(deps, path)
|
||||
if (held) {
|
||||
if (!stillOurs(claims, 'resource', path, held)) throw new Error(pathTakenLate('resource', path))
|
||||
await ResourceService.updateResource({
|
||||
workspace: deps.workspace,
|
||||
path,
|
||||
requestBody: { value, description }
|
||||
})
|
||||
} else {
|
||||
await ResourceService.createResource({
|
||||
workspace: deps.workspace,
|
||||
requestBody: { resource_type: 'postgresql', path, value, description }
|
||||
})
|
||||
}
|
||||
// Read back rather than claim the username: `created_by` survives an update, so it cannot
|
||||
// tell an edit by somebody else from no edit at all. `edited_at` moves on every write, which
|
||||
// is what makes the next attempt able to see one that happened in between.
|
||||
return claim(claims, 'resource', path, (await resourceMark(deps, path)) ?? deps.username)
|
||||
}
|
||||
|
||||
/** `undefined` when nothing is there. */
|
||||
async function resourceMark(deps: RunDeps, path: string): Promise<string | undefined> {
|
||||
const held = await ResourceService.getResource({ workspace: deps.workspace, path }).catch(
|
||||
() => undefined
|
||||
)
|
||||
return held ? (held.edited_at ?? held.created_by ?? '') : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs what the wizard collected, reporting each step as it goes.
|
||||
*
|
||||
* Every step is safe to re-run, because Try again runs the whole plan a second time:
|
||||
* each one upserts rather than assuming what it creates is absent.
|
||||
*/
|
||||
export async function runSetup(state: WizardState, deps: RunDeps): Promise<RunResult> {
|
||||
const planned = plan(state)
|
||||
const steps: SetupStep[] = planned.map((s) => ({ title: s.title, status: 'pending' }))
|
||||
let index = 0
|
||||
const advance = (
|
||||
status: 'running' | 'done' | 'failed',
|
||||
description?: string,
|
||||
substeps?: SetupStep[]
|
||||
) => {
|
||||
steps[index] = {
|
||||
...steps[index],
|
||||
status,
|
||||
description,
|
||||
substeps: substeps ?? steps[index].substeps
|
||||
}
|
||||
deps.onProgress([...steps])
|
||||
}
|
||||
let rowWritten = false
|
||||
let rowRolledBack = false
|
||||
let claims = deps.claims
|
||||
let createdProjects: CreatedProject[] = [...deps.createdProjects]
|
||||
/** Records a created project once, so a second attempt cannot displace the first one's guard. */
|
||||
const rememberProject = (name: string, at: string) => {
|
||||
if (!createdProjects.some((p) => p.path === at))
|
||||
createdProjects = [...createdProjects, { name, path: at }]
|
||||
}
|
||||
const fail = (message: string): RunResult => {
|
||||
advance('failed', message)
|
||||
return {
|
||||
ok: false,
|
||||
error: message,
|
||||
rowWritten,
|
||||
rowRolledBack,
|
||||
claims,
|
||||
createdProjects
|
||||
}
|
||||
}
|
||||
|
||||
const path = resourcePathOf(state)
|
||||
const name = state.review.name.trim()
|
||||
/**
|
||||
* An earlier attempt stored a created project's password here. Supabase hands that out once
|
||||
* and every write upserts, so every route back to this path refuses. Each created project
|
||||
* guards its own path -- checking only the latest unlocked the earlier one's password.
|
||||
*/
|
||||
const guardedHere = deps.createdProjects.find((p) => p.path === path)
|
||||
const instanceName = state.instance.dbName?.trim() ?? ''
|
||||
|
||||
let project = state.supabase.project
|
||||
let resourcePath =
|
||||
state.provider === 'resource' && !state.own.creating ? state.own.resourcePath! : path
|
||||
|
||||
for (; index < planned.length; index++) {
|
||||
advance('running')
|
||||
try {
|
||||
if (planned[index].key === 'create_project') {
|
||||
// The password is generated here and can never be read back from Supabase, so it
|
||||
// is written to the secret variable before the project that uses it exists. A run
|
||||
// that dies right after creation is then still repairable; the reverse order
|
||||
// would strand a billed project nobody holds the password to.
|
||||
const wanted = state.supabase.projectName.trim()
|
||||
const inOrg = (name: string) => (p: SupabaseProject) =>
|
||||
p.name === name && (!state.supabase.org || projectOrg(p) === orgSlug(state.supabase.org))
|
||||
const projects = await listSupabaseProjects(deps.supabaseToken!)
|
||||
const existing = projects.find(inOrg(wanted))
|
||||
if (existing) {
|
||||
if (!(await exists('variable', deps.workspace, path))) {
|
||||
// A project this same session created is the one case where the password is
|
||||
// held after all, just not here: the path has been edited since. Saying so
|
||||
// beats telling someone to reset or delete a project that is working.
|
||||
const elsewhere = deps.createdProjects.find((p) => p.name === wanted)
|
||||
if (elsewhere)
|
||||
return fail(
|
||||
`The password for ${wanted}, which this setup created, is stored at ${elsewhere.path}, not at ${path}. Set the path back to ${elsewhere.path} to carry on with that project.`
|
||||
)
|
||||
return fail(
|
||||
`A Supabase project called ${wanted} already exists, but Windmill does not hold its password and Supabase cannot return it. Reset the password in Supabase and connect it as an existing project, or delete the project and retry.`
|
||||
)
|
||||
}
|
||||
project = existing
|
||||
} else {
|
||||
// The project has to still exist for its password to be worth protecting: a name
|
||||
// recorded from a create that could not be confirmed is a false alarm, and
|
||||
// refusing on it leaves the session with nothing it can do. Matched by name
|
||||
// across every organization -- a namesake costs a rename, a miss costs the
|
||||
// password.
|
||||
const earlier = guardedHere?.name
|
||||
if (earlier && projects.some((p) => p.name === earlier)) {
|
||||
return fail(createdSecretRefusal(earlier, guardedHere!.path))
|
||||
}
|
||||
const password = generateDbPassword()
|
||||
claims = await writeSecret(
|
||||
deps,
|
||||
claims,
|
||||
path,
|
||||
password,
|
||||
`Password for the ${wanted} Supabase database`
|
||||
)
|
||||
try {
|
||||
project = await createSupabaseProject(deps.supabaseToken!, {
|
||||
name: wanted,
|
||||
organizationSlug: orgSlug(state.supabase.org!),
|
||||
region: state.supabase.region,
|
||||
dbPass: password
|
||||
})
|
||||
// From here the password in `path` is the only copy of a billed project's
|
||||
// credentials, and every later write to that path upserts.
|
||||
rememberProject(wanted, path)
|
||||
} catch (err) {
|
||||
// A refusal and a lost response look the same from here, and only one of them
|
||||
// bills. Ask Supabase which it was: a project that turned up is ours, holds the
|
||||
// password just written, and is what the rest of the run is for. If even that
|
||||
// cannot be answered -- an expired token answers nothing -- record the name
|
||||
// anyway, and let the next attempt's own lookup decide whether it was real.
|
||||
const appeared = await listSupabaseProjects(deps.supabaseToken!).then(
|
||||
(after) => after.find(inOrg(wanted)),
|
||||
() => {
|
||||
rememberProject(wanted, path)
|
||||
return undefined
|
||||
}
|
||||
)
|
||||
if (!appeared) throw err
|
||||
rememberProject(wanted, path)
|
||||
project = appeared
|
||||
}
|
||||
}
|
||||
} else if (planned[index].key === 'wait_healthy') {
|
||||
// Minutes of polling with nothing else to show: hang what Supabase reports off the
|
||||
// step, so the longest wait in the wizard has something behind its chevron.
|
||||
project = await waitUntilSupabaseHealthy(
|
||||
deps.supabaseToken!,
|
||||
projectRef(project!),
|
||||
(status) => advance('running', status)
|
||||
)
|
||||
} else if (planned[index].key === 'save_credentials') {
|
||||
if (state.provider === 'supabase') {
|
||||
if (state.supabase.mode === 'existing') {
|
||||
if (guardedHere) return fail(createdSecretRefusal(guardedHere.name, path))
|
||||
claims = await writeSecret(
|
||||
deps,
|
||||
claims,
|
||||
path,
|
||||
state.supabase.password,
|
||||
`Password for the ${project!.name} Supabase database`
|
||||
)
|
||||
}
|
||||
const connection = await resolveSupabaseConnection(
|
||||
deps.supabaseToken!,
|
||||
project!,
|
||||
state.supabase.connectionMode
|
||||
)
|
||||
if (connection.mode !== state.supabase.connectionMode)
|
||||
state.supabase.connectionMode = connection.mode
|
||||
if (connection.unavailable) deps.onPoolerUnavailable?.(connection.unavailable)
|
||||
claims = await writeResource(
|
||||
deps,
|
||||
claims,
|
||||
path,
|
||||
supabaseResourceValue(project!, path, connection),
|
||||
`Supabase project ${project!.name}`
|
||||
)
|
||||
} else {
|
||||
if (guardedHere) return fail(createdSecretRefusal(guardedHere.name, path))
|
||||
const parts = newResourceParts(state)!
|
||||
claims = await writeSecret(
|
||||
deps,
|
||||
claims,
|
||||
path,
|
||||
parts.password ?? '',
|
||||
`Password for the ${parts.host} database`
|
||||
)
|
||||
claims = await writeResource(
|
||||
deps,
|
||||
claims,
|
||||
path,
|
||||
postgresResourceValue(parts, `$var:${path}`, state.own.advanced),
|
||||
`Database for the ${name} data table`
|
||||
)
|
||||
}
|
||||
} else if (planned[index].key === 'setup_instance') {
|
||||
// The call reports nothing until it returns, so name the checks it is about to run
|
||||
// with the first one marked in flight; its answer replaces them when it lands.
|
||||
// Otherwise the longest step in the wizard is a single line that sits there.
|
||||
advance('running', undefined, instanceSetupSteps(instanceName, undefined, true))
|
||||
const status = await SettingService.setupCustomInstanceDb({
|
||||
name: instanceName,
|
||||
requestBody: { tag: 'datatable' }
|
||||
})
|
||||
await deps.onInstanceDbsChanged?.()
|
||||
const checks = instanceSetupSteps(instanceName, status, false)
|
||||
if (!status.success) {
|
||||
advance('failed', status.error ?? 'Setup failed', checks)
|
||||
return {
|
||||
ok: false,
|
||||
error: status.error ?? 'Setup failed',
|
||||
rowWritten,
|
||||
rowRolledBack,
|
||||
claims,
|
||||
createdProjects
|
||||
}
|
||||
}
|
||||
advance('running', undefined, checks)
|
||||
} else if (state.provider === 'instance') {
|
||||
// An instance data table is probed by name, through the very entry being written
|
||||
// here, so this is the one branch that cannot check first. A database Windmill
|
||||
// cannot store data in must not stay in the config, so a refusal takes the row
|
||||
// back out -- leaving it would also block retrying under the same name.
|
||||
const database = { resource_type: 'instance' as const, resource_path: instanceName }
|
||||
claims = await writeRow(deps, claims, name, database)
|
||||
rowWritten = true
|
||||
const report = await WorkspaceService.testDataTableConnection({
|
||||
workspace: deps.workspace,
|
||||
datatableName: name
|
||||
}).catch(async (err) => {
|
||||
// A probe that never answered leaves the same unusable row behind as one that
|
||||
// answered no -- an unreachable database or a timeout lands here -- so it takes
|
||||
// the same way out rather than the bare outer catch.
|
||||
const rollback = await removeRow(deps, claims, name)
|
||||
rowRolledBack = rollback === 'removed'
|
||||
// `foreign` means the name is somebody else's now: our row is not there to
|
||||
// hand back to the collision checks, and a retry must not write over theirs.
|
||||
rowWritten = rollback === 'kept'
|
||||
throw err
|
||||
})
|
||||
if (!report.can_create_table) {
|
||||
const rollback = await removeRow(deps, claims, name)
|
||||
rowRolledBack = rollback === 'removed'
|
||||
rowWritten = rollback === 'kept'
|
||||
advance('failed', 'The database is reachable but its user cannot create tables.')
|
||||
return {
|
||||
ok: false,
|
||||
report,
|
||||
rowWritten,
|
||||
rowRolledBack,
|
||||
claims,
|
||||
createdProjects
|
||||
}
|
||||
}
|
||||
advance('done')
|
||||
return {
|
||||
ok: true,
|
||||
report,
|
||||
rowWritten,
|
||||
rowRolledBack,
|
||||
claims,
|
||||
createdProjects
|
||||
}
|
||||
} else {
|
||||
// Checked through the resource, so nothing is written until the database has proved
|
||||
// it can hold a data table.
|
||||
const report = await probeDatatableConnection(deps.workspace, `$res:${resourcePath}`)
|
||||
if (!report.can_create_table) {
|
||||
advance('failed', 'The database is reachable but its user cannot create tables.')
|
||||
return {
|
||||
ok: false,
|
||||
report,
|
||||
rowWritten,
|
||||
rowRolledBack,
|
||||
claims,
|
||||
createdProjects
|
||||
}
|
||||
}
|
||||
claims = await writeRow(deps, claims, name, {
|
||||
resource_type: 'postgresql',
|
||||
resource_path: resourcePath
|
||||
})
|
||||
rowWritten = true
|
||||
advance('done')
|
||||
return {
|
||||
ok: true,
|
||||
report,
|
||||
rowWritten,
|
||||
rowRolledBack,
|
||||
claims,
|
||||
createdProjects
|
||||
}
|
||||
}
|
||||
advance('done')
|
||||
} catch (err: any) {
|
||||
return fail(err?.body ?? err?.message ?? String(err))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
rowWritten,
|
||||
rowRolledBack,
|
||||
claims,
|
||||
createdProjects
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* What a database lets the data table's role do, answered by the worker rather than by the API
|
||||
* server.
|
||||
*
|
||||
* It runs as a preview job for the same reason `TestConnection` does: a job goes through the
|
||||
* worker's Postgres executor, so IAM and Azure workload identity authenticate as the worker
|
||||
* will when a real query runs. A connection opened from the API server proves something about
|
||||
* the API server, which is a different machine with a different identity.
|
||||
*
|
||||
* Postgres composes the suggested statements itself through `format('%I')`, so identifier
|
||||
* quoting stays where it is already implemented.
|
||||
*/
|
||||
|
||||
import { JobService, type Preview, type TestDataTableConnectionResponse } from '$lib/gen'
|
||||
import { tryEvery } from '$lib/utils'
|
||||
|
||||
const PRIVILEGES = `SELECT current_user AS usr,
|
||||
current_schema() AS sch,
|
||||
has_schema_privilege(current_schema(), 'CREATE') AS can_create_table,
|
||||
has_database_privilege(current_database(), 'CREATE') AS can_create_schema,
|
||||
to_regclass('_wm_migrations') IS NOT NULL AS has_migrations_table,
|
||||
-- A role whose search_path names no valid schema has a NULL current_schema(), and
|
||||
-- format('%I', NULL) raises rather than returning NULL, which would fail the whole
|
||||
-- query on the one case fix_search_path exists to report.
|
||||
CASE WHEN current_schema() IS NULL THEN NULL
|
||||
ELSE format('GRANT CREATE ON SCHEMA %I TO %I', current_schema(), current_user)
|
||||
END AS grant_schema,
|
||||
format('GRANT CREATE ON DATABASE %I TO %I', current_database(), current_user) AS grant_database,
|
||||
format('ALTER ROLE %I SET search_path = public', current_user) AS fix_search_path`
|
||||
|
||||
type Row = {
|
||||
usr?: string
|
||||
sch?: string | null
|
||||
can_create_table?: boolean
|
||||
can_create_schema?: boolean
|
||||
has_migrations_table?: boolean
|
||||
grant_schema?: string | null
|
||||
grant_database?: string | null
|
||||
fix_search_path?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* `database` is whatever a Postgres step takes: the resource value, or a `$res:` path the
|
||||
* worker resolves. Throws with the database's own message when the query fails, and after
|
||||
* `timeout` when no worker picks the job up.
|
||||
*/
|
||||
export async function probeDatatableConnection(
|
||||
workspace: string,
|
||||
database: Record<string, any> | string,
|
||||
// Longer than the 20s the worker allows its own Postgres connect, or a host that accepts
|
||||
// the connection and never answers -- a firewall with no rule for the workers, which this
|
||||
// check exists to catch -- is cancelled first and reported as a missing worker.
|
||||
timeout = 30000
|
||||
): Promise<TestDataTableConnectionResponse> {
|
||||
const job = await JobService.runScriptPreview({
|
||||
workspace,
|
||||
requestBody: {
|
||||
path: 'testConnection: datatable',
|
||||
language: 'postgresql' as Preview['language'],
|
||||
content: PRIVILEGES,
|
||||
args: { database }
|
||||
}
|
||||
})
|
||||
|
||||
let completed: Awaited<ReturnType<typeof JobService.getCompletedJob>> | undefined = undefined
|
||||
await tryEvery({
|
||||
tryCode: async () => {
|
||||
completed = await JobService.getCompletedJob({ workspace, id: job })
|
||||
},
|
||||
timeoutCode: async () => {
|
||||
await JobService.cancelQueuedJob({
|
||||
workspace,
|
||||
id: job,
|
||||
requestBody: { reason: 'The connection check did not start' }
|
||||
}).catch(() => {})
|
||||
},
|
||||
interval: 500,
|
||||
timeout
|
||||
})
|
||||
|
||||
if (!completed) {
|
||||
throw new Error(
|
||||
'The connection check did not run. Is a worker listening to the postgresql tag available?'
|
||||
)
|
||||
}
|
||||
const done = completed as { success: boolean; result?: any }
|
||||
if (!done.success) {
|
||||
throw new Error(done.result?.error?.message ?? 'Could not connect to the database')
|
||||
}
|
||||
|
||||
const row: Row = (Array.isArray(done.result) ? done.result[0] : done.result) ?? {}
|
||||
// Suggested only where the privilege is actually missing; Postgres returns NULL for a
|
||||
// statement it could not name, which is the case where no grant would help anyway.
|
||||
const suggested_grants = [
|
||||
row.can_create_table ? undefined : (row.grant_schema ?? undefined),
|
||||
row.can_create_schema ? undefined : (row.grant_database ?? undefined)
|
||||
].filter((s): s is string => !!s)
|
||||
|
||||
return {
|
||||
user: row.usr ?? '',
|
||||
schema: row.sch ?? null,
|
||||
can_create_table: !!row.can_create_table,
|
||||
can_create_schema: !!row.can_create_schema,
|
||||
migrations_table_exists: !!row.has_migrations_table,
|
||||
suggested_grants,
|
||||
suggested_search_path: row.sch ? undefined : (row.fix_search_path ?? undefined)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { CustomInstanceDb } from '$lib/gen'
|
||||
import { runningFrom, type SetupStep } from '../wizards/SetupChecklist.svelte'
|
||||
|
||||
/**
|
||||
* The same checks as [`instanceDbSteps`], in the vocabulary the wizard's checklist speaks.
|
||||
* Nothing is reported until the call returns, so an unreported step is either the failure
|
||||
* (when the call errored) or simply not reached yet.
|
||||
*/
|
||||
export function instanceSetupSteps(
|
||||
dbname: string,
|
||||
status: CustomInstanceDb | undefined,
|
||||
running: boolean
|
||||
): SetupStep[] {
|
||||
let firstUnreported = true
|
||||
const steps = instanceDbSteps(dbname, status).map((step): SetupStep => {
|
||||
if (step.status === 'OK') return { ...step, status: 'done' }
|
||||
if (step.status === 'FAIL') return { ...step, status: 'failed' }
|
||||
if (step.status === 'SKIP') return { ...step, status: 'skipped' }
|
||||
const failed = firstUnreported && !!status?.error
|
||||
firstUnreported = false
|
||||
return { ...step, status: failed ? 'failed' : 'pending' }
|
||||
})
|
||||
return runningFrom(steps, running)
|
||||
}
|
||||
|
||||
/**
|
||||
* The checks `setup_custom_instance_db` reports, in the order it runs them. Shared so the
|
||||
* setup modal and the data table wizard describe the same failure the same way.
|
||||
*/
|
||||
export function instanceDbSteps(dbname: string, status: CustomInstanceDb | undefined) {
|
||||
return [
|
||||
{
|
||||
title: 'Super admin required',
|
||||
status: status?.logs.super_admin,
|
||||
description:
|
||||
'You need to be a super admin to create a new database in the Windmill PostgreSQL instance'
|
||||
},
|
||||
{
|
||||
title: 'Retrieve and parse database credentials',
|
||||
status: status?.logs.database_credentials,
|
||||
description:
|
||||
'Windmill uses the DATABASE_URL or DATABASE_URL_FILE environment variable to connect to the PostgreSQL instance. Make sure it is correctly set'
|
||||
},
|
||||
{
|
||||
title: 'Database name is valid',
|
||||
status: status?.logs.valid_dbname,
|
||||
description:
|
||||
'The database name must be alphanumeric (underscores and hyphens allowed) and cannot be named the same as the Windmill database (usually "windmill")'
|
||||
},
|
||||
{
|
||||
title:
|
||||
'Create database' +
|
||||
(status?.logs.created_database === 'SKIP' ? ' (already exists, skipped)' : ''),
|
||||
status: status?.logs.created_database,
|
||||
description: `In the Windmill PostgreSQL instance, run: CREATE DATABASE "${dbname}".`
|
||||
},
|
||||
{
|
||||
title: `Connect to the ${dbname} database`,
|
||||
status: status?.logs.db_connect,
|
||||
description:
|
||||
"Connect to the newly created database with the default admin user (the one in DATABASE_URL, usually 'postgres') to run the next commands"
|
||||
},
|
||||
{
|
||||
title: 'Grant permissions to custom_instance_user',
|
||||
status: status?.logs.grant_permissions,
|
||||
description:
|
||||
'Gives custom_instance_user the required permissions to use the database. custom_instance_user is already created during a migration and has an auto-generated password stored in global_settings.custom_instance_pg_databases.user_pwd. These are the commands : \n\n' +
|
||||
`GRANT CONNECT ON DATABASE "${dbname}" TO custom_instance_user;\n` +
|
||||
'GRANT USAGE ON SCHEMA public TO custom_instance_user;\n' +
|
||||
'GRANT CREATE ON SCHEMA public TO custom_instance_user;\n' +
|
||||
`GRANT CREATE ON DATABASE "${dbname}" TO custom_instance_user;\n` +
|
||||
'ALTER DEFAULT PRIVILEGES IN SCHEMA public \n' +
|
||||
' GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES\n TO custom_instance_user;\n' +
|
||||
'ALTER ROLE custom_instance_user CREATEROLE;'
|
||||
},
|
||||
{
|
||||
title: 'Grant replication to custom_instance_replication_user',
|
||||
status: status?.logs.replication_user,
|
||||
description:
|
||||
'Postgres triggers on custom-instance datatables connect as custom_instance_replication_user, whose password is stored in global_settings.custom_instance_replication_pwd. The role is cluster-wide, so it is created on the Windmill PostgreSQL instance rather than on this database : \n\n' +
|
||||
'ALTER ROLE custom_instance_replication_user REPLICATION;\n' +
|
||||
'GRANT custom_instance_user TO custom_instance_replication_user;\n\n' +
|
||||
'Setting REPLICATION requires a superuser on PostgreSQL 15 and older. Managed instances never grant one, so on AWS RDS Windmill falls back to GRANT rds_replication TO custom_instance_replication_user. The database stays usable for datatables if this step fails, but postgres triggers on them do not.' +
|
||||
(status?.logs.replication_user_error
|
||||
? `\n\nError: ${status.logs.replication_user_error}`
|
||||
: '')
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
anythingClaimed,
|
||||
claim,
|
||||
claimsFromJSON,
|
||||
claimsToJSON,
|
||||
noClaims,
|
||||
release,
|
||||
stillOurs
|
||||
} from './setupClaims'
|
||||
|
||||
describe('stillOurs', () => {
|
||||
it('honours a claim whose object has not moved', () => {
|
||||
const claims = claim(noClaims, 'secret', 'f/team/db', 'alice')
|
||||
expect(stillOurs(claims, 'secret', 'f/team/db', 'alice')).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses when the object was last written by somebody else', () => {
|
||||
const claims = claim(noClaims, 'secret', 'f/team/db', 'alice')
|
||||
expect(stillOurs(claims, 'secret', 'f/team/db', 'bob')).toBe(false)
|
||||
})
|
||||
|
||||
// Deleted and recreated between two attempts: something is there, it is not ours.
|
||||
it('refuses when the object is gone', () => {
|
||||
const claims = claim(noClaims, 'resource', 'f/team/db', 'alice')
|
||||
expect(stillOurs(claims, 'resource', 'f/team/db', undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses a path this run never claimed', () => {
|
||||
expect(stillOurs(noClaims, 'secret', 'f/team/db', 'alice')).toBe(false)
|
||||
})
|
||||
|
||||
// The secret and the resource are separate objects at one path.
|
||||
it('keeps the two objects at one path apart', () => {
|
||||
const claims = claim(noClaims, 'secret', 'f/team/db', 'alice')
|
||||
expect(stillOurs(claims, 'secret', 'f/team/db', 'alice')).toBe(true)
|
||||
expect(stillOurs(claims, 'resource', 'f/team/db', 'alice')).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses a row repointed since it was written', () => {
|
||||
const claims = claim(noClaims, 'row', 'main', 'f/team/db')
|
||||
expect(stillOurs(claims, 'row', 'main', 'f/team/db')).toBe(true)
|
||||
expect(stillOurs(claims, 'row', 'main', 'someone-elses-db')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('claims as a set', () => {
|
||||
it('replaces the mark when the same object is claimed again', () => {
|
||||
let claims = claim(noClaims, 'row', 'main', 'first')
|
||||
claims = claim(claims, 'row', 'main', 'second')
|
||||
expect(claims).toHaveLength(1)
|
||||
expect(stillOurs(claims, 'row', 'main', 'second')).toBe(true)
|
||||
})
|
||||
|
||||
it('gives a claim up so the name is free again', () => {
|
||||
const claims = release(claim(noClaims, 'row', 'main', 'x'), 'row', 'main')
|
||||
expect(anythingClaimed(claims)).toBe(false)
|
||||
})
|
||||
|
||||
it('carries every claim across the redirect, whatever kinds are held', () => {
|
||||
let claims = claim(noClaims, 'secret', 'f/team/db', 'alice')
|
||||
claims = claim(claims, 'resource', 'f/team/db', 'alice')
|
||||
claims = claim(claims, 'row', 'main', 'f/team/db')
|
||||
const restored = claimsFromJSON(JSON.parse(JSON.stringify(claimsToJSON(claims))))
|
||||
expect(restored).toEqual(claims)
|
||||
})
|
||||
|
||||
it('survives a payload that is not claims at all', () => {
|
||||
expect(claimsFromJSON(undefined)).toEqual(noClaims)
|
||||
expect(claimsFromJSON([{ kind: 'nonsense', path: 'p', mark: 'm' }])).toEqual(noClaims)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* What a setup run created, and whether it is still there.
|
||||
*
|
||||
* Try again re-runs the whole plan, so every write meets what the previous attempt left behind
|
||||
* and has to answer one question: is the thing at this path the thing I made? Writing over its
|
||||
* own work is required; writing over another admin's destroys a password Supabase shows once.
|
||||
*
|
||||
* A claim therefore carries a **mark** — the discriminator to compare against the object as it
|
||||
* is now, rather than trusting that whatever sits at a remembered path is ours.
|
||||
*
|
||||
* Values, not runes, so the ownership matrix is testable without mounting a component.
|
||||
*/
|
||||
|
||||
export type ClaimKind = 'secret' | 'resource' | 'row'
|
||||
|
||||
export type Claim = {
|
||||
kind: ClaimKind
|
||||
path: string
|
||||
/**
|
||||
* Compared against the live object. It has to move whenever anyone else writes: `edited_at`
|
||||
* for a secret and a resource — an author survives an edit and so cannot tell one from no
|
||||
* edit at all — and the target for a row.
|
||||
*/
|
||||
mark: string
|
||||
}
|
||||
|
||||
export type Claims = readonly Claim[]
|
||||
|
||||
export const noClaims: Claims = []
|
||||
|
||||
function sameObject(a: Claim, kind: ClaimKind, path: string): boolean {
|
||||
return a.kind === kind && a.path === path
|
||||
}
|
||||
|
||||
/** Re-claiming an object replaces its mark. */
|
||||
export function claim(claims: Claims, kind: ClaimKind, path: string, mark: string): Claims {
|
||||
return [...claims.filter((c) => !sameObject(c, kind, path)), { kind, path, mark }]
|
||||
}
|
||||
|
||||
export function claimOf(claims: Claims, kind: ClaimKind, path: string): Claim | undefined {
|
||||
return claims.find((c) => sameObject(c, kind, path))
|
||||
}
|
||||
|
||||
/** Given up when a run takes its own object back out, so the path is free again. */
|
||||
export function release(claims: Claims, kind: ClaimKind, path: string): Claims {
|
||||
return claims.filter((c) => !sameObject(c, kind, path))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the object now at `path` is the one this run claimed. `observed` is the mark read back
|
||||
* from the live object; `undefined` means nothing is there.
|
||||
*/
|
||||
export function stillOurs(
|
||||
claims: Claims,
|
||||
kind: ClaimKind,
|
||||
path: string,
|
||||
observed: string | undefined
|
||||
): boolean {
|
||||
const held = claimOf(claims, kind, path)
|
||||
return !!held && observed !== undefined && held.mark === observed
|
||||
}
|
||||
|
||||
export function anythingClaimed(claims: Claims): boolean {
|
||||
return claims.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Carried across the full-page redirect the blocked-popup Supabase leg falls back to. No secret
|
||||
* travels: a mark is a timestamp or a resource path.
|
||||
*/
|
||||
export function claimsToJSON(claims: Claims): Claim[] {
|
||||
return [...claims]
|
||||
}
|
||||
|
||||
const KINDS: ClaimKind[] = ['secret', 'resource', 'row']
|
||||
|
||||
export function claimsFromJSON(value: unknown): Claims {
|
||||
if (!Array.isArray(value)) return noClaims
|
||||
return value.filter(
|
||||
(c): c is Claim =>
|
||||
!!c &&
|
||||
typeof c === 'object' &&
|
||||
typeof (c as Claim).path === 'string' &&
|
||||
typeof (c as Claim).mark === 'string' &&
|
||||
KINDS.includes((c as Claim).kind)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { fromStore } from 'svelte/store'
|
||||
import { base } from '$lib/base'
|
||||
import { oauthStore } from '$lib/stores'
|
||||
|
||||
const OAUTH_WINDOW = 'windmill_supabase_oauth'
|
||||
const CONNECT_URL = `${base}/api/oauth/connect/supabase_wizard`
|
||||
|
||||
/**
|
||||
* The Supabase authorization leg, driven from a popup.
|
||||
*
|
||||
* A full-page redirect unmounts whatever opened it, so a user who stops to create a Supabase
|
||||
* account lands on their dashboard with nothing left pointing back. Keeping the flow in a
|
||||
* popup keeps the host on screen, and keeps the window ours to steer: after they sign up we
|
||||
* send the same popup back through the connect endpoint and consent follows.
|
||||
*/
|
||||
export function useSupabaseOauth(
|
||||
opts: {
|
||||
onPopupBlocked?: () => void
|
||||
/**
|
||||
* Where popups are blocked, navigate this tab instead of opening a new one. Only for
|
||||
* hosts that can be resumed afterwards -- a caller whose state dies with the page (a
|
||||
* half-filled form) must leave this off and keep the user where they are.
|
||||
*/
|
||||
redirectIfBlocked?: boolean
|
||||
/** Even the new tab was refused, so the caller has to say so rather than sit loading. */
|
||||
onFallbackBlocked?: () => void
|
||||
/** The window went away without authorizing; the caller can drop its own waiting state. */
|
||||
onAbandoned?: () => void
|
||||
/**
|
||||
* Authorization came back and the token is in the store. Reported like the failures
|
||||
* above so a caller does not have to watch `authed` to find out. Fires on any successful
|
||||
* authorization, this caller's or another's -- every instance listens on the same window
|
||||
* -- so a caller that acts on it has to know it was the one waiting.
|
||||
*/
|
||||
onAuthed?: () => void
|
||||
} = {}
|
||||
) {
|
||||
const oauth = fromStore(oauthStore)
|
||||
let pending = $state(false)
|
||||
let win: Window | null = null
|
||||
let abandonWatch: ReturnType<typeof setInterval> | undefined = undefined
|
||||
|
||||
$effect(() => {
|
||||
function onMessage(e: MessageEvent) {
|
||||
if (e.origin !== window.location.origin || e.data?.type !== 'supabase_oauth') return
|
||||
oauthStore.set(e.data.res)
|
||||
pending = false
|
||||
clearInterval(abandonWatch)
|
||||
win?.close()
|
||||
opts.onAuthed?.()
|
||||
}
|
||||
window.addEventListener('message', onMessage)
|
||||
return () => {
|
||||
window.removeEventListener('message', onMessage)
|
||||
clearInterval(abandonWatch)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Nothing arrives if the user closes the window, denies consent, or wanders off to create
|
||||
* an account first -- which is a link this flow deliberately offers. Watch for the window
|
||||
* going away, so the button comes back instead of staying disabled until a page reload.
|
||||
*/
|
||||
function watchForAbandon() {
|
||||
clearInterval(abandonWatch)
|
||||
abandonWatch = setInterval(() => {
|
||||
if (!win || win.closed) {
|
||||
clearInterval(abandonWatch)
|
||||
pending = false
|
||||
opts.onAbandoned?.()
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
return {
|
||||
get token(): string | undefined {
|
||||
return oauth.current?.access_token
|
||||
},
|
||||
get authed(): boolean {
|
||||
return !!oauth.current?.access_token
|
||||
},
|
||||
get pending(): boolean {
|
||||
return pending
|
||||
},
|
||||
/** Opens (or re-points) the popup, falling back to a new tab where popups are blocked. */
|
||||
connect() {
|
||||
win = window.open(CONNECT_URL, OAUTH_WINDOW, 'width=600,height=820')
|
||||
if (!win) {
|
||||
opts.onPopupBlocked?.()
|
||||
if (opts.redirectIfBlocked) {
|
||||
window.location.href = CONNECT_URL
|
||||
return
|
||||
}
|
||||
// No `noopener`: the callback hands the token back through `window.opener`, and
|
||||
// severing that is what would leave the host waiting forever. The URL is our own
|
||||
// origin, so there is nothing to protect against here.
|
||||
win = window.open(CONNECT_URL, '_blank')
|
||||
if (!win) {
|
||||
opts.onFallbackBlocked?.()
|
||||
return
|
||||
}
|
||||
}
|
||||
pending = true
|
||||
watchForAbandon()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Supabase Management API calls, proxied through Windmill's backend.
|
||||
*
|
||||
* The Management API sends no access-control-allow-origin, so the browser cannot call it
|
||||
* directly -- every request below goes through /api/oauth/*, which forwards the user's OAuth
|
||||
* access token.
|
||||
*/
|
||||
|
||||
import { DEFAULT_SSLMODE } from '$lib/utils/postgresConnectionString'
|
||||
import { base } from '$lib/base'
|
||||
import { oauthStore } from '$lib/stores'
|
||||
import { get } from 'svelte/store'
|
||||
|
||||
export type SupabaseOrg = { id: string; slug?: string; name: string }
|
||||
|
||||
export type SupabaseProject = {
|
||||
/** `id` is Supabase's deprecated spelling of `ref`; both are sent today. */
|
||||
id?: string
|
||||
ref?: string
|
||||
name: string
|
||||
region: string
|
||||
status?: string
|
||||
organization_slug?: string
|
||||
organization_id?: string
|
||||
database?: { host: string }
|
||||
}
|
||||
|
||||
/** One Supavisor endpoint of a project. A project has one per mode and replica. */
|
||||
export type SupabasePooler = {
|
||||
database_type: 'PRIMARY' | 'READ_REPLICA'
|
||||
pool_mode: 'transaction' | 'session'
|
||||
db_user: string
|
||||
db_host: string
|
||||
db_port: number
|
||||
db_name: string
|
||||
}
|
||||
|
||||
export type SupabaseConnectionMode = 'session' | 'direct'
|
||||
|
||||
/** Supabase deprecated `id` in favour of `ref`, and still sends both. */
|
||||
export function projectRef(project: SupabaseProject): string {
|
||||
return project.ref ?? project.id ?? ''
|
||||
}
|
||||
|
||||
export function projectOrg(project: SupabaseProject): string | undefined {
|
||||
return project.organization_slug ?? project.organization_id
|
||||
}
|
||||
|
||||
/** Region codes accepted by region_selection, with the names Supabase shows for them. */
|
||||
export const SUPABASE_REGIONS: { code: string; label: string }[] = [
|
||||
{ code: 'us-east-1', label: 'East US (N. Virginia)' },
|
||||
{ code: 'us-west-1', label: 'West US (N. California)' },
|
||||
{ code: 'eu-central-1', label: 'Central EU (Frankfurt)' },
|
||||
{ code: 'eu-west-1', label: 'West EU (Ireland)' },
|
||||
{ code: 'eu-west-3', label: 'West EU (Paris)' },
|
||||
{ code: 'ap-southeast-1', label: 'Southeast Asia (Singapore)' },
|
||||
{ code: 'ap-northeast-1', label: 'Northeast Asia (Tokyo)' }
|
||||
]
|
||||
|
||||
export const DEFAULT_SUPABASE_REGION = 'eu-central-1'
|
||||
|
||||
function headers(token: string): HeadersInit {
|
||||
return { 'Content-Type': 'application/json', 'X-Supabase-Token': token }
|
||||
}
|
||||
|
||||
async function unwrap(res: Response, what: string): Promise<any> {
|
||||
if (!res.ok) {
|
||||
// Supabase access tokens are short-lived while `oauthStore` lasts as long as the tab, so
|
||||
// a stale one otherwise leaves every caller "authorized" and unable to reach the button
|
||||
// that would fix it. Forgetting it here is what puts Connect back on screen.
|
||||
if (res.status === 401) oauthStore.set(undefined)
|
||||
const body = await res.text()
|
||||
throw new Error(`${what}: ${supabaseErrorMessage(body) || res.statusText}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Supabase answers with `{ message }` or `{ error }` and occasionally plain text.
|
||||
* Surfacing the raw body puts a JSON blob in front of the user, so unwrap it to
|
||||
* the sentence inside.
|
||||
*/
|
||||
export function supabaseErrorMessage(body: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(body)
|
||||
return parsed?.message ?? parsed?.error ?? parsed?.msg ?? body
|
||||
} catch {
|
||||
return body
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSupabaseOrgs(token: string): Promise<SupabaseOrg[]> {
|
||||
const res = await fetch(`${base}/api/oauth/list_supabase_orgs`, { headers: headers(token) })
|
||||
return unwrap(res, 'Could not list your Supabase organizations')
|
||||
}
|
||||
|
||||
export async function listSupabaseProjects(token: string): Promise<SupabaseProject[]> {
|
||||
const res = await fetch(`${base}/api/oauth/list_supabase`, { headers: headers(token) })
|
||||
return unwrap(res, 'Could not list your Supabase projects')
|
||||
}
|
||||
|
||||
/** Plan of one organization, which the list endpoint does not carry. */
|
||||
export async function getSupabaseOrgPlan(token: string, slug: string): Promise<string | undefined> {
|
||||
try {
|
||||
const res = await fetch(`${base}/api/oauth/get_supabase_org/${slug}`, {
|
||||
headers: headers(token)
|
||||
})
|
||||
if (!res.ok) return undefined
|
||||
return (await res.json())?.plan
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** organization_slug is what create takes; older payloads only carry an id. */
|
||||
export function orgSlug(org: SupabaseOrg): string {
|
||||
return org.slug ?? org.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Supabase never lets a database password be read back, so the only way to know it is to be
|
||||
* the one who set it: db_pass is an input to project creation.
|
||||
*/
|
||||
export function generateDbPassword(): string {
|
||||
const charset = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||
const values = new Uint32Array(32)
|
||||
crypto.getRandomValues(values)
|
||||
return Array.from(values, (v) => charset[v % charset.length]).join('')
|
||||
}
|
||||
|
||||
export async function createSupabaseProject(
|
||||
token: string,
|
||||
args: { name: string; organizationSlug: string; region: string; dbPass: string }
|
||||
): Promise<SupabaseProject> {
|
||||
const res = await fetch(`${base}/api/oauth/create_supabase_project`, {
|
||||
method: 'POST',
|
||||
headers: headers(token),
|
||||
body: JSON.stringify({
|
||||
name: args.name,
|
||||
organization_slug: args.organizationSlug,
|
||||
db_pass: args.dbPass,
|
||||
// region_selection is { type: 'specific' | 'smartGroup', code }. Neither the published
|
||||
// docs nor the OpenAPI spec describe it correctly (they give `kind`/`region` and
|
||||
// `primary`) -- this shape comes from the API's own validation errors, so do not
|
||||
// "correct" it against the documentation.
|
||||
region_selection: { type: 'specific', code: args.region }
|
||||
})
|
||||
})
|
||||
return unwrap(res, 'Supabase refused to create the project')
|
||||
}
|
||||
|
||||
/**
|
||||
* Creation returns immediately with the project still coming up, so the pooler is not
|
||||
* reachable yet. Poll until Supabase reports it healthy before trying to connect.
|
||||
*/
|
||||
export async function waitUntilSupabaseHealthy(
|
||||
token: string,
|
||||
projectId: string,
|
||||
onStatus?: (status: string | undefined) => void,
|
||||
attempts = 60
|
||||
): Promise<SupabaseProject> {
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
await new Promise((r) => setTimeout(r, 5000))
|
||||
let list: SupabaseProject[]
|
||||
try {
|
||||
list = await listSupabaseProjects(token)
|
||||
} catch (err) {
|
||||
// A transient failure is worth another poll; an expired token is not -- retrying it
|
||||
// burns five minutes and then reports a timeout, which names the wrong problem.
|
||||
if (!get(oauthStore)?.access_token) throw err
|
||||
continue
|
||||
}
|
||||
const project = list?.find?.((p) => projectRef(p) === projectId)
|
||||
if (project?.status === 'ACTIVE_HEALTHY') return project
|
||||
onStatus?.(project?.status)
|
||||
}
|
||||
throw new Error('Timed out waiting for the project to become reachable')
|
||||
}
|
||||
|
||||
/**
|
||||
* The session-mode Supavisor endpoint of the project's primary database.
|
||||
*
|
||||
* Which pooler a project sits behind is assigned by Supabase, not derived from its
|
||||
* region: constructing `aws-0-<region>.pooler.supabase.com` is wrong for every project
|
||||
* that landed on another one, and the resulting resource never connects.
|
||||
*/
|
||||
export async function getSupabasePooler(token: string, projectId: string): Promise<SupabasePooler> {
|
||||
const res = await fetch(`${base}/api/oauth/get_supabase_pooler/${projectId}`, {
|
||||
headers: headers(token)
|
||||
})
|
||||
const configs: SupabasePooler[] = await unwrap(res, 'Could not read the connection details')
|
||||
const primary = configs.filter((c) => c.database_type === 'PRIMARY')
|
||||
const pooler = primary.find((c) => c.pool_mode === 'session') ?? primary[0] ?? configs[0]
|
||||
if (!pooler) throw new Error('Supabase returned no connection details for this project')
|
||||
return pooler
|
||||
}
|
||||
|
||||
export type SupabaseConnection = {
|
||||
mode: SupabaseConnectionMode
|
||||
pooler?: SupabasePooler
|
||||
/** Why session pooling was asked for and not used. Absent when nothing was given up. */
|
||||
unavailable?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The endpoint a project should be reached through, degrading rather than failing. Reading the
|
||||
* pooler config needs the `database_pooling_config_read` scope, which an instance's OAuth app
|
||||
* may not have. A direct connection still works where the workers have IPv6, so fall back to
|
||||
* it and say so.
|
||||
*/
|
||||
export async function resolveSupabaseConnection(
|
||||
token: string,
|
||||
project: SupabaseProject,
|
||||
mode: SupabaseConnectionMode
|
||||
): Promise<SupabaseConnection> {
|
||||
if (mode !== 'session') return { mode }
|
||||
try {
|
||||
return { mode, pooler: await getSupabasePooler(token, projectRef(project)) }
|
||||
} catch (err) {
|
||||
return { mode: 'direct', unavailable: err instanceof Error ? err.message : String(err) }
|
||||
}
|
||||
}
|
||||
|
||||
/** The resource value for a project, given the endpoint it should connect through. */
|
||||
export function supabaseResourceValue(
|
||||
project: SupabaseProject,
|
||||
passwordVarPath: string,
|
||||
connection: { mode: SupabaseConnectionMode; pooler?: SupabasePooler }
|
||||
) {
|
||||
const direct = connection.mode === 'direct' || !connection.pooler
|
||||
return {
|
||||
host: direct
|
||||
? (project.database?.host ?? `db.${projectRef(project)}.supabase.co`)
|
||||
: connection.pooler!.db_host,
|
||||
user: direct ? 'postgres' : connection.pooler!.db_user,
|
||||
port: direct ? 5432 : connection.pooler!.db_port,
|
||||
dbname: direct ? 'postgres' : connection.pooler!.db_name,
|
||||
// Supabase terminates TLS on every endpoint it hands out, and this connection carries a
|
||||
// generated password, so there is no reason to leave a plaintext fallback open.
|
||||
sslmode: DEFAULT_SSLMODE,
|
||||
password: `$var:${passwordVarPath}`,
|
||||
// Resource forms fill in every unset property from the schema as soon as they render,
|
||||
// so a postgresql resource saved without these comes up already modified -- and saves a
|
||||
// draft -- the first time anyone opens it. Write them here so opening one is a no-op.
|
||||
// (accept_invalid_certs renders conditionally and is not seeded, so it stays out.)
|
||||
region: '',
|
||||
root_certificate_pem: '',
|
||||
use_iam_auth: false
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,20 @@
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
import { superadmin } from '$lib/stores'
|
||||
import { getLocalSetting } from '$lib/utils'
|
||||
import { derived } from 'svelte/store'
|
||||
|
||||
/**
|
||||
* Opt-in for the data table setup wizard while it is being tested. Browser-local and read
|
||||
* once per page: `localStorage.setItem('dataTableWizard', 'true')`, then reload. With it
|
||||
* off, adding a data table falls back to the inline row in the settings table.
|
||||
*/
|
||||
export const DATATABLE_WIZARD_SETTING_NAME = 'dataTableWizard'
|
||||
|
||||
export function isDataTableWizardEnabled(): boolean {
|
||||
return getLocalSetting(DATATABLE_WIZARD_SETTING_NAME) === 'true'
|
||||
}
|
||||
|
||||
export let isCustomInstanceDbEnabled = derived(
|
||||
[superadmin],
|
||||
([superadmin_]) => superadmin_ && !isCloudHosted()
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Where popups are blocked the Supabase leg falls back to a full-page redirect, which
|
||||
* unmounts the wizard. What the user had chosen is parked here and picked back up by the
|
||||
* settings page when Supabase sends them home.
|
||||
*
|
||||
* Kept out of the wizard component so the OAuth callback route can ask whether anything is
|
||||
* parked without pulling the whole wizard into that page's bundle.
|
||||
*/
|
||||
|
||||
import type { SupabaseConnectionMode, SupabaseOrg, SupabaseProject } from './supabaseProvisioning'
|
||||
import type { Claim } from './setupClaims'
|
||||
import type { CreatedProject } from './addDataTableModel'
|
||||
|
||||
const RESUME_KEY = 'datatable_wizard_resume'
|
||||
|
||||
export type WizardResume = {
|
||||
name: string
|
||||
region: string
|
||||
projectName: string
|
||||
/**
|
||||
* What the interrupted run had already created. Without these the resumed run meets its
|
||||
* own secret variable and resource as somebody else's and refuses to write over them,
|
||||
* which strands the Supabase project it just paid for. No secret is parked -- these are
|
||||
* paths, and the password they name is already in the workspace.
|
||||
*/
|
||||
resourcePath?: string
|
||||
/** Everything the run holds, serialised whole so a newly added kind cannot be left behind. */
|
||||
claims?: Claim[]
|
||||
/** Every project created before the redirect, each still guarding its password's path. */
|
||||
createdProjects?: CreatedProject[]
|
||||
/**
|
||||
* Which side of the step-2 toggle the run was on, and where it was pointed. A run that
|
||||
* died mid-create otherwise comes back on `existing`, is asked for the password it
|
||||
* generated and never showed anyone, and looks for its project in whichever organization
|
||||
* happens to be first.
|
||||
*/
|
||||
mode?: 'existing' | 'create'
|
||||
org?: SupabaseOrg
|
||||
/** The project that was picked. Without it a resume selects the first in the list, which is
|
||||
* a different database from the one whose password the user had already typed. */
|
||||
project?: SupabaseProject
|
||||
connectionMode?: SupabaseConnectionMode
|
||||
}
|
||||
|
||||
/** True while a wizard run is waiting on the Supabase redirect to come back. */
|
||||
export function hasParkedWizard(): boolean {
|
||||
return sessionStorage.getItem(RESUME_KEY) != null
|
||||
}
|
||||
|
||||
export function parkWizard(state: WizardResume) {
|
||||
sessionStorage.setItem(RESUME_KEY, JSON.stringify(state))
|
||||
}
|
||||
|
||||
export function takeParkedWizard(): WizardResume | undefined {
|
||||
const raw = sessionStorage.getItem(RESUME_KEY)
|
||||
sessionStorage.removeItem(RESUME_KEY)
|
||||
if (!raw) return undefined
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
composePostgresConnectionString,
|
||||
connectionParamRefusal,
|
||||
parsePostgresConnectionString,
|
||||
unsupportedConnectionParam
|
||||
} from './postgresConnectionString'
|
||||
|
||||
// Two callers depend on this producing the same resource value from the same string:
|
||||
// the resource form's "From connection string", and the data table wizard.
|
||||
describe('parsePostgresConnectionString', () => {
|
||||
it('reads every part of a full URI', () => {
|
||||
expect(
|
||||
parsePostgresConnectionString('postgres://u:p@db.example.com:6543/mydb?sslmode=require')
|
||||
).toEqual({
|
||||
user: 'u',
|
||||
password: 'p',
|
||||
host: 'db.example.com',
|
||||
port: 6543,
|
||||
dbname: 'mydb',
|
||||
sslmode: 'require'
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves optional parts undefined rather than empty', () => {
|
||||
expect(parsePostgresConnectionString('postgresql://u@host/')).toEqual({
|
||||
user: 'u',
|
||||
password: undefined,
|
||||
host: 'host',
|
||||
port: undefined,
|
||||
dbname: undefined,
|
||||
sslmode: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('returns undefined for anything that is not a postgres URI', () => {
|
||||
expect(parsePostgresConnectionString('mysql://u:p@host/db')).toBeUndefined()
|
||||
expect(parsePostgresConnectionString('')).toBeUndefined()
|
||||
})
|
||||
|
||||
// Verified against psql: `postgres://role:p%40ss@host/db` authenticates as `p@ss`, and an
|
||||
// unencoded `@` puts the rest of the password in libpq's host too. Reading these any other
|
||||
// way would make the same string mean something here that it means nowhere else.
|
||||
it('decodes percent escapes in credentials, as libpq does', () => {
|
||||
expect(parsePostgresConnectionString('postgres://u:p%40ss@host/db')?.password).toBe('p@ss')
|
||||
expect(parsePostgresConnectionString('postgres://u%40corp:p@host/db')?.user).toBe('u@corp')
|
||||
})
|
||||
})
|
||||
|
||||
// The wizard offers the same connection as a string or as fields and switches between them
|
||||
// by composing and reparsing. A password holding a character the URI reserves is the case
|
||||
// that breaks silently: it comes back wrong rather than failing to parse.
|
||||
describe('composePostgresConnectionString', () => {
|
||||
// `prefer` is libpq's default, so it is the one a composer is tempted to leave out -- and
|
||||
// the one that silently becomes `require` when the wizard reparses the string and falls
|
||||
// back to its own default. It is a weaker TLS setting chosen on purpose; it has to survive.
|
||||
it('keeps an explicit prefer through the round trip', () => {
|
||||
const parts = { user: 'u', host: 'h', port: undefined, dbname: 'db', sslmode: 'prefer' }
|
||||
const composed = composePostgresConnectionString(parts)
|
||||
expect(composed).toContain('sslmode=prefer')
|
||||
expect(parsePostgresConnectionString(composed)?.sslmode).toBe('prefer')
|
||||
})
|
||||
|
||||
// The wizard composes this from fields, so a database name holding a character the URI
|
||||
// reserves has to survive the toggle. `?` is the one that truncates silently: the parser
|
||||
// reads everything after it as the query string.
|
||||
it('round-trips a database name holding reserved characters', () => {
|
||||
const parts = { user: 'u', host: 'h', dbname: 'sales?archive', sslmode: 'require' }
|
||||
expect(parsePostgresConnectionString(composePostgresConnectionString(parts))?.dbname).toBe(
|
||||
'sales?archive'
|
||||
)
|
||||
})
|
||||
|
||||
// A literal IPv6 address is all colons, so the URI brackets it and the resource stores it
|
||||
// bare. Both halves have to agree or the wizard's own toggle produces a string it rejects.
|
||||
it('brackets an IPv6 host and reads it back bare', () => {
|
||||
const composed = composePostgresConnectionString({
|
||||
user: 'u',
|
||||
host: '2001:db8::1',
|
||||
port: 5432,
|
||||
dbname: 'db'
|
||||
})
|
||||
expect(composed).toContain('@[2001:db8::1]:5432/')
|
||||
expect(parsePostgresConnectionString(composed)?.host).toBe('2001:db8::1')
|
||||
expect(parsePostgresConnectionString('postgres://u:p@[2001:db8::1]/db')?.host).toBe(
|
||||
'2001:db8::1'
|
||||
)
|
||||
})
|
||||
|
||||
it('round-trips through parse', () => {
|
||||
const parts = {
|
||||
user: 'u@corp',
|
||||
password: 'p@ss/w:rd',
|
||||
host: 'db.example.com',
|
||||
port: 6543,
|
||||
dbname: 'mydb',
|
||||
sslmode: 'require'
|
||||
}
|
||||
expect(parsePostgresConnectionString(composePostgresConnectionString(parts))).toEqual(parts)
|
||||
})
|
||||
})
|
||||
|
||||
// A parameter the resource has no field for is not a preference that can be dropped: it decides
|
||||
// where data lands, or how the connection is verified. The check is an allowlist because the
|
||||
// dangerous ones are precisely the ones a hand-written denylist would miss.
|
||||
describe('unsupportedConnectionParam', () => {
|
||||
it('names a parameter that decides where data lands', () => {
|
||||
expect(unsupportedConnectionParam('postgres://u:p@h/db?options=-csearch_path%3Dtenant')).toBe(
|
||||
'options'
|
||||
)
|
||||
expect(unsupportedConnectionParam('postgres://u:p@h/db?search_path=tenant')).toBe('search_path')
|
||||
})
|
||||
|
||||
// Dropping these saves a *weaker* connection than the one pasted.
|
||||
it('names a parameter that decides how the connection is secured or routed', () => {
|
||||
expect(unsupportedConnectionParam('postgres://u:p@h/db?sslrootcert=system')).toBe('sslrootcert')
|
||||
expect(unsupportedConnectionParam('postgres://u:p@h/db?channel_binding=require')).toBe(
|
||||
'channel_binding'
|
||||
)
|
||||
expect(unsupportedConnectionParam('postgres://u:p@h/db?target_session_attrs=read-write')).toBe(
|
||||
'target_session_attrs'
|
||||
)
|
||||
})
|
||||
|
||||
// The backend applies its own connect timeout, so accepting one and dropping it would make
|
||||
// `connect_timeout=1` mean a twenty-second wait.
|
||||
it('names a parameter whose behaviour the backend overrides', () => {
|
||||
expect(unsupportedConnectionParam('postgres://u:p@h/db?connect_timeout=1')).toBe(
|
||||
'connect_timeout'
|
||||
)
|
||||
})
|
||||
|
||||
// `sslmode=` also occurs inside another parameter's value, and reading it there turns TLS
|
||||
// off behind a string that never asked for it -- past the allowlist, since the parameter
|
||||
// actually carrying it is one we accept.
|
||||
it('reads sslmode by name, not from anywhere it appears in the query', () => {
|
||||
const disguised = 'postgres://u:p@h/db?application_name=sslmode=disable'
|
||||
expect(unsupportedConnectionParam(disguised)).toBeUndefined()
|
||||
expect(parsePostgresConnectionString(disguised)?.sslmode).toBeUndefined()
|
||||
})
|
||||
|
||||
// libpq rejects `?SslMode=` as an invalid URI query parameter rather than folding it, so a
|
||||
// string carrying one does not connect anywhere. Naming it is the honest answer; honouring
|
||||
// it would save a resource from a URI Postgres itself refuses.
|
||||
it('refuses a parameter whose name is not the one libpq accepts', () => {
|
||||
const shouted = 'postgres://u:p@h/db?SslMode=verify-full'
|
||||
expect(unsupportedConnectionParam(shouted)).toBe('SslMode')
|
||||
expect(parsePostgresConnectionString(shouted)?.sslmode).toBeUndefined()
|
||||
})
|
||||
|
||||
// libpq takes the last of a repeated parameter. Taking the first reads a weaker mode than
|
||||
// the string actually asks for.
|
||||
it('takes the last value of a repeated parameter', () => {
|
||||
expect(
|
||||
parsePostgresConnectionString('postgres://u:p@h/db?sslmode=disable&sslmode=require')?.sslmode
|
||||
).toBe('require')
|
||||
})
|
||||
|
||||
it('ignores the one it can store, and the ones that cost nothing', () => {
|
||||
expect(unsupportedConnectionParam('postgres://u:p@h/db?sslmode=require')).toBeUndefined()
|
||||
expect(unsupportedConnectionParam('postgres://u:p@h/db?application_name=wm')).toBeUndefined()
|
||||
expect(unsupportedConnectionParam('postgres://u:p@h/db')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// One refusal reached the user through two very different causes, and the wrong explanation
|
||||
// sends them to fix the wrong thing: respelling a parameter this resource cannot store changes
|
||||
// nothing, and removing one it can store loses what the string asked for.
|
||||
describe('connectionParamRefusal', () => {
|
||||
it('blames the spelling only when the parameter is one the resource keeps', () => {
|
||||
expect(connectionParamRefusal('postgres://u:p@h/db?SslMode=verify-full')).toContain(
|
||||
'case-sensitive'
|
||||
)
|
||||
expect(connectionParamRefusal('postgres://u:p@h/db?SslMode=verify-full')).toContain('sslmode')
|
||||
})
|
||||
|
||||
it('blames the resource when respelling would not help', () => {
|
||||
const refusal = connectionParamRefusal('postgres://u:p@h/db?Connect_Timeout=1')
|
||||
expect(refusal).toContain('cannot store')
|
||||
expect(refusal).not.toContain('case-sensitive')
|
||||
})
|
||||
|
||||
it('says nothing about a string it can save', () => {
|
||||
expect(connectionParamRefusal('postgres://u:p@h/db?sslmode=require')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* `postgres://user:password@host:5432/dbname?sslmode=require` in both directions.
|
||||
*
|
||||
* Shared by the resource form and the data table wizard: both turn a pasted
|
||||
* connection string into a `postgresql` resource value, and the two drifting
|
||||
* apart would mean the same string produced two different resources.
|
||||
*
|
||||
* The wizard offers the same connection as a string or as fields and lets the
|
||||
* user switch, so parse and compose have to be inverses: whatever one produces,
|
||||
* the other must read back unchanged.
|
||||
*
|
||||
* libpq is the arbiter of what a connection string means, so this follows it rather than
|
||||
* RFC 3986 where they differ: credentials are split at the *first* `@` -- an unencoded one
|
||||
* lands in the host for libpq too -- and percent escapes in them are decoded, so `p%40ss`
|
||||
* authenticates as `p@ss`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The host alternation is what admits IPv6: a literal address is full of colons, so a URI
|
||||
* has to bracket it (`@[2001:db8::1]:5432/`) and the brackets are what tell the port apart
|
||||
* from the address. Brackets are stripped on the way in and added back on the way out, so
|
||||
* what is stored is the bare address a Postgres client wants.
|
||||
*/
|
||||
const CONNECTION_STRING =
|
||||
/postgres(?:ql)?:\/\/(?<user>[^:@]+)(?::(?<password>[^@]+))?@(?<host>\[[^\]]+\]|[^:\/?]+)(?::(?<port>\d+))?\/(?<dbname>[^\?]+)?/
|
||||
|
||||
/**
|
||||
* The query parameters, read the way libpq reads them: names are case-sensitive — `SslMode` is
|
||||
* rejected outright as an invalid URI query parameter, not folded to `sslmode` — and a name
|
||||
* repeated takes its last value. One reader for both the parser and the allowlist below, or
|
||||
* they disagree about what a string says and a name is refused by neither and honoured by
|
||||
* neither.
|
||||
*/
|
||||
function paramsOf(connectionString: string): Map<string, string> {
|
||||
const query = connectionString.split('?').slice(1).join('?')
|
||||
const params = new Map<string, string>()
|
||||
if (!query) return params
|
||||
new URLSearchParams(query).forEach((value, name) => params.set(name, value))
|
||||
return params
|
||||
}
|
||||
|
||||
/**
|
||||
* A database someone types into Windmill is almost never localhost, so callers ask for TLS
|
||||
* where libpq would settle for `prefer`. A string that names its own `sslmode` keeps it.
|
||||
*/
|
||||
export const DEFAULT_SSLMODE = 'require'
|
||||
|
||||
export type PostgresConnectionParts = {
|
||||
user: string
|
||||
password?: string
|
||||
host: string
|
||||
port?: number
|
||||
dbname?: string
|
||||
sslmode?: string
|
||||
}
|
||||
|
||||
/** A lone `%` is not an escape, and a password is free to contain one. */
|
||||
function decode(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
/** Undefined when the string is not a postgres URI. */
|
||||
export function parsePostgresConnectionString(
|
||||
connectionString: string
|
||||
): PostgresConnectionParts | undefined {
|
||||
const match = connectionString.match(CONNECTION_STRING)
|
||||
if (!match?.groups) return undefined
|
||||
const { user, password, host, port, dbname } = match.groups
|
||||
// By parameter name, never by searching the query text: `sslmode=` also occurs inside
|
||||
// another parameter's *value*, and a substring match there reads someone's
|
||||
// `application_name=sslmode=disable` as a request to turn TLS off.
|
||||
const sslmode = paramsOf(connectionString).get('sslmode')
|
||||
return {
|
||||
user: decode(user),
|
||||
password: password ? decode(password) : undefined,
|
||||
host: host.startsWith('[') ? host.slice(1, -1) : host,
|
||||
port: port ? Number(port) : undefined,
|
||||
dbname: dbname ? decode(dbname) : undefined,
|
||||
sslmode: sslmode || undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** The only query parameter the `postgresql` resource has a field for. */
|
||||
const REPRESENTABLE_PARAMS = ['sslmode']
|
||||
|
||||
/**
|
||||
* Parameters that change nothing about what the connection reaches, how it is secured, or how
|
||||
* it behaves, so losing them costs the user nothing. `connect_timeout` is deliberately not one
|
||||
* of them: the backend applies its own fixed timeout, so honouring it is not on offer.
|
||||
*/
|
||||
const COSMETIC_PARAMS = ['application_name']
|
||||
|
||||
/**
|
||||
* The name of a parameter this string carries that the resource cannot honour. An allowlist,
|
||||
* not a list of known-bad names: libpq keeps adding parameters, and the ones that matter are
|
||||
* the ones that would be missed. Dropping one silently saves a connection weaker or simply
|
||||
* other than the one pasted, behind a probe that reports success.
|
||||
*/
|
||||
export function unsupportedConnectionParam(connectionString: string): string | undefined {
|
||||
for (const name of paramsOf(connectionString).keys()) {
|
||||
if (!REPRESENTABLE_PARAMS.includes(name) && !COSMETIC_PARAMS.includes(name)) return name
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the string cannot be saved, in the terms the reader needs. Two refusals come out of the
|
||||
* check above and they call for opposite fixes: a name Postgres does not accept at all, where
|
||||
* the parameter itself is fine and only its spelling is wrong, and a parameter this resource
|
||||
* has no field for, where respelling it changes nothing.
|
||||
*/
|
||||
export function connectionParamRefusal(connectionString: string): string | undefined {
|
||||
const name = unsupportedConnectionParam(connectionString)
|
||||
if (!name) return undefined
|
||||
const lower = name.toLowerCase()
|
||||
const storableWhenSpelledRight =
|
||||
REPRESENTABLE_PARAMS.includes(lower) || COSMETIC_PARAMS.includes(lower)
|
||||
return storableWhenSpelledRight
|
||||
? `Postgres does not accept ${name}: connection parameter names are case-sensitive. Write it as ${lower}.`
|
||||
: `Windmill cannot store ${name} on a Postgres resource, and ignoring it would connect differently from what this string asks for. Remove it, or set the connection with the fields.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Every part that was set is emitted, `sslmode` included. Leaving `prefer` out because it is
|
||||
* libpq's own default would be shorter, but it does not survive the trip: a caller that
|
||||
* reparses this string gets `undefined` back and substitutes its own default, which is how an
|
||||
* explicit `prefer` silently became `require`. Whatever this produces, `parse` must read back.
|
||||
*/
|
||||
export function composePostgresConnectionString(parts: PostgresConnectionParts): string {
|
||||
const credentials = parts.password
|
||||
? `${encodeURIComponent(parts.user)}:${encodeURIComponent(parts.password)}`
|
||||
: encodeURIComponent(parts.user)
|
||||
const port = parts.port ? `:${parts.port}` : ''
|
||||
const query = parts.sslmode ? `?sslmode=${parts.sslmode}` : ''
|
||||
const dbname = parts.dbname ? encodeURIComponent(parts.dbname) : ''
|
||||
// A bare IPv6 address would put its own colons where the port separator goes.
|
||||
const host =
|
||||
parts.host.includes(':') && !parts.host.startsWith('[') ? `[${parts.host}]` : parts.host
|
||||
return `postgres://${credentials}@${host}${port}/${dbname}${query}`
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
import { onMount } from 'svelte'
|
||||
import { OauthService } from '$lib/gen'
|
||||
import { oauthStore } from '$lib/stores'
|
||||
import { hasParkedWizard } from '$lib/components/workspaceSettings/wizardParking'
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
@@ -15,25 +16,63 @@
|
||||
let code = page.url.searchParams.get('code') ?? undefined
|
||||
let state = page.url.searchParams.get('state') ?? undefined
|
||||
|
||||
/**
|
||||
* As the wizard's popup there is no page to land on: the tab behind us is still showing the
|
||||
* flow and is watching for this window to go away. Leaving it open on a full Windmill page
|
||||
* is what strands the caller's button spinning, and declining consent is a normal outcome,
|
||||
* not an edge case.
|
||||
*/
|
||||
function closeIfPopup(): boolean {
|
||||
if (!window.opener) return false
|
||||
window.close()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a failed leg lands when this is not a popup. A parked run has to be handed back its
|
||||
* own page: nothing else consumes the park, so sending it to `/resources` leaves the run in
|
||||
* `sessionStorage` to spring the wizard open on some unrelated later visit.
|
||||
*/
|
||||
function failureDestination(): string {
|
||||
return hasParkedWizard() ? '/workspace_settings?tab=windmill_data_tables' : '/resources'
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (error) {
|
||||
if (closeIfPopup()) return
|
||||
sendUserToast(`Error trying to fetch projects from windmill: ${error}`, true)
|
||||
goto('/resources')
|
||||
goto(failureDestination())
|
||||
} else if (code && state) {
|
||||
try {
|
||||
const res = await OauthService.connectCallback({
|
||||
clientName: client_name,
|
||||
requestBody: { code, state }
|
||||
})
|
||||
// Opened as the data table wizard's popup: hand the token to the tab that is still
|
||||
// sitting on the wizard and get out of the way, so nothing has to be resumed.
|
||||
if (window.opener) {
|
||||
window.opener.postMessage({ type: 'supabase_oauth', res }, window.location.origin)
|
||||
window.close()
|
||||
return
|
||||
}
|
||||
$oauthStore = res
|
||||
goto(`/resources?callback=${client_name}`)
|
||||
// The data table wizard parks its state before redirecting, so it can be resumed
|
||||
// where it left off. Everything else lands on the resources page, which opens the
|
||||
// Supabase drawer for this callback.
|
||||
if (hasParkedWizard()) {
|
||||
goto(`/workspace_settings?tab=windmill_data_tables&callback=${client_name}`)
|
||||
} else {
|
||||
goto(`/resources?callback=${client_name}`)
|
||||
}
|
||||
} catch (e) {
|
||||
if (closeIfPopup()) return
|
||||
sendUserToast(`Error parsing the response token, ${e.body}`, true)
|
||||
goto('/resources')
|
||||
goto(failureDestination())
|
||||
}
|
||||
} else {
|
||||
if (closeIfPopup()) return
|
||||
sendUserToast('Missing code or state as query params', true)
|
||||
goto('/resources')
|
||||
goto(failureDestination())
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user