From c49a1e3987c1eba92309bc8503985b14c079245f Mon Sep 17 00:00:00 2001 From: acoshift Date: Sat, 16 May 2026 07:43:42 +0700 Subject: [PATCH 1/3] feat: add env group management Adds a console UI for the EnvGroup API: list, create, update (full override), and delete project-scoped environment variable groups. The create form doubles as the update form (mirroring the role pattern) and reuses the deployment env editor (table rows + optional text-area). Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 2 +- .../(auth)/(project)/env-group/+layout.js | 6 + .../(auth)/(project)/env-group/+page.js | 12 + .../(auth)/(project)/env-group/+page.svelte | 60 +++++ .../(project)/env-group/create/+page.js | 24 ++ .../(project)/env-group/create/+page.svelte | 209 ++++++++++++++++++ src/routes/(auth)/Sidebar.svelte | 6 + src/types/api.d.ts | 8 + tests/env-group.spec.js | 45 ++++ tests/fixtures/mocks.js | 15 ++ 10 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 src/routes/(auth)/(project)/env-group/+layout.js create mode 100644 src/routes/(auth)/(project)/env-group/+page.js create mode 100644 src/routes/(auth)/(project)/env-group/+page.svelte create mode 100644 src/routes/(auth)/(project)/env-group/create/+page.js create mode 100644 src/routes/(auth)/(project)/env-group/create/+page.svelte create mode 100644 tests/env-group.spec.js diff --git a/CLAUDE.md b/CLAUDE.md index 94a143f..341e5da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ src/routes/ auth/ # sign-in / callback / sign-out (unauthenticated) (auth)/ # layout group — redirects to /auth/signin if no token (project)/ # layout group — requires ?project= param - audit-log/ deployment/ disk/ domain/ dropbox/ email/ + audit-log/ deployment/ disk/ domain/ dropbox/ email/ env-group/ pull-secret/ registry/ role/ route/ service-account/ workload-identity/ billing/ diff --git a/src/routes/(auth)/(project)/env-group/+layout.js b/src/routes/(auth)/(project)/env-group/+layout.js new file mode 100644 index 0000000..a80bf32 --- /dev/null +++ b/src/routes/(auth)/(project)/env-group/+layout.js @@ -0,0 +1,6 @@ +export function load () { + return { + menu: 'env-group', + overrideRedirect: '/env-group' + } +} diff --git a/src/routes/(auth)/(project)/env-group/+page.js b/src/routes/(auth)/(project)/env-group/+page.js new file mode 100644 index 0000000..c00af56 --- /dev/null +++ b/src/routes/(auth)/(project)/env-group/+page.js @@ -0,0 +1,12 @@ +import api from '$lib/api' + +export async function load ({ parent, fetch }) { + const { project } = await parent() + + /** @type {Api.Response>} */ + const res = await api.invoke('envGroup.list', { project }, fetch) + return { + envGroups: res.result?.items ?? [], + error: res.error + } +} diff --git a/src/routes/(auth)/(project)/env-group/+page.svelte b/src/routes/(auth)/(project)/env-group/+page.svelte new file mode 100644 index 0000000..d39d5e5 --- /dev/null +++ b/src/routes/(auth)/(project)/env-group/+page.svelte @@ -0,0 +1,60 @@ + + +
Env Groups
+
+
+
+ +
+ +
+ + + + + + + + + + + + {#each envGroups as it (it.name)} + + + + + + + + {/each} + + + +
NameVariablesCreated atCreated by
+ + {it.name} + + {Object.keys(it.env ?? {}).length}{format.datetime(it.createdAt)}{it.createdBy} + +
+ +
+
+
+
+
diff --git a/src/routes/(auth)/(project)/env-group/create/+page.js b/src/routes/(auth)/(project)/env-group/create/+page.js new file mode 100644 index 0000000..9b655ac --- /dev/null +++ b/src/routes/(auth)/(project)/env-group/create/+page.js @@ -0,0 +1,24 @@ +import { redirect, error } from '@sveltejs/kit' +import api from '$lib/api' + +export async function load ({ url, parent, fetch }) { + const { project } = await parent() + const name = url.searchParams.get('name') + + let envGroup = null + if (name) { + /** @type {Api.Response} */ + const res = await api.invoke('envGroup.get', { project, name }, fetch) + if (!res.ok) { + if (res.error?.notFound) redirect(302, `/env-group?project=${project}`) + error(500, res.error?.message) + } + if (!res.result) redirect(302, `/env-group?project=${project}`) + envGroup = res.result + } + + return { + menu: 'env-group', + envGroup + } +} diff --git a/src/routes/(auth)/(project)/env-group/create/+page.svelte b/src/routes/(auth)/(project)/env-group/create/+page.svelte new file mode 100644 index 0000000..74bb019 --- /dev/null +++ b/src/routes/(auth)/(project)/env-group/create/+page.svelte @@ -0,0 +1,209 @@ + + +
+ + {#if envGroup} +
+
{envGroup.name}
+
+
+
Update
+
+ {:else} +
+
Create
+
+ {/if} +
+ +
+ +
+
+

+ {#if envGroup} + Update env group "{envGroup.name}" + {:else} + Create new env group + {/if} +

+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ +
Environment Variables
+
+
+ + + + + + + + + + + {#each form.env as it, i (i)} + + + + + + + {/each} + + + + + + +
KeyValue
+
+ +
+
: +
+ +
+
+ +
+ +
+
+ + + {#if showEnvText} +
+ +
+ {/if} +
+ +
+ +
+ + {#if envGroup} + + {/if} +
+
+
diff --git a/src/routes/(auth)/Sidebar.svelte b/src/routes/(auth)/Sidebar.svelte index 6b71a51..1f03003 100644 --- a/src/routes/(auth)/Sidebar.svelte +++ b/src/routes/(auth)/Sidebar.svelte @@ -57,6 +57,12 @@ icon: 'fa-key', link: '/pull-secret' }, + { + id: 'env-group', + title: 'Env Groups', + icon: 'fa-cog', + link: '/env-group' + }, { id: 'role', title: 'Roles', diff --git a/src/types/api.d.ts b/src/types/api.d.ts index 6333bfc..048f8e6 100644 --- a/src/types/api.d.ts +++ b/src/types/api.d.ts @@ -201,6 +201,14 @@ declare namespace Api { [key: string]: string } + export type EnvGroup = { + project: string + name: string + env: Env + createdAt: string + createdBy: string + } + export type MountData = { [key: string]: string } diff --git a/tests/env-group.spec.js b/tests/env-group.spec.js new file mode 100644 index 0000000..4944511 --- /dev/null +++ b/tests/env-group.spec.js @@ -0,0 +1,45 @@ +import { test, expect, setMocks } from './helpers.js' +import { sampleEnvGroup } from './fixtures/mocks.js' + +test.describe('env groups', () => { + test('lists env groups', async ({ page }) => { + await setMocks({ + 'envGroup.list': { + ok: true, + result: { items: [sampleEnvGroup] } + } + }) + + await page.goto('/env-group?project=test-project') + + const main = page.locator('.content-wrapper') + await expect(main.getByRole('heading', { name: 'Env Groups' })).toBeVisible() + await expect(main.getByRole('link', { name: 'shared-config' })).toBeVisible() + await expect(main.getByRole('cell', { name: '2', exact: true })).toBeVisible() + }) + + test('empty state when no env groups', async ({ page }) => { + await page.goto('/env-group?project=test-project') + const main = page.locator('.content-wrapper') + await expect(main.getByText('No data')).toBeVisible() + }) + + test('shows existing env vars on update', async ({ page }) => { + await setMocks({ + 'envGroup.get': { + ok: true, + result: sampleEnvGroup + } + }) + + await page.goto('/env-group/create?project=test-project&name=shared-config') + + const main = page.locator('.content-wrapper') + await expect(main.getByRole('heading', { name: 'Update env group "shared-config"' })).toBeVisible() + await expect(main.getByRole('textbox', { name: 'Name', exact: true })).toHaveValue('shared-config') + await expect(main.locator('input[placeholder="Variable name"]').nth(0)).toHaveValue('LOG_LEVEL') + await expect(main.locator('input[placeholder="Value"]').nth(0)).toHaveValue('info') + await expect(main.getByRole('button', { name: 'Update' })).toBeVisible() + await expect(main.getByRole('button', { name: 'Delete' })).toBeVisible() + }) +}) diff --git a/tests/fixtures/mocks.js b/tests/fixtures/mocks.js index 3facb07..880d34e 100644 --- a/tests/fixtures/mocks.js +++ b/tests/fixtures/mocks.js @@ -108,6 +108,10 @@ export function defaultMocks () { ok: true, result: { items: [] } }, + '/envGroup.list': { + ok: true, + result: { items: [] } + }, '/__registry/list': { ok: true, result: { items: [] } @@ -251,6 +255,17 @@ export const sampleWorkloadIdentity = { createdBy: '[email protected]' } +export const sampleEnvGroup = { + project: 'test-project', + name: 'shared-config', + env: { + LOG_LEVEL: 'info', + FEATURE_FLAG: 'true' + }, + createdAt: now, + createdBy: '[email protected]' +} + export const sampleEmailDomain = { domain: 'mail.example.com', createdAt: now From 2f8247a22a898dfe248ba4d8585afd66a7037a26 Mon Sep 17 00:00:00 2001 From: acoshift Date: Sat, 16 May 2026 07:54:42 +0700 Subject: [PATCH 2/3] feat: include env group in audit log resource type filter Adds envGroup to the audit-log RESOURCE_TYPES list so users can filter audit events by env group activity now that the resource is manageable from the console. Co-Authored-By: Claude Opus 4.7 --- src/routes/(auth)/(project)/audit-log/+page.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/src/routes/(auth)/(project)/audit-log/+page.svelte b/src/routes/(auth)/(project)/audit-log/+page.svelte index c1f8511..1841cd2 100644 --- a/src/routes/(auth)/(project)/audit-log/+page.svelte +++ b/src/routes/(auth)/(project)/audit-log/+page.svelte @@ -18,6 +18,7 @@ { value: 'deployment', label: 'Deployment' }, { value: 'disk', label: 'Disk' }, { value: 'domain', label: 'Domain' }, + { value: 'envGroup', label: 'Env Group' }, { value: 'pullSecret', label: 'Pull Secret' }, { value: 'role', label: 'Role' }, { value: 'serviceAccount', label: 'Service Account' } From 6f7efd9abc0b4200e427e0310db38785b75dd289 Mon Sep 17 00:00:00 2001 From: acoshift Date: Sat, 16 May 2026 08:21:29 +0700 Subject: [PATCH 3/3] feat: attach env groups to deployments Adds env group selection to the deploy form (multi-select with permission fallback to a free-text input), pre-fills it from the existing revision, and renders the attached env groups as links on the deployment detail page. Sends envGroups as a full override on deployment.deploy. Co-Authored-By: Claude Opus 4.7 --- .../deployment/(detail)/detail/+page.svelte | 24 ++++ .../(project)/deployment/deploy/+page.svelte | 108 +++++++++++++++++- src/types/api.d.ts | 1 + tests/deployment.spec.js | 76 ++++++++++++ tests/fixtures/mocks.js | 1 + 5 files changed, 209 insertions(+), 1 deletion(-) diff --git a/src/routes/(auth)/(project)/deployment/(detail)/detail/+page.svelte b/src/routes/(auth)/(project)/deployment/(detail)/detail/+page.svelte index d5ec2fe..2ca2d24 100644 --- a/src/routes/(auth)/(project)/deployment/(detail)/detail/+page.svelte +++ b/src/routes/(auth)/(project)/deployment/(detail)/detail/+page.svelte @@ -252,6 +252,30 @@ +
Env Groups
+
+ + + + + + + + {#each deployment.envGroups || [] as name (name)} + + + + {:else} + + {/each} + +
Name
+ + {name} + +
+
+
Environment Variables
diff --git a/src/routes/(auth)/(project)/deployment/deploy/+page.svelte b/src/routes/(auth)/(project)/deployment/deploy/+page.svelte index 267ebe7..8e94ce6 100644 --- a/src/routes/(auth)/(project)/deployment/deploy/+page.svelte +++ b/src/routes/(auth)/(project)/deployment/deploy/+page.svelte @@ -17,7 +17,8 @@ const permission = $state({ pullSecrets: true, workloadIdentities: true, - disks: true + disks: true, + envGroups: true }) /** @type {Api.PullSecret[]} */ @@ -29,6 +30,9 @@ /** @type {Api.Disk[]} */ let disks = $state([]) + /** @type {Api.EnvGroup[]} */ + let envGroups = $state([]) + const form = $state({ location: deployment?.location || '', name: '', @@ -61,6 +65,8 @@ }, /** @type {{ k: string, v: string }[]} */ env: [], + /** @type {string[]} */ + envGroups: [], /** @type {{ k: string, v: string }[]} */ mountData: [], /** @type {Api.SidecarForm[]} */ @@ -91,6 +97,7 @@ form.maxReplicas = deployment.maxReplicas form.resources = deployment.resources form.env = Object.entries(deployment.env || {}).map(([k, v]) => ({ k, v })) + form.envGroups = [...(deployment.envGroups || [])] form.mountData = Object.entries(deployment.mountData || {}).map(([k, v]) => ({ k, v })) form.sidecars = (deployment.sidecars || []).map((s) => { if (s.cloudSqlProxy) { @@ -178,6 +185,19 @@ disks = resp.result.items ?? [] } + async function fetchEnvGroups () { + const resp = await api.invoke('envGroup.list', { project }, fetch) + if (!resp.ok) { + if (resp.error?.forbidden) { + permission.envGroups = false + return + } + modal.error({ error: resp.error }) + return + } + envGroups = resp.result.items ?? [] + } + async function changeLocation () { pullSecrets = [] workloadIdentities = [] @@ -208,6 +228,34 @@ .join('\n') } + let envGroupInput = $state('') + + /** + * @param {string} name + */ + function addEnvGroup (name) { + const n = name.trim() + if (!n || form.envGroups.includes(n)) return + form.envGroups = [...form.envGroups, n] + } + + function selectEnvGroupChanged (e) { + addEnvGroup(e.target.value) + e.target.value = '' + } + + function addEnvGroupFromInput () { + addEnvGroup(envGroupInput) + envGroupInput = '' + } + + /** + * @param {string} name + */ + function removeEnvGroup (name) { + form.envGroups = form.envGroups.filter((g) => g !== name) + } + function convertSidecars () { return form.sidecars .filter((s) => s.type) @@ -283,6 +331,7 @@ onMount(() => { changeLocation() parseEnvValue() + fetchEnvGroups() }) @@ -666,6 +715,63 @@

+
Env Groups
+ + Env groups are project-scoped sets of environment variables that are merged into the deployment. + Variables defined here take precedence over those defined in env groups. + + {#if permission.envGroups} +
+
+ {#key envGroups} + + {/key} +
+
+ {:else} +
+
+ +
+ +
+

* You don't have permission to list env groups

+ {/if} +
+
+ + + + + + + + {#each form.envGroups as name (name)} + + + + + {:else} + + + + {/each} + +
Name
{name} + +
No env groups
+
+ +
+
Environment Variables
diff --git a/src/types/api.d.ts b/src/types/api.d.ts index 048f8e6..0edc888 100644 --- a/src/types/api.d.ts +++ b/src/types/api.d.ts @@ -294,6 +294,7 @@ declare namespace Api { revision: number image: string env: Env + envGroups: string[] command: string[] args: string[] workloadIdentity: string diff --git a/tests/deployment.spec.js b/tests/deployment.spec.js index 4a4b733..4762743 100644 --- a/tests/deployment.spec.js +++ b/tests/deployment.spec.js @@ -151,3 +151,79 @@ test.describe('deployment deploy — sidecars', () => { await expect(main.locator('#input-sidecar-port-0')).toHaveValue('3306') }) }) + +test.describe('deployment detail — env groups', () => { + test('lists attached env groups with links to their edit page', async ({ page }) => { + await setMocks({ + 'deployment.get': { + ok: true, + result: { ...sampleDeployment, envGroups: ['shared-config', 'secrets'] } + }, + 'location.get': { ok: true, result: defaultLocation } + }) + + await page.goto('/deployment/detail?project=test-project&location=gke&name=web') + + const main = page.locator('.content-wrapper') + await expect(main.getByRole('heading', { name: 'Env Groups' })).toBeVisible() + await expect(main.getByRole('link', { name: 'shared-config' })) + .toHaveAttribute('href', '/env-group/create?project=test-project&name=shared-config') + await expect(main.getByRole('link', { name: 'secrets' })) + .toHaveAttribute('href', '/env-group/create?project=test-project&name=secrets') + }) + + test('shows no data when deployment has no env groups', async ({ page }) => { + await setMocks({ + 'deployment.get': { ok: true, result: sampleDeployment }, + 'location.get': { ok: true, result: defaultLocation } + }) + + await page.goto('/deployment/detail?project=test-project&location=gke&name=web') + + const main = page.locator('.content-wrapper') + const envGroupTable = main.locator('section, div').filter({ + has: page.getByRole('heading', { name: 'Env Groups' }) + }) + await expect(main.getByRole('heading', { name: 'Env Groups' })).toBeVisible() + await expect(envGroupTable.getByText('No data').first()).toBeVisible() + }) +}) + +test.describe('deployment deploy — env groups', () => { + test('hydrates existing env groups when editing a revision', async ({ page }) => { + await setMocks({ + 'deployment.get': { + ok: true, + result: { ...sampleDeployment, envGroups: ['shared-config'] } + }, + 'location.get': { ok: true, result: defaultLocation }, + 'envGroup.list': { + ok: true, + result: { + items: [ + { + project: 'test-project', + name: 'shared-config', + env: {}, + createdAt: '2024-01-01T00:00:00Z', + createdBy: '[email protected]' + }, + { + project: 'test-project', + name: 'secrets', + env: {}, + createdAt: '2024-01-01T00:00:00Z', + createdBy: '[email protected]' + } + ] + } + } + }) + + await page.goto('/deployment/deploy?project=test-project&location=gke&name=web') + + const main = page.locator('.content-wrapper') + await expect(main.getByRole('heading', { name: 'Env Groups' })).toBeVisible() + await expect(main.getByRole('cell', { name: 'shared-config' })).toBeVisible() + }) +}) diff --git a/tests/fixtures/mocks.js b/tests/fixtures/mocks.js index 880d34e..cd98fcf 100644 --- a/tests/fixtures/mocks.js +++ b/tests/fixtures/mocks.js @@ -135,6 +135,7 @@ export const sampleDeployment = { revision: 1, image: 'nginx:latest', env: {}, + envGroups: [], command: [], args: [], workloadIdentity: '',