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)/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' }
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
+
+
+
+
+ Name
+
+
+
+ {#each deployment.envGroups || [] as name (name)}
+
+
+
+ {name}
+
+
+
+ {:else}
+
+ {/each}
+
+
+
+
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}
+
+ Select Env Group
+ {#each envGroups.filter((g) => !form.envGroups.includes(g.name)) as it (it.name)}
+ {it.name}
+ {/each}
+
+ {/key}
+
+
+ {:else}
+
+ * You don't have permission to list env groups
+ {/if}
+
+
+
+
+ Name
+
+
+
+
+ {#each form.envGroups as name (name)}
+
+ {name}
+
+ removeEnvGroup(name)}>
+
+
+
+
+ {:else}
+
+ No env groups
+
+ {/each}
+
+
+
+
+
+
Environment Variables
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
+
+
+
+
+
+
+
+
+ Name
+ Variables
+ Created at
+ Created by
+
+
+
+
+ {#each envGroups as it (it.name)}
+
+
+
+ {it.name}
+
+
+ {Object.keys(it.env ?? {}).length}
+ {format.datetime(it.createdAt)}
+ {it.createdBy}
+
+
+
+
+
+
+
+
+ {/each}
+
+
+
+
+
+
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}
+
+
+
+
+
+
+
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..0edc888 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
}
@@ -286,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/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..cd98fcf 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: [] }
@@ -131,6 +135,7 @@ export const sampleDeployment = {
revision: 1,
image: 'nginx:latest',
env: {},
+ envGroups: [],
command: [],
args: [],
workloadIdentity: '',
@@ -251,6 +256,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