mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-26 16:02:36 +00:00
feat(frontend): expose variable value expiration in the UI
The variable drawer takes an optional expiry on a secret value, and the variables list badges it, turning red once the date has passed. A workspace settings tab picks the handler, its extra args, and whether variables under `u/` are skipped. That mute flag is stored inside the handler setting, so the toggle stays disabled until a handler is picked: saving it on its own would clear the setting it lives in and still report success. The expiry is carried as a tri-state through the draft deploy and copilot paths — absent leaves the remote value alone, an explicit null clears it — so deploying a draft cannot silently drop a date someone set elsewhere. The settings tab names the one case where the configured identity does not apply: a handler script carrying `on_behalf_of` runs as that user rather than as `g/variable_expiration_handler`, which matters because the same tab asks the admin to grant that group write access to the variables it rotates. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
35bde36730
commit
5782757d0c
@@ -287,7 +287,8 @@
|
||||
path,
|
||||
value: v.value ?? '',
|
||||
is_secret: v.is_secret ?? false,
|
||||
description: v.description ?? ''
|
||||
description: v.description ?? '',
|
||||
value_expires_at: v.value_expires_at ?? undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1069,8 +1069,9 @@
|
||||
<li>git sync repo count (sync vs promotion mode)</li>
|
||||
<li
|
||||
>feature usage (counts of which product features are used, including AI provider and
|
||||
model identifiers, the names of public hub scripts used, and the plan tier and quota
|
||||
shown when the execution meter is opened, last 30 days)</li
|
||||
model identifiers, the names of public hub scripts used, the plan tier and quota shown
|
||||
when the execution meter is opened, and whether a variable whose value expired was a
|
||||
secret, last 30 days)</li
|
||||
>
|
||||
<li
|
||||
>feature adoption (counts of which flow, script, trigger and worker features your
|
||||
@@ -1121,8 +1122,9 @@
|
||||
<li>development instance status</li>
|
||||
<li
|
||||
>feature usage (counts of which product features are used, including AI provider and
|
||||
model identifiers, the names of public hub scripts used, and the plan tier and quota
|
||||
shown when the execution meter is opened, last 30 days)</li
|
||||
model identifiers, the names of public hub scripts used, the plan tier and quota shown
|
||||
when the execution meter is opened, and whether a variable whose value expired was a
|
||||
secret, last 30 days)</li
|
||||
>
|
||||
<li
|
||||
>feature adoption (counts of which flow, script, trigger and worker features your
|
||||
|
||||
@@ -26,14 +26,18 @@
|
||||
import LocalDraftBanner from './LocalDraftBanner.svelte'
|
||||
import { isEncryptedDraftValue } from '$lib/encryptedDraft'
|
||||
import { setLocalDraftHint } from '$lib/localDraftHints.svelte'
|
||||
import { logFeatureUsage } from '$lib/utils/featureUsage'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
// Subset of VariableDraftState (copilot/chat/global/workspaceItems.ts) — the same
|
||||
// localStorage rows back both, so a field this shape omits is dropped on save.
|
||||
type VariableState = {
|
||||
path: string
|
||||
variable: { value: string; is_secret: boolean; description: string }
|
||||
labels: string[] | undefined
|
||||
wsSpecific: boolean
|
||||
value_expires_at?: string
|
||||
}
|
||||
|
||||
// The "current" workspace this editor defaults New/Edit actions to. Session
|
||||
@@ -186,7 +190,8 @@
|
||||
description: v.description ?? ''
|
||||
},
|
||||
labels: v.labels ?? undefined,
|
||||
wsSpecific: v.ws_specific ?? false
|
||||
wsSpecific: v.ws_specific ?? false,
|
||||
value_expires_at: v.value_expires_at ?? undefined
|
||||
}
|
||||
// Open with the saved draft if present, else the deployed.
|
||||
const s: VariableState = savedDraftState ?? deployedState
|
||||
@@ -253,6 +258,12 @@
|
||||
|
||||
async function save(): Promise<void> {
|
||||
const dirty = dirtyWorkspaces
|
||||
// Counted before the loop rewrites the baselines, and once per save rather than
|
||||
// once per workspace, so a multi-workspace edit is one act of setting an expiry.
|
||||
const expiryEdited = dirty.some(
|
||||
(ws) => initialStates[ws]?.value_expires_at !== states[ws].draft?.value_expires_at
|
||||
)
|
||||
const expiryNowSet = dirty.some((ws) => !!states[ws].draft?.value_expires_at)
|
||||
try {
|
||||
for (const ws of dirty) {
|
||||
const s = states[ws].draft!
|
||||
@@ -270,6 +281,11 @@
|
||||
ini.variable.description != s.variable.description
|
||||
? s.variable.description
|
||||
: undefined,
|
||||
// Tri-state: absent leaves the stored date alone, `null` clears it.
|
||||
value_expires_at:
|
||||
ini.value_expires_at != s.value_expires_at
|
||||
? (s.value_expires_at ?? null)
|
||||
: undefined,
|
||||
labels: s.labels,
|
||||
ws_specific: s.wsSpecific
|
||||
}
|
||||
@@ -282,6 +298,7 @@
|
||||
value: s.variable.value,
|
||||
is_secret: s.variable.is_secret,
|
||||
description: s.variable.description,
|
||||
value_expires_at: s.value_expires_at,
|
||||
labels: s.labels,
|
||||
ws_specific: s.wsSpecific
|
||||
}
|
||||
@@ -298,6 +315,11 @@
|
||||
// it shows up immediately instead of after the 60s TTL.
|
||||
invalidateWorkspacePaths(ws)
|
||||
}
|
||||
if (expiryEdited) {
|
||||
logFeatureUsage('variable_expiration', 'expiry_edited', {
|
||||
key: expiryNowSet ? 'set' : 'cleared'
|
||||
})
|
||||
}
|
||||
sendUserToast(edit ? `Updated variable in ${dirty.length} workspace(s)` : `Created variable`)
|
||||
dispatch('create')
|
||||
drawer?.closeDrawer()
|
||||
@@ -350,6 +372,7 @@
|
||||
bind:variable={current.variable}
|
||||
bind:labels={current.labels}
|
||||
bind:wsSpecific={current.wsSpecific}
|
||||
bind:valueExpiresAt={current.value_expires_at}
|
||||
{initialPath}
|
||||
deployTo={deployTo.current}
|
||||
{can_write}
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
import { isOwner } from '$lib/utils'
|
||||
import { isEncryptedDraftValue } from '$lib/encryptedDraft'
|
||||
import EncryptedDraftField from './EncryptedDraftField.svelte'
|
||||
import DateTimeInput from './DateTimeInput.svelte'
|
||||
import { base } from '$lib/base'
|
||||
|
||||
interface Variable {
|
||||
value: string
|
||||
@@ -28,6 +30,7 @@
|
||||
variable: Variable
|
||||
labels: string[] | undefined
|
||||
wsSpecific: boolean
|
||||
valueExpiresAt: string | undefined
|
||||
deployTo: string | undefined
|
||||
can_write: boolean
|
||||
edit: boolean
|
||||
@@ -43,6 +46,7 @@
|
||||
variable = $bindable(),
|
||||
labels = $bindable(),
|
||||
wsSpecific = $bindable(),
|
||||
valueExpiresAt = $bindable(),
|
||||
deployTo,
|
||||
can_write,
|
||||
edit,
|
||||
@@ -204,3 +208,29 @@
|
||||
<textarea rows="4" use:autosize bind:value={variable.description} placeholder="Used for X"
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<Label
|
||||
label="Value expiration"
|
||||
tooltip="When the value stored here stops working, such as the expiry date of an API key. Windmill runs the workspace's variable expiration handler an hour before it so the value can be rotated. The variable itself is never deleted."
|
||||
>
|
||||
<!-- Unbound: DateTimeInput writes `null` on clear, and a `null` where the deployed
|
||||
baseline has nothing reads as an edit, so every empty state has to normalize to
|
||||
`undefined` before it reaches the draft row. -->
|
||||
<DateTimeInput
|
||||
value={valueExpiresAt}
|
||||
clearable
|
||||
useDropdown
|
||||
disabled={!can_write}
|
||||
on:change={(e) => (valueExpiresAt = e.detail ?? undefined)}
|
||||
on:clear={() => (valueExpiresAt = undefined)}
|
||||
/>
|
||||
<span class="text-2xs font-normal text-hint">
|
||||
{#if valueExpiresAt && new Date(valueExpiresAt).getTime() <= Date.now()}
|
||||
This date has passed. Set a new one once the value is rotated.
|
||||
{:else}
|
||||
Runs the workspace's <a href="{base}/workspace_settings?tab=variable_expiration"
|
||||
>variable expiration handler</a
|
||||
> an hour before this date. Without one, nothing happens.
|
||||
{/if}
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
@@ -927,6 +927,42 @@ describe('global AI tools', () => {
|
||||
expect(localStorageSnapshot()).not.toContain('new-secret-token')
|
||||
})
|
||||
|
||||
// Clearing an expiry once the value has been rotated is a normal edit, and the draft
|
||||
// state has no null to carry it — so an absent expiry has to deploy as an explicit
|
||||
// null, which the update endpoint reads as "clear" rather than "leave alone".
|
||||
it('clears a variable value expiry through to the deploy body', async () => {
|
||||
vi.mocked(VariableService.existsVariable).mockResolvedValueOnce(true) // write probe
|
||||
vi.mocked(VariableService.existsVariable).mockResolvedValueOnce(true) // deploy probe
|
||||
vi.mocked(VariableService.getVariable).mockResolvedValueOnce({
|
||||
path: 'f/secrets/api_key',
|
||||
value: 'tok',
|
||||
is_secret: false,
|
||||
description: 'd',
|
||||
value_expires_at: '2027-03-15T08:30:00Z'
|
||||
} as any)
|
||||
|
||||
await callGlobalTool('write_variable', {
|
||||
path: 'f/secrets/api_key',
|
||||
value_expires_at: null
|
||||
})
|
||||
|
||||
expect(
|
||||
getBackendDraft<any>('variable', 'f/secrets/api_key', { workspace: WORKSPACE })
|
||||
?.value_expires_at
|
||||
).toBeUndefined()
|
||||
|
||||
await callGlobalTool('deploy_workspace_item', {
|
||||
type: 'variable',
|
||||
path: 'f/secrets/api_key'
|
||||
})
|
||||
|
||||
expect(VariableService.updateVariable).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
requestBody: expect.objectContaining({ value_expires_at: null })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
// '' means "no value staged", so there is nothing to create the secret with.
|
||||
it('does not create a secret variable draft that stages no value', async () => {
|
||||
seedBackendDraft(
|
||||
|
||||
@@ -648,6 +648,17 @@ const writeVariableSchema = variableRequestSchema.extend({
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The description of the variable. Omit it to keep the current description.'),
|
||||
// Clearing an expiry is the normal move once a value has been rotated, and the
|
||||
// generated schema admits only a timestamp — leaving an omitted field and a cleared
|
||||
// one indistinguishable.
|
||||
value_expires_at: z
|
||||
.string()
|
||||
.datetime({ offset: true })
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"When the value stored in this variable stops working. Triggers the workspace's variable expiration handler one hour before it is reached; never causes the variable to be deleted. Omit it to keep the current expiry, or pass null to clear one — for instance once the value has been rotated."
|
||||
),
|
||||
override: draftOverrideField
|
||||
})
|
||||
|
||||
@@ -2203,7 +2214,7 @@ function getResourceInstructions(): string {
|
||||
- Global mode writes complete draft payloads only; it does not save, deploy, run, scaffold local files, or generate metadata.
|
||||
- A resource draft is a workspace item: \`{ type: 'resource', path, summary?, value, isDraft }\`. \`value\` is a CreateResource body: \`{ path, value, description?, resource_type, labels? }\` where the inner \`value\` is the resource type's data shape.
|
||||
- Reading a variable returns \`{ type: 'variable', path, summary?, isSecret, isDraft }\` — never its value, secret or not. \`isSecret\` tells you whether the value is encrypted.
|
||||
- \`write_variable\` takes \`{ path, value?, is_secret?, description?, account?, is_oauth?, expires_at?, labels? }\`. Creating a variable needs \`value\` and \`is_secret\`; editing one needs only the fields you are changing. Omitting \`value\` keeps the stored value, which is the only way to edit a secret variable — you cannot read its value, so passing any \`value\` you did not get from the user destroys it.
|
||||
- \`write_variable\` takes \`{ path, value?, is_secret?, description?, account?, is_oauth?, expires_at?, value_expires_at?, labels? }\`. Creating a variable needs \`value\` and \`is_secret\`; editing one needs only the fields you are changing. Omitting \`value\` keeps the stored value, which is the only way to edit a secret variable — you cannot read its value, so passing any \`value\` you did not get from the user destroys it.
|
||||
- For secret fields in a resource value, do NOT inline the raw secret. Create a Variable first with \`is_secret: true\`, then in the resource value reference it as \`"$var:path/to/variable"\`.
|
||||
- Reference formats inside resource values: \`$var:g/all/name\` (global), \`$var:u/user/name\` (user), \`$var:f/folder/name\` (folder). Reference another resource with \`$res:path/to/resource\`. These are references FROM a resource value; never store a \`$var:\` string as a variable's own value.
|
||||
- When deploying drafts that depend on each other (e.g., a resource and the variables it references), deploy the variables first.
|
||||
@@ -4590,7 +4601,8 @@ function variableToDraftState(variable: ListableVariable): VariableDraftState {
|
||||
// reviews. Undefined drops out of the JSON instead.
|
||||
account: variable.account ?? undefined,
|
||||
is_oauth: variable.is_oauth ?? undefined,
|
||||
expires_at: variable.expires_at ?? undefined
|
||||
expires_at: variable.expires_at ?? undefined,
|
||||
value_expires_at: variable.value_expires_at ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4660,7 +4672,12 @@ function createVariableToDraftState(
|
||||
wsSpecific: args.ws_specific ?? base?.wsSpecific ?? false,
|
||||
account: args.account ?? base?.account,
|
||||
is_oauth: args.is_oauth ?? base?.is_oauth,
|
||||
expires_at: args.expires_at ?? base?.expires_at
|
||||
expires_at: args.expires_at ?? base?.expires_at,
|
||||
// `null` clears, an omitted field keeps what the base holds. The draft state itself
|
||||
// has no null — an absent expiry is `undefined` there, so that a cleared one
|
||||
// survives the JSON round-trip a stored draft goes through as an absent key.
|
||||
value_expires_at:
|
||||
args.value_expires_at === null ? undefined : (args.value_expires_at ?? base?.value_expires_at)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4679,10 +4696,18 @@ function buildVariableCreateRequestBody(draftValue: CreateVariable): CreateVaria
|
||||
// endpoint, and a draft stores '' when it stages no new value — so omitting `value` in
|
||||
// that case is what leaves the stored one untouched. A staged value is sent as-is: the
|
||||
// endpoint decrypts an `$encrypted:` marker and encrypts plaintext.
|
||||
function buildVariableUpdateRequestBody(
|
||||
draftValue: CreateVariable
|
||||
): Omit<CreateVariable, 'value'> & { value?: string } {
|
||||
const { value, ...rest } = structuredClone(draftValue)
|
||||
function buildVariableUpdateRequestBody(draftValue: CreateVariable): Omit<
|
||||
CreateVariable,
|
||||
'value' | 'value_expires_at'
|
||||
> & {
|
||||
value?: string
|
||||
value_expires_at?: string | null
|
||||
} {
|
||||
const { value, value_expires_at, ...others } = structuredClone(draftValue)
|
||||
// The draft is seeded from the deployed variable, so an absent expiry means this edit
|
||||
// cleared it and has to be sent as an explicit null — omitting it would read as "leave
|
||||
// alone". Same full-state semantics as the value below.
|
||||
const rest = { ...others, value_expires_at: value_expires_at ?? null }
|
||||
// A value the draft is never allowed to carry — a secret's, and an OAuth-managed one
|
||||
// dropped by `variableToDraftState` — reaches here as '' whenever this edit staged
|
||||
// none, so sending it would blank the stored value or wipe a rotating token.
|
||||
|
||||
@@ -235,6 +235,7 @@ function variableDraftToWorkspaceItem(path: string, draft: VariableDraftState):
|
||||
account: draft.account,
|
||||
is_oauth: draft.is_oauth,
|
||||
expires_at: draft.expires_at,
|
||||
value_expires_at: draft.value_expires_at,
|
||||
labels: draft.labels,
|
||||
ws_specific: draft.wsSpecific
|
||||
},
|
||||
|
||||
@@ -108,6 +108,7 @@ export type VariableDraftState = {
|
||||
account?: number
|
||||
is_oauth?: boolean
|
||||
expires_at?: string
|
||||
value_expires_at?: string
|
||||
}
|
||||
|
||||
export type WorkspaceItem = {
|
||||
|
||||
@@ -489,7 +489,8 @@ export const variableRequestSchema = z.object({
|
||||
"description": z.string().describe("The description of the variable"),
|
||||
"account": z.number().int().describe("The account identifier").optional(),
|
||||
"is_oauth": z.boolean().describe("Whether the variable is an OAuth variable").optional(),
|
||||
"expires_at": z.string().datetime({ offset: true }).describe("The expiration date of the variable").optional(),
|
||||
"expires_at": z.string().datetime({ offset: true }).describe("When to garbage-collect the variable. Used for ephemeral secrets; the variable is deleted once this is reached. Not the value's expiry \u2014 see value_expires_at.\n").optional(),
|
||||
"value_expires_at": z.string().datetime({ offset: true }).describe("When the value stored in this variable stops working. Triggers the workspace's variable expiration handler one hour before it is reached; never causes the variable to be deleted.\n").optional(),
|
||||
"labels": z.array(z.string()).optional(),
|
||||
"ws_specific": z.boolean().optional()
|
||||
})
|
||||
|
||||
@@ -153,7 +153,12 @@ export const mcpEndpointTools: EndpointTool[] = [
|
||||
},
|
||||
"expires_at": {
|
||||
"type": "string",
|
||||
"description": "The expiration date of the variable",
|
||||
"description": "When to garbage-collect the variable. Used for ephemeral secrets; the variable is deleted once this is reached. Not the value's expiry \u2014 see value_expires_at.\n",
|
||||
"format": "date-time"
|
||||
},
|
||||
"value_expires_at": {
|
||||
"type": "string",
|
||||
"description": "When the value stored in this variable stops working. Triggers the workspace's variable expiration handler one hour before it is reached; never causes the variable to be deleted.\n",
|
||||
"format": "date-time"
|
||||
},
|
||||
"labels": {
|
||||
@@ -241,6 +246,12 @@ export const mcpEndpointTools: EndpointTool[] = [
|
||||
"type": "string",
|
||||
"description": "The new description of the variable"
|
||||
},
|
||||
"value_expires_at": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "When the value stored in this variable stops working. Omit to leave it unchanged; send null to clear it. Changing it re-arms the workspace's variable expiration handler.\n",
|
||||
"format": "date-time"
|
||||
},
|
||||
"labels": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
||||
@@ -6,7 +6,7 @@ vi.mock('$lib/gen', () => ({
|
||||
FlowService: { getFlowByPath: vi.fn(), createFlow: vi.fn(), updateFlow: vi.fn() },
|
||||
DraftService: { deleteDraft: vi.fn() },
|
||||
AppService: {},
|
||||
VariableService: {},
|
||||
VariableService: { getVariable: vi.fn(), createVariable: vi.fn(), updateVariable: vi.fn() },
|
||||
ResourceService: {},
|
||||
ScheduleService: {},
|
||||
HttpTriggerService: {},
|
||||
@@ -30,7 +30,7 @@ vi.mock('$lib/components/raw_apps/utils', () => ({ canonicalRawAppDiffValue: vi.
|
||||
vi.mock('$lib/appDiffSides', () => ({ classicAppDraftParts: vi.fn() }))
|
||||
vi.mock('$lib/utils_deployable', () => ({ TRIGGER_RUNTIME_IGNORE: [] }))
|
||||
|
||||
import { ScriptService, FlowService } from '$lib/gen'
|
||||
import { ScriptService, FlowService, VariableService } from '$lib/gen'
|
||||
|
||||
// draftBaseIsStale compares a draft's base pointer against the deployed head
|
||||
// of the item it was fetched with (`get_draft=true`). Shared by CompareDrafts
|
||||
@@ -124,3 +124,56 @@ describe('deployDraft preserves on_behalf_of', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// The variable deploy bodies are built field-by-field, so a field left out of them is
|
||||
// dropped with no error. `value_expires_at` is additionally tri-state on update — absent
|
||||
// leaves the stored date alone, null clears it — which is why it is diffed against the
|
||||
// deployed row rather than sent as-is.
|
||||
describe('deployDraft variable value_expires_at', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
const DATE = '2027-03-15T08:30:00.000Z'
|
||||
|
||||
function mockVariable(deployedExpiry: string | undefined, draftExpiry: string | undefined) {
|
||||
vi.mocked(VariableService.getVariable).mockResolvedValueOnce({
|
||||
path: 'u/admin/key',
|
||||
value: 'v',
|
||||
is_secret: false,
|
||||
description: '',
|
||||
value_expires_at: deployedExpiry,
|
||||
draft: {
|
||||
path: 'u/admin/key',
|
||||
variable: { value: 'v', is_secret: false, description: '' },
|
||||
value_expires_at: draftExpiry
|
||||
}
|
||||
} as any)
|
||||
}
|
||||
|
||||
const updateBody = () =>
|
||||
vi.mocked(VariableService.updateVariable).mock.calls[0][0].requestBody as any
|
||||
|
||||
it('sends a date the draft set on a variable that had none', async () => {
|
||||
mockVariable(undefined, DATE)
|
||||
await deployDraft('variable', 'u/admin/key', 'ws')
|
||||
expect(updateBody().value_expires_at).toBe(DATE)
|
||||
})
|
||||
|
||||
it('sends null when the draft cleared the deployed date', async () => {
|
||||
mockVariable(DATE, undefined)
|
||||
await deployDraft('variable', 'u/admin/key', 'ws')
|
||||
expect(updateBody().value_expires_at).toBeNull()
|
||||
})
|
||||
|
||||
it('omits it when the draft leaves the deployed date alone', async () => {
|
||||
mockVariable(DATE, DATE)
|
||||
await deployDraft('variable', 'u/admin/key', 'ws')
|
||||
expect(updateBody().value_expires_at).toBeUndefined()
|
||||
})
|
||||
|
||||
it('carries the date onto a draft-only variable', async () => {
|
||||
mockVariable(undefined, DATE)
|
||||
await deployDraft('variable', 'u/admin/key', 'ws', { draftOnly: true })
|
||||
const body = vi.mocked(VariableService.createVariable).mock.calls[0][0].requestBody as any
|
||||
expect(body.value_expires_at).toBe(DATE)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -205,14 +205,15 @@ function stripScheduleTriggerRuntime(row: any): Record<string, unknown> {
|
||||
function canonicalizeDraftDiffValue(kind: DraftKind, raw: any, isDraft: boolean): unknown {
|
||||
if (!raw || typeof raw !== 'object') return raw ?? {}
|
||||
if (kind === 'variable') {
|
||||
// draft: { variable: { value, is_secret, description }, labels, wsSpecific }
|
||||
// deployed row: { value, is_secret, description, labels, ws_specific }
|
||||
// draft: { variable: { value, is_secret, description }, value_expires_at, labels, wsSpecific }
|
||||
// deployed row: { value, is_secret, description, value_expires_at, labels, ws_specific }
|
||||
const v = isDraft ? (raw.variable ?? {}) : raw
|
||||
const is_secret = !!v.is_secret
|
||||
return {
|
||||
value: is_secret ? '<secret>' : (v.value ?? ''),
|
||||
is_secret,
|
||||
description: v.description ?? '',
|
||||
value_expires_at: raw.value_expires_at ?? undefined,
|
||||
labels: raw.labels ?? undefined,
|
||||
ws_specific: (isDraft ? raw.wsSpecific : raw.ws_specific) ?? undefined
|
||||
}
|
||||
@@ -549,7 +550,7 @@ export async function deployDraft(
|
||||
} else if (kind === 'variable') {
|
||||
const { deployed, draft: d } = splitOverlay(await OVERLAY_GETTERS.variable!(workspace, path))
|
||||
// VariableEditor's `VariableState` draft shape:
|
||||
// { path, variable: { value, is_secret, description }, labels?, wsSpecific }
|
||||
// { path, variable: { value, is_secret, description }, value_expires_at?, labels?, wsSpecific }
|
||||
if (draftOnly) {
|
||||
await VariableService.createVariable({
|
||||
workspace,
|
||||
@@ -558,6 +559,7 @@ export async function deployDraft(
|
||||
value: d.variable?.value ?? '',
|
||||
is_secret: !!d.variable?.is_secret,
|
||||
description: d.variable?.description ?? '',
|
||||
value_expires_at: d.value_expires_at,
|
||||
labels: d.labels,
|
||||
ws_specific: d.wsSpecific
|
||||
}
|
||||
@@ -572,12 +574,18 @@ export async function deployDraft(
|
||||
value: d.variable?.value === '' ? undefined : d.variable?.value,
|
||||
is_secret: d.variable?.is_secret,
|
||||
description: d.variable?.description,
|
||||
// Tri-state: absent leaves the stored date alone, `null` clears it. Diffed
|
||||
// against the deployed row because the draft carries no baseline of its own
|
||||
// and an absent date in it means "no expiry", not "unchanged".
|
||||
value_expires_at:
|
||||
(deployed.value_expires_at ?? undefined) !== d.value_expires_at
|
||||
? (d.value_expires_at ?? null)
|
||||
: undefined,
|
||||
labels: d.labels,
|
||||
ws_specific: d.wsSpecific
|
||||
}
|
||||
})
|
||||
}
|
||||
void deployed
|
||||
} else if (kind === 'resource') {
|
||||
const { deployed, draft: d } = splitOverlay(await OVERLAY_GETTERS.resource!(workspace, path))
|
||||
// ResourceEditor's `ResourceState` draft shape:
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
Shield,
|
||||
Trash,
|
||||
Building,
|
||||
CalendarClock,
|
||||
DollarSign,
|
||||
EyeOff,
|
||||
Circle,
|
||||
@@ -396,7 +397,7 @@
|
||||
</tr>
|
||||
</Head>
|
||||
<tbody class="divide-y">
|
||||
{#each filteredItems as { path, value, is_secret, description, extra_perms, canWrite, account, is_refreshed, is_expired, refresh_error, is_linked, labels, inherited_labels, ws_specific, draft_only, is_draft }}
|
||||
{#each filteredItems as { path, value, is_secret, description, extra_perms, canWrite, account, is_refreshed, is_expired, refresh_error, is_linked, labels, inherited_labels, ws_specific, draft_only, is_draft, value_expires_at }}
|
||||
{@const hasDraft =
|
||||
getLocalDraftHint($workspaceStore, 'variable', path) ?? is_draft}
|
||||
<Row>
|
||||
@@ -477,6 +478,22 @@
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
{#if value_expires_at}
|
||||
{@const expired = new Date(value_expires_at).getTime() <= Date.now()}
|
||||
<Popover notClickable>
|
||||
<CalendarClock
|
||||
size={16}
|
||||
class={expired ? 'text-red-600' : 'text-secondary'}
|
||||
/>
|
||||
{#snippet text()}
|
||||
<div>
|
||||
The value {expired ? 'expired' : 'expires'} on
|
||||
{new Date(value_expires_at).toLocaleString()}. The workspace's
|
||||
variable expiration handler runs an hour before that.
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
{#if account}
|
||||
<Popover notClickable>
|
||||
<RefreshCw size={16} />
|
||||
|
||||
@@ -184,6 +184,12 @@
|
||||
let errorHandlerMutedOnCancel: boolean | undefined = $state(undefined)
|
||||
let errorHandlerMutedOnUserPath: boolean | undefined = $state(undefined)
|
||||
let successHandlerScriptPath: string | undefined = $state(undefined)
|
||||
let variableExpirationPath: string | undefined = $state(undefined)
|
||||
let variableExpirationItemKind: 'flow' | 'script' = $state('script')
|
||||
let variableExpirationMutedOnUserPath: boolean = $state(false)
|
||||
// Carried through saves rather than edited: `extra_args` is an API/CLI-only escape hatch
|
||||
// for passing fixed arguments to the handler, and omitting it here would erase it.
|
||||
let variableExpirationExtraArgs: Record<string, any> | undefined = $state(undefined)
|
||||
let criticalAlertUIMuted: boolean | undefined = $state(undefined)
|
||||
let initialCriticalAlertUIMuted: boolean | undefined = $state(undefined)
|
||||
let publicAppRateLimitPerMinute: number | undefined = $state(undefined)
|
||||
@@ -237,6 +243,11 @@
|
||||
// Track initial success handler for unsaved changes detection
|
||||
let initialSuccessHandlerScriptPath: string | undefined = $state(undefined)
|
||||
|
||||
// Track initial variable expiration handler for unsaved changes detection
|
||||
let initialVariableExpirationPath: string | undefined = $state(undefined)
|
||||
let initialVariableExpirationItemKind: 'flow' | 'script' = $state('script')
|
||||
let initialVariableExpirationMutedOnUserPath: boolean = $state(false)
|
||||
|
||||
let s3ResourceSettings: S3ResourceSettings = $state({
|
||||
resourceType: 's3',
|
||||
resourcePath: undefined,
|
||||
@@ -287,6 +298,39 @@
|
||||
)
|
||||
})
|
||||
|
||||
// The stored setting is a single prefixed path, so the kind only counts as a change
|
||||
// while a path is selected — otherwise toggling script/flow on an empty picker reads
|
||||
// as unsaved work that would save nothing.
|
||||
function variableExpirationFullPath(kind: 'flow' | 'script', path: string | undefined) {
|
||||
return path ? `${kind}/${path}` : undefined
|
||||
}
|
||||
|
||||
// The mute flag is stored inside the handler setting, so saving with no path selected
|
||||
// clears the whole setting and drops it. Gating the toggle keeps the tab from offering
|
||||
// a change it cannot persist.
|
||||
let variableExpirationHasHandler = $derived(
|
||||
!!variableExpirationFullPath(variableExpirationItemKind, variableExpirationPath)
|
||||
)
|
||||
|
||||
// `handlerPath`, not `path`: `hasUnsavedChanges` blanks a key of that name, so a change
|
||||
// of handler would never register as one.
|
||||
let hasVariableExpirationChanges = $derived.by(() => {
|
||||
if (tab !== 'variable_expiration') return false
|
||||
return hasUnsavedChanges(
|
||||
{
|
||||
handlerPath: variableExpirationFullPath(
|
||||
initialVariableExpirationItemKind,
|
||||
initialVariableExpirationPath
|
||||
),
|
||||
mutedOnUserPath: initialVariableExpirationMutedOnUserPath
|
||||
},
|
||||
{
|
||||
handlerPath: variableExpirationFullPath(variableExpirationItemKind, variableExpirationPath),
|
||||
mutedOnUserPath: variableExpirationMutedOnUserPath
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// Derived state for checking unsaved changes in critical alert mute setting
|
||||
let hasCriticalAlertMuteChanges = $derived.by(() => {
|
||||
if (tab !== 'critical_alerts') return false
|
||||
@@ -379,6 +423,7 @@
|
||||
| 'dev_workspace'
|
||||
| 'error_handler'
|
||||
| 'success_handler'
|
||||
| 'variable_expiration'
|
||||
| 'critical_alerts'
|
||||
| 'ai'
|
||||
| 'windmill_data_tables'
|
||||
@@ -639,6 +684,14 @@
|
||||
| { path?: string; extra_args?: any }
|
||||
| undefined
|
||||
successHandlerScriptPath = (successHandler?.path ?? '').split('/').slice(1).join('/')
|
||||
const variableExpirationHandler = settings.variable_expiration_handler
|
||||
const variableExpirationHandlerPath = variableExpirationHandler?.path ?? ''
|
||||
variableExpirationItemKind = variableExpirationHandlerPath
|
||||
? (variableExpirationHandlerPath.split('/')[0] as 'flow' | 'script')
|
||||
: 'script'
|
||||
variableExpirationPath = variableExpirationHandlerPath.split('/').slice(1).join('/')
|
||||
variableExpirationMutedOnUserPath = variableExpirationHandler?.muted_on_user_path ?? false
|
||||
variableExpirationExtraArgs = variableExpirationHandler?.extra_args
|
||||
workspaceDefaultAppPath = settings.default_app
|
||||
initialWorkspaceDefaultAppPath = settings.default_app
|
||||
|
||||
@@ -691,6 +744,11 @@
|
||||
// Store initial success handler state for unsaved changes detection
|
||||
initialSuccessHandlerScriptPath = successHandlerScriptPath
|
||||
|
||||
// Store initial variable expiration handler state for unsaved changes detection
|
||||
initialVariableExpirationPath = variableExpirationPath
|
||||
initialVariableExpirationItemKind = variableExpirationItemKind
|
||||
initialVariableExpirationMutedOnUserPath = variableExpirationMutedOnUserPath
|
||||
|
||||
loadedSettings = true
|
||||
}
|
||||
|
||||
@@ -862,6 +920,35 @@
|
||||
initialErrorHandlerMutedOnUserPath = errorHandlerMutedOnUserPath
|
||||
}
|
||||
|
||||
async function editVariableExpirationHandler() {
|
||||
const path = variableExpirationFullPath(variableExpirationItemKind, variableExpirationPath)
|
||||
await WorkspaceService.editVariableExpirationHandler({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
path,
|
||||
extra_args: variableExpirationExtraArgs,
|
||||
muted_on_user_path: variableExpirationMutedOnUserPath
|
||||
}
|
||||
})
|
||||
sendUserToast(
|
||||
path
|
||||
? `Variable expiration handler set to ${path}`
|
||||
: initialVariableExpirationPath
|
||||
? 'Variable expiration handler removed'
|
||||
: 'Variable expiration settings saved'
|
||||
)
|
||||
|
||||
initialVariableExpirationPath = variableExpirationPath
|
||||
initialVariableExpirationItemKind = variableExpirationItemKind
|
||||
initialVariableExpirationMutedOnUserPath = variableExpirationMutedOnUserPath
|
||||
}
|
||||
|
||||
function discardVariableExpirationChanges() {
|
||||
variableExpirationPath = initialVariableExpirationPath
|
||||
variableExpirationItemKind = initialVariableExpirationItemKind
|
||||
variableExpirationMutedOnUserPath = initialVariableExpirationMutedOnUserPath
|
||||
}
|
||||
|
||||
async function editSuccessHandler() {
|
||||
if (successHandlerScriptPath) {
|
||||
await WorkspaceService.editSuccessHandler({
|
||||
@@ -1116,6 +1203,23 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'variable_expiration':
|
||||
return {
|
||||
savedValue: {
|
||||
handlerPath: variableExpirationFullPath(
|
||||
initialVariableExpirationItemKind,
|
||||
initialVariableExpirationPath
|
||||
),
|
||||
mutedOnUserPath: initialVariableExpirationMutedOnUserPath
|
||||
},
|
||||
modifiedValue: {
|
||||
handlerPath: variableExpirationFullPath(
|
||||
variableExpirationItemKind,
|
||||
variableExpirationPath
|
||||
),
|
||||
mutedOnUserPath: variableExpirationMutedOnUserPath
|
||||
}
|
||||
}
|
||||
case 'critical_alerts':
|
||||
return {
|
||||
savedValue: { criticalAlertUIMuted: initialCriticalAlertUIMuted },
|
||||
@@ -1156,6 +1260,9 @@
|
||||
discardErrorHandlerSettingsChanges()
|
||||
successHandlerScriptPath = initialSuccessHandlerScriptPath
|
||||
break
|
||||
case 'variable_expiration':
|
||||
discardVariableExpirationChanges()
|
||||
break
|
||||
case 'critical_alerts':
|
||||
criticalAlertUIMuted = initialCriticalAlertUIMuted
|
||||
break
|
||||
@@ -1263,6 +1370,13 @@
|
||||
aiDescription: 'Error and success handler workspace settings',
|
||||
isEE: true
|
||||
},
|
||||
{
|
||||
id: 'variable_expiration',
|
||||
label: 'Variable expiration',
|
||||
aiId: 'workspace-settings-variable-expiration',
|
||||
aiDescription:
|
||||
'Runnable executed when a variable value is about to expire, so the value can be rotated'
|
||||
},
|
||||
{
|
||||
id: 'critical_alerts',
|
||||
label: 'Critical alerts',
|
||||
@@ -1988,6 +2102,93 @@ export async function main(
|
||||
disabled={!$enterpriseLicense}
|
||||
/>
|
||||
</Section>
|
||||
{:else if tab == 'variable_expiration'}
|
||||
<SettingsPageHeader
|
||||
title="Variable expiration handler"
|
||||
description="Run a script or flow an hour before the value of a workspace variable expires, so it can be rotated before it stops working."
|
||||
link="https://www.windmill.dev/docs/core_concepts/variables_and_secrets"
|
||||
/>
|
||||
|
||||
<Section label="Handler">
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-row gap-2 items-center">
|
||||
<!-- Bound rather than seeded through `initialPath`: the picker keeps its own
|
||||
copy of the selection, so a Discard that only reset the page state would leave
|
||||
the reverted path invisible. -->
|
||||
<ScriptPicker
|
||||
bind:scriptPath={variableExpirationPath}
|
||||
bind:itemKind={variableExpirationItemKind}
|
||||
allowRefresh
|
||||
allowFlow
|
||||
clearable
|
||||
/>
|
||||
<Button
|
||||
variant="default"
|
||||
href={`${base}/scripts/add?lang=bun#` +
|
||||
encodeState({
|
||||
path: 'f/variable_expiration_handler',
|
||||
summary: 'Workspace Variable Expiration Handler',
|
||||
description:
|
||||
'Called an hour before the value of a variable in this workspace expires',
|
||||
content: `//native
|
||||
|
||||
// Workspace Variable Expiration Handler
|
||||
// Called an hour before the value of a variable in this workspace expires, and
|
||||
// again whenever that date is moved to a new one.
|
||||
//
|
||||
// The value itself is never passed in: job arguments and results are stored in
|
||||
// cleartext and shown in Runs. Read it with the Windmill client if the rotation
|
||||
// needs it, but never return or log it.
|
||||
|
||||
export async function main(
|
||||
variable_path: string,
|
||||
description: string,
|
||||
value_expires_at: string,
|
||||
is_secret: boolean
|
||||
) {
|
||||
console.log(\`\${variable_path} expires at \${value_expires_at}\`)
|
||||
|
||||
// Rotate the value here, then write the next expiry date back onto the
|
||||
// variable — that is what re-arms this handler for the following rotation.
|
||||
}
|
||||
`
|
||||
})}
|
||||
>
|
||||
Create a handler
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SettingCard class="gap-2">
|
||||
<Toggle
|
||||
bind:checked={variableExpirationMutedOnUserPath}
|
||||
disabled={!variableExpirationHasHandler}
|
||||
options={{
|
||||
right: 'Do not run the handler for variables in u/ paths',
|
||||
rightTooltip: variableExpirationHasHandler
|
||||
? undefined
|
||||
: 'Select a handler first — this setting is stored with it.'
|
||||
}}
|
||||
/>
|
||||
</SettingCard>
|
||||
|
||||
<Alert type="info" title="Runs as g/variable_expiration_handler">
|
||||
The handler is executed by the automatically created group
|
||||
g/variable_expiration_handler. Rotating a variable means writing to it, so grant
|
||||
that group write access to the variables it is meant to rotate — it starts with
|
||||
none. A script that carries an on-behalf-of identity is the exception: it runs
|
||||
as that user, with their permissions rather than the group's.
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<SettingsFooter
|
||||
class="mt-6"
|
||||
inline
|
||||
hasUnsavedChanges={hasVariableExpirationChanges}
|
||||
onSave={editVariableExpirationHandler}
|
||||
onDiscard={discardVariableExpirationChanges}
|
||||
saveLabel="Save variable expiration handler"
|
||||
/>
|
||||
</Section>
|
||||
{:else if tab == 'critical_alerts'}
|
||||
<SettingsPageHeader
|
||||
title="Workspace critical alerts"
|
||||
|
||||
Reference in New Issue
Block a user