@cat-factory/app 0.49.0 → 0.49.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,383 @@
1
+ <script setup lang="ts">
2
+ // The Kubernetes "ephemeral environment backend" connect form — one option of the
3
+ // environment tab's backend-type selector (the other being the BYO HTTP manifest editor).
4
+ // It builds the discriminated `{ kind: 'kubernetes', kubernetes }` config plus the
5
+ // `apiToken` secret bundle and emits test/save to the parent tab (which calls the shared
6
+ // provider-connections store). The K8s env config differs from the runner K8s config
7
+ // (manifest source + URL derivation vs an executor image + namespace), so it has its own
8
+ // form rather than reusing KubernetesRunnerForm.
9
+ import { computed, reactive, ref, watch } from 'vue'
10
+ import { KUBERNETES_ENV_TOKEN_SECRET_KEY } from '@cat-factory/contracts'
11
+ import type { ProviderConnection } from '~/types/providerConnections'
12
+
13
+ const props = defineProps<{
14
+ connection: ProviderConnection | null
15
+ supportsTest: boolean
16
+ testing: boolean
17
+ busy: boolean
18
+ testResult: { ok: boolean; message?: string } | null
19
+ }>()
20
+
21
+ const emit = defineEmits<{
22
+ test: [payload: { config: Record<string, unknown>; secrets: Record<string, string> }]
23
+ save: [payload: { config: Record<string, unknown>; secrets: Record<string, string> }]
24
+ }>()
25
+
26
+ const { t } = useI18n()
27
+
28
+ const form = reactive({
29
+ label: '',
30
+ apiServerUrl: '',
31
+ caCertPem: '',
32
+ insecureSkipTlsVerify: false,
33
+ namespaceTemplate: '',
34
+ imageTemplate: '',
35
+ // manifest source
36
+ manifestSourceType: 'colocated' as 'colocated' | 'separate',
37
+ manifestPath: '',
38
+ manifestRepo: '',
39
+ manifestRef: '',
40
+ // url derivation
41
+ urlSource: 'ingressTemplate' as 'ingressTemplate' | 'ingressStatus' | 'serviceStatus',
42
+ hostTemplate: '',
43
+ ingressName: '',
44
+ serviceName: '',
45
+ servicePort: '',
46
+ urlScheme: '' as '' | 'http' | 'https',
47
+ })
48
+ const apiToken = ref('')
49
+
50
+ const manifestSourceItems = computed(() => [
51
+ { label: t('settings.providerConnection.kubernetesEnv.sourceColocated'), value: 'colocated' },
52
+ { label: t('settings.providerConnection.kubernetesEnv.sourceSeparate'), value: 'separate' },
53
+ ])
54
+ const urlSourceItems = computed(() => [
55
+ {
56
+ label: t('settings.providerConnection.kubernetesEnv.urlIngressTemplate'),
57
+ value: 'ingressTemplate',
58
+ },
59
+ {
60
+ label: t('settings.providerConnection.kubernetesEnv.urlIngressStatus'),
61
+ value: 'ingressStatus',
62
+ },
63
+ {
64
+ label: t('settings.providerConnection.kubernetesEnv.urlServiceStatus'),
65
+ value: 'serviceStatus',
66
+ },
67
+ ])
68
+ const schemeItems = computed(() => [
69
+ { label: t('settings.providerConnection.kubernetesEnv.schemeDefault'), value: '' },
70
+ { label: 'https', value: 'https' },
71
+ { label: 'http', value: 'http' },
72
+ ])
73
+
74
+ // A registered k8s env connection exposes its non-secret config, so prefill every
75
+ // non-secret field from it (never the token — secrets are write-only and re-entered on
76
+ // update). This lets an edit change one field without re-typing the whole form.
77
+ watch(
78
+ () => props.connection,
79
+ (c) => {
80
+ if (c?.kind !== 'kubernetes') return
81
+ const k =
82
+ c.config && (c.config as { kind?: string }).kind === 'kubernetes'
83
+ ? (c.config as { kubernetes: Record<string, unknown> }).kubernetes
84
+ : undefined
85
+ if (!k) return
86
+ form.label = typeof k.label === 'string' ? k.label : ''
87
+ form.apiServerUrl = typeof k.apiServerUrl === 'string' ? k.apiServerUrl : ''
88
+ form.caCertPem = typeof k.caCertPem === 'string' ? k.caCertPem : ''
89
+ form.insecureSkipTlsVerify = k.insecureSkipTlsVerify === true
90
+ form.namespaceTemplate = typeof k.namespaceTemplate === 'string' ? k.namespaceTemplate : ''
91
+ form.imageTemplate = typeof k.imageTemplate === 'string' ? k.imageTemplate : ''
92
+ const src = k.manifestSource as Record<string, unknown> | undefined
93
+ if (src?.type === 'separate') {
94
+ form.manifestSourceType = 'separate'
95
+ form.manifestRepo = typeof src.repo === 'string' ? src.repo : ''
96
+ form.manifestRef = typeof src.ref === 'string' ? src.ref : ''
97
+ form.manifestPath = typeof src.path === 'string' ? src.path : ''
98
+ } else if (src?.type === 'colocated') {
99
+ form.manifestSourceType = 'colocated'
100
+ form.manifestPath = typeof src.path === 'string' ? src.path : ''
101
+ }
102
+ const url = k.url as Record<string, unknown> | undefined
103
+ if (url?.source === 'ingressTemplate') {
104
+ form.urlSource = 'ingressTemplate'
105
+ form.hostTemplate = typeof url.hostTemplate === 'string' ? url.hostTemplate : ''
106
+ } else if (url?.source === 'ingressStatus') {
107
+ form.urlSource = 'ingressStatus'
108
+ form.ingressName = typeof url.ingressName === 'string' ? url.ingressName : ''
109
+ } else if (url?.source === 'serviceStatus') {
110
+ form.urlSource = 'serviceStatus'
111
+ form.serviceName = typeof url.serviceName === 'string' ? url.serviceName : ''
112
+ form.servicePort = typeof url.port === 'number' ? String(url.port) : ''
113
+ }
114
+ if (url && (url.scheme === 'http' || url.scheme === 'https')) form.urlScheme = url.scheme
115
+ },
116
+ { immediate: true },
117
+ )
118
+
119
+ // Mirror kubernetesManifestSourceSchema's `owner/repo` regex so a slashless value is
120
+ // caught here with a field hint instead of a generic 422 from the backend.
121
+ const repoShapeValid = computed(() => /^[^/\s]+\/[^/\s]+$/.test(form.manifestRepo.trim()))
122
+ const manifestSourceValid = computed(() =>
123
+ form.manifestSourceType === 'separate'
124
+ ? repoShapeValid.value && !!form.manifestPath.trim()
125
+ : !!form.manifestPath.trim(),
126
+ )
127
+ // serviceStatus.port is an optional integer 1..65535 (kubernetesUrlSourceSchema). Validate
128
+ // it here so a decimal isn't silently dropped and an out-of-range value isn't sent then 422'd.
129
+ const servicePortValid = computed(() => {
130
+ const raw = form.servicePort.trim()
131
+ if (!raw) return true
132
+ const port = Number(raw)
133
+ return Number.isInteger(port) && port >= 1 && port <= 65535
134
+ })
135
+ const urlValid = computed(() => {
136
+ if (form.urlSource === 'ingressTemplate') return !!form.hostTemplate.trim()
137
+ if (form.urlSource === 'serviceStatus') return !!form.serviceName.trim() && servicePortValid.value
138
+ return true // ingressStatus has no required field
139
+ })
140
+
141
+ const canSave = computed(
142
+ () =>
143
+ !!form.label.trim() &&
144
+ !!form.apiServerUrl.trim() &&
145
+ !!apiToken.value.trim() &&
146
+ manifestSourceValid.value &&
147
+ urlValid.value,
148
+ )
149
+
150
+ function buildManifestSource(): Record<string, unknown> {
151
+ if (form.manifestSourceType === 'separate') {
152
+ const src: Record<string, unknown> = {
153
+ type: 'separate',
154
+ repo: form.manifestRepo.trim(),
155
+ path: form.manifestPath.trim(),
156
+ }
157
+ if (form.manifestRef.trim()) src.ref = form.manifestRef.trim()
158
+ return src
159
+ }
160
+ return { type: 'colocated', path: form.manifestPath.trim() }
161
+ }
162
+
163
+ function buildUrl(): Record<string, unknown> {
164
+ const url: Record<string, unknown> = { source: form.urlSource }
165
+ if (form.urlSource === 'ingressTemplate') {
166
+ url.hostTemplate = form.hostTemplate.trim()
167
+ } else if (form.urlSource === 'ingressStatus') {
168
+ if (form.ingressName.trim()) url.ingressName = form.ingressName.trim()
169
+ } else {
170
+ url.serviceName = form.serviceName.trim()
171
+ const port = Number(form.servicePort)
172
+ if (form.servicePort.trim() && Number.isInteger(port)) url.port = port
173
+ }
174
+ if (form.urlScheme) url.scheme = form.urlScheme
175
+ return url
176
+ }
177
+
178
+ function buildPayload(): { config: Record<string, unknown>; secrets: Record<string, string> } {
179
+ const kubernetes: Record<string, unknown> = {
180
+ label: form.label.trim(),
181
+ apiServerUrl: form.apiServerUrl.trim(),
182
+ manifestSource: buildManifestSource(),
183
+ url: buildUrl(),
184
+ }
185
+ if (form.caCertPem.trim()) kubernetes.caCertPem = form.caCertPem.trim()
186
+ if (form.insecureSkipTlsVerify) kubernetes.insecureSkipTlsVerify = true
187
+ if (form.namespaceTemplate.trim()) kubernetes.namespaceTemplate = form.namespaceTemplate.trim()
188
+ if (form.imageTemplate.trim()) kubernetes.imageTemplate = form.imageTemplate.trim()
189
+ return {
190
+ config: { kind: 'kubernetes', kubernetes },
191
+ secrets: { [KUBERNETES_ENV_TOKEN_SECRET_KEY]: apiToken.value.trim() },
192
+ }
193
+ }
194
+
195
+ function optional(label: string): string {
196
+ return t('settings.providerConnection.form.optionalLabel', { label })
197
+ }
198
+ </script>
199
+
200
+ <template>
201
+ <div class="rounded-lg border border-dashed border-slate-700 p-3 space-y-3">
202
+ <p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
203
+ {{
204
+ connection?.kind === 'kubernetes'
205
+ ? t('settings.providerConnection.form.updateConfiguration')
206
+ : t('settings.providerConnection.form.connect')
207
+ }}
208
+ </p>
209
+
210
+ <UFormField :label="t('settings.providerConnection.kubernetesEnv.label')">
211
+ <UInput
212
+ v-model="form.label"
213
+ :placeholder="t('settings.providerConnection.kubernetesEnv.labelPlaceholder')"
214
+ />
215
+ </UFormField>
216
+
217
+ <UFormField
218
+ :label="t('settings.providerConnection.kubernetesEnv.apiServerUrl')"
219
+ :help="t('settings.providerConnection.kubernetesEnv.apiServerUrlHelp')"
220
+ >
221
+ <UInput v-model="form.apiServerUrl" class="font-mono" placeholder="https://10.0.0.1:6443" />
222
+ </UFormField>
223
+
224
+ <UFormField
225
+ :label="t('settings.providerConnection.kubernetesEnv.apiToken')"
226
+ :help="t('settings.providerConnection.kubernetesEnv.apiTokenHelp')"
227
+ >
228
+ <UInput v-model="apiToken" type="password" class="font-mono" />
229
+ </UFormField>
230
+
231
+ <!-- Manifest source: where the per-PR resources are read from. -->
232
+ <UFormField :label="t('settings.providerConnection.kubernetesEnv.manifestSourceLabel')">
233
+ <USelect v-model="form.manifestSourceType" :items="manifestSourceItems" />
234
+ </UFormField>
235
+
236
+ <UFormField
237
+ v-if="form.manifestSourceType === 'separate'"
238
+ :label="t('settings.providerConnection.kubernetesEnv.repo')"
239
+ :help="t('settings.providerConnection.kubernetesEnv.repoHelp')"
240
+ >
241
+ <UInput v-model="form.manifestRepo" class="font-mono" placeholder="acme/preview-manifests" />
242
+ </UFormField>
243
+
244
+ <UFormField
245
+ v-if="form.manifestSourceType === 'separate'"
246
+ :label="optional(t('settings.providerConnection.kubernetesEnv.ref'))"
247
+ :help="t('settings.providerConnection.kubernetesEnv.refHelp')"
248
+ >
249
+ <UInput v-model="form.manifestRef" class="font-mono" placeholder="main" />
250
+ </UFormField>
251
+
252
+ <UFormField
253
+ :label="t('settings.providerConnection.kubernetesEnv.path')"
254
+ :help="t('settings.providerConnection.kubernetesEnv.pathHelp')"
255
+ >
256
+ <UInput v-model="form.manifestPath" class="font-mono" placeholder="k8s/preview" />
257
+ </UFormField>
258
+
259
+ <!-- URL derivation: how the live environment URL is resolved once applied. -->
260
+ <UFormField :label="t('settings.providerConnection.kubernetesEnv.urlSourceLabel')">
261
+ <USelect v-model="form.urlSource" :items="urlSourceItems" />
262
+ </UFormField>
263
+
264
+ <UFormField
265
+ v-if="form.urlSource === 'ingressTemplate'"
266
+ :label="t('settings.providerConnection.kubernetesEnv.hostTemplate')"
267
+ :help="t('settings.providerConnection.kubernetesEnv.hostTemplateHelp')"
268
+ >
269
+ <UInput
270
+ v-model="form.hostTemplate"
271
+ class="font-mono"
272
+ placeholder="{{branch}}.preview.example.com"
273
+ />
274
+ </UFormField>
275
+
276
+ <UFormField
277
+ v-if="form.urlSource === 'ingressStatus'"
278
+ :label="optional(t('settings.providerConnection.kubernetesEnv.ingressName'))"
279
+ :help="t('settings.providerConnection.kubernetesEnv.ingressNameHelp')"
280
+ >
281
+ <UInput v-model="form.ingressName" class="font-mono" />
282
+ </UFormField>
283
+
284
+ <UFormField
285
+ v-if="form.urlSource === 'serviceStatus'"
286
+ :label="t('settings.providerConnection.kubernetesEnv.serviceName')"
287
+ >
288
+ <UInput v-model="form.serviceName" class="font-mono" />
289
+ </UFormField>
290
+
291
+ <UFormField
292
+ v-if="form.urlSource === 'serviceStatus'"
293
+ :label="optional(t('settings.providerConnection.kubernetesEnv.port'))"
294
+ >
295
+ <UInput
296
+ v-model="form.servicePort"
297
+ type="number"
298
+ :min="1"
299
+ :max="65535"
300
+ class="font-mono"
301
+ placeholder="80"
302
+ />
303
+ </UFormField>
304
+
305
+ <UFormField :label="optional(t('settings.providerConnection.kubernetesEnv.scheme'))">
306
+ <USelect v-model="form.urlScheme" :items="schemeItems" />
307
+ </UFormField>
308
+
309
+ <!-- Optional refinements. -->
310
+ <UFormField
311
+ :label="optional(t('settings.providerConnection.kubernetesEnv.namespaceTemplate'))"
312
+ :help="t('settings.providerConnection.kubernetesEnv.namespaceTemplateHelp')"
313
+ >
314
+ <UInput
315
+ v-model="form.namespaceTemplate"
316
+ class="font-mono"
317
+ placeholder="cf-env-{{pullNumber}}"
318
+ />
319
+ </UFormField>
320
+
321
+ <UFormField
322
+ :label="optional(t('settings.providerConnection.kubernetesEnv.imageTemplate'))"
323
+ :help="t('settings.providerConnection.kubernetesEnv.imageTemplateHelp')"
324
+ >
325
+ <UInput v-model="form.imageTemplate" class="font-mono" />
326
+ </UFormField>
327
+
328
+ <UFormField
329
+ :label="optional(t('settings.providerConnection.kubernetesEnv.caCertPem'))"
330
+ :help="t('settings.providerConnection.kubernetesEnv.caCertPemHelp')"
331
+ >
332
+ <UTextarea
333
+ v-model="form.caCertPem"
334
+ :rows="3"
335
+ class="font-mono"
336
+ placeholder="-----BEGIN CERTIFICATE-----"
337
+ />
338
+ </UFormField>
339
+
340
+ <UFormField :help="t('settings.providerConnection.kubernetesEnv.insecureSkipTlsVerifyHelp')">
341
+ <UCheckbox
342
+ v-model="form.insecureSkipTlsVerify"
343
+ :label="t('settings.providerConnection.kubernetesEnv.insecureSkipTlsVerify')"
344
+ />
345
+ </UFormField>
346
+
347
+ <div v-if="supportsTest" class="flex items-center gap-2">
348
+ <UButton
349
+ color="neutral"
350
+ variant="soft"
351
+ size="sm"
352
+ icon="i-lucide-plug-zap"
353
+ :loading="testing"
354
+ :disabled="!canSave"
355
+ @click="emit('test', buildPayload())"
356
+ >
357
+ {{ t('settings.providerConnection.test.button') }}
358
+ </UButton>
359
+ <span v-if="testResult && testResult.ok" class="text-xs text-emerald-400">
360
+ {{ testResult.message ?? t('settings.providerConnection.test.ok') }}
361
+ </span>
362
+ <span v-else-if="testResult" class="text-xs text-rose-400">
363
+ {{ testResult.message ?? t('settings.providerConnection.test.failed') }}
364
+ </span>
365
+ </div>
366
+
367
+ <div class="flex justify-end">
368
+ <UButton
369
+ color="primary"
370
+ size="sm"
371
+ :loading="busy"
372
+ :disabled="!canSave"
373
+ @click="emit('save', buildPayload())"
374
+ >
375
+ {{
376
+ connection?.kind === 'kubernetes'
377
+ ? t('common.save')
378
+ : t('settings.providerConnection.form.connect')
379
+ }}
380
+ </UButton>
381
+ </div>
382
+ </div>
383
+ </template>
@@ -11,6 +11,7 @@ import type { ProviderConnectionKind } from '~/types/providerConnections'
11
11
  import ProvisioningLogsDrawer from '~/components/provisioning/ProvisioningLogsDrawer.vue'
12
12
  import ProviderManifestEditor from '~/components/settings/ProviderManifestEditor.vue'
13
13
  import KubernetesRunnerForm from '~/components/settings/KubernetesRunnerForm.vue'
14
+ import KubernetesEnvironmentForm from '~/components/settings/KubernetesEnvironmentForm.vue'
14
15
 
15
16
  const props = defineProps<{ kind: ProviderConnectionKind }>()
16
17
 
@@ -185,18 +186,32 @@ async function saveManifest(payload: {
185
186
  }
186
187
  }
187
188
 
188
- // --- Runner-backend selector (runner-pool only) -------------------------------------
189
- // The runner-pool tab can configure either the manifest pool OR a native Kubernetes
190
- // cluster; environments are manifest-only. Defaults to the saved connection's kind.
191
- const RUNNER_BACKEND_KINDS = ['manifest', 'kubernetes'] as const
192
- type RunnerBackendKind = (typeof RUNNER_BACKEND_KINDS)[number]
193
- const backendKind = ref<RunnerBackendKind>('manifest')
194
- const showBackendSelector = computed(() => props.kind === 'runner-pool')
189
+ // --- Backend selector -----------------------------------------------------------------
190
+ // Both infrastructure tabs can configure either the BYO manifest backend OR a native
191
+ // Kubernetes backend (a runner cluster for runner-pool, per-PR namespaces for environment).
192
+ // The two K8s backends have different config shapes, so each kind renders its own form.
193
+ // Defaults to the saved connection's kind.
194
+ const BACKEND_KINDS = ['manifest', 'kubernetes'] as const
195
+ type BackendKind = (typeof BACKEND_KINDS)[number]
196
+ const backendKind = ref<BackendKind>('manifest')
197
+ const showBackendSelector = computed(() => true)
198
+ const backendSelectorLabel = computed(() =>
199
+ t(
200
+ props.kind === 'environment'
201
+ ? 'settings.providerConnection.backend.environmentSelectorLabel'
202
+ : 'settings.providerConnection.backend.selectorLabel',
203
+ ),
204
+ )
205
+ function backendKindLabel(k: BackendKind): string {
206
+ if (k === 'kubernetes') return t('settings.providerConnection.backend.kubernetes')
207
+ return t(
208
+ props.kind === 'environment'
209
+ ? 'settings.providerConnection.backend.environmentManifest'
210
+ : 'settings.providerConnection.backend.manifest',
211
+ )
212
+ }
195
213
  const backendKindItems = computed(() =>
196
- RUNNER_BACKEND_KINDS.map((k) => ({
197
- label: t(`settings.providerConnection.backend.${k}`),
198
- value: k,
199
- })),
214
+ BACKEND_KINDS.map((k) => ({ label: backendKindLabel(k), value: k })),
200
215
  )
201
216
  watch(
202
217
  () => connection.value,
@@ -317,17 +332,26 @@ function fieldHelp(key: string): string | undefined {
317
332
  }}
318
333
  </div>
319
334
 
320
- <!-- Runner-backend selector: the manifest pool or a native Kubernetes cluster. -->
321
- <UFormField
322
- v-if="showBackendSelector"
323
- :label="t('settings.providerConnection.backend.selectorLabel')"
324
- >
335
+ <!-- Backend selector: the BYO manifest backend or a native Kubernetes backend. -->
336
+ <UFormField v-if="showBackendSelector" :label="backendSelectorLabel">
325
337
  <USelect v-model="backendKind" :items="backendKindItems" />
326
338
  </UFormField>
327
339
 
328
- <!-- Native Kubernetes runner backend. -->
340
+ <!-- Native Kubernetes runner backend (runner-pool). -->
329
341
  <KubernetesRunnerForm
330
- v-if="showBackendSelector && backendKind === 'kubernetes'"
342
+ v-if="kind === 'runner-pool' && backendKind === 'kubernetes'"
343
+ :connection="connection"
344
+ :supports-test="descriptor.supportsTest"
345
+ :testing="testing"
346
+ :busy="busy"
347
+ :test-result="testResult"
348
+ @test="testConfig"
349
+ @save="saveConfig"
350
+ />
351
+
352
+ <!-- Native Kubernetes ephemeral-environment backend (environment). -->
353
+ <KubernetesEnvironmentForm
354
+ v-else-if="kind === 'environment' && backendKind === 'kubernetes'"
331
355
  :connection="connection"
332
356
  :supports-test="descriptor.supportsTest"
333
357
  :testing="testing"
@@ -70,12 +70,15 @@ export function providerConnectionsApi({ send, ws }: ApiContext) {
70
70
  kind === 'environment'
71
71
  ? send(CONTRACTS.environment.register, {
72
72
  pathPrefix: ws(workspaceId),
73
- body: body as RegisterEnvironmentProviderInput,
73
+ body: {
74
+ config: backendConfig(body),
75
+ secrets: body.secrets,
76
+ } as RegisterEnvironmentProviderInput,
74
77
  })
75
78
  : send(CONTRACTS['runner-pool'].register, {
76
79
  pathPrefix: ws(workspaceId),
77
80
  body: {
78
- config: runnerBackendConfig(body),
81
+ config: backendConfig(body),
79
82
  secrets: body.secrets,
80
83
  } as RegisterRunnerPoolInput,
81
84
  }),
@@ -94,12 +97,15 @@ export function providerConnectionsApi({ send, ws }: ApiContext) {
94
97
  kind === 'environment'
95
98
  ? send(CONTRACTS.environment.test, {
96
99
  pathPrefix: ws(workspaceId),
97
- body: body as TestEnvironmentConnectionInput,
100
+ body: {
101
+ ...(body.manifest || body.config ? { config: backendConfig(body) } : {}),
102
+ ...(body.secrets ? { secrets: body.secrets } : {}),
103
+ } as TestEnvironmentConnectionInput,
98
104
  })
99
105
  : send(CONTRACTS['runner-pool'].test, {
100
106
  pathPrefix: ws(workspaceId),
101
107
  body: {
102
- ...(body.manifest || body.config ? { config: runnerBackendConfig(body) } : {}),
108
+ ...(body.manifest || body.config ? { config: backendConfig(body) } : {}),
103
109
  ...(body.secrets ? { secrets: body.secrets } : {}),
104
110
  } as TestRunnerPoolConnectionInput,
105
111
  }),
@@ -110,13 +116,12 @@ export function providerConnectionsApi({ send, ws }: ApiContext) {
110
116
  }
111
117
 
112
118
  /**
113
- * Resolve the discriminated runner-backend config from a connect-form payload: an
114
- * explicit `config` (the Kubernetes form) wins; otherwise a bare `manifest` (the manifest
115
- * editor) is wrapped into the manifest backend kind.
119
+ * Resolve the discriminated backend config (runner-pool OR environment) from a connect-form
120
+ * payload: an explicit `config` (the Kubernetes form) wins; otherwise a bare `manifest` (the
121
+ * manifest editor) is wrapped into the `manifest` backend kind. Both subsystems now take a
122
+ * discriminated `config`, so the same shape serves each.
116
123
  */
117
- function runnerBackendConfig(
118
- body: RegisterProviderInput | TestProviderInput,
119
- ): Record<string, unknown> {
124
+ function backendConfig(body: RegisterProviderInput | TestProviderInput): Record<string, unknown> {
120
125
  if (body.config) return body.config
121
126
  return { kind: 'manifest', manifest: body.manifest ?? {} }
122
127
  }
@@ -1280,7 +1280,46 @@
1280
1280
  "backend": {
1281
1281
  "selectorLabel": "Runner backend",
1282
1282
  "manifest": "Self-hosted pool (manifest)",
1283
- "kubernetes": "Kubernetes"
1283
+ "kubernetes": "Kubernetes",
1284
+ "environmentSelectorLabel": "Environment backend",
1285
+ "environmentManifest": "Custom HTTP API (manifest)"
1286
+ },
1287
+ "kubernetesEnv": {
1288
+ "label": "Name",
1289
+ "labelPlaceholder": "Preview cluster",
1290
+ "apiServerUrl": "API server URL",
1291
+ "apiServerUrlHelp": "The kube-apiserver root, e.g. https://10.0.0.1:6443. The orchestrator applies each PR's manifests through the apiserver, so only this endpoint must be reachable.",
1292
+ "apiToken": "ServiceAccount token",
1293
+ "apiTokenHelp": "A bearer token with RBAC to create namespaces and apply the operator's resources. Stored encrypted; never shown again.",
1294
+ "manifestSourceLabel": "Manifest source",
1295
+ "sourceColocated": "Co-located in the PR repo",
1296
+ "sourceSeparate": "Separate repo",
1297
+ "repo": "Manifests repo",
1298
+ "repoHelp": "The owner/repo that holds the Kubernetes manifests, e.g. acme/preview-manifests.",
1299
+ "ref": "Ref",
1300
+ "refHelp": "Branch, tag or SHA to read the manifests at. Empty uses the repo's default branch.",
1301
+ "path": "Manifest path",
1302
+ "pathHelp": "File or directory within the repo holding the resources to apply.",
1303
+ "urlSourceLabel": "Environment URL",
1304
+ "urlIngressTemplate": "Ingress host template",
1305
+ "urlIngressStatus": "Read Ingress status",
1306
+ "urlServiceStatus": "Read Service status",
1307
+ "hostTemplate": "Host template",
1308
+ "hostTemplateHelp": "Host template rendered with the provision vars such as branch and pullNumber; the rendered host becomes the environment URL.",
1309
+ "ingressName": "Ingress name",
1310
+ "ingressNameHelp": "Ingress to read the load-balancer host from. Empty uses the only Ingress applied.",
1311
+ "serviceName": "Service name",
1312
+ "port": "Port",
1313
+ "scheme": "URL scheme",
1314
+ "schemeDefault": "https (default)",
1315
+ "namespaceTemplate": "Namespace template",
1316
+ "namespaceTemplateHelp": "Per-PR namespace name rendered from the provision vars such as pullNumber. Empty derives one from the PR number.",
1317
+ "imageTemplate": "Image template",
1318
+ "imageTemplateHelp": "Image reference exposed to the manifests, rendered over the provision vars such as branch and sha.",
1319
+ "caCertPem": "Cluster CA certificate (PEM)",
1320
+ "caCertPemHelp": "Paste the cluster CA bundle so the apiserver's TLS certificate verifies. Omit only for a publicly-trusted CA.",
1321
+ "insecureSkipTlsVerify": "Skip TLS verification",
1322
+ "insecureSkipTlsVerifyHelp": "Strongly discouraged. Disables apiserver TLS verification; use only for kind/dev clusters."
1284
1323
  },
1285
1324
  "kubernetes": {
1286
1325
  "label": "Name",
@@ -1288,7 +1288,46 @@
1288
1288
  "backend": {
1289
1289
  "selectorLabel": "Backend de ejecución",
1290
1290
  "manifest": "Pool autohospedado (manifiesto)",
1291
- "kubernetes": "Kubernetes"
1291
+ "kubernetes": "Kubernetes",
1292
+ "environmentSelectorLabel": "Backend de entorno",
1293
+ "environmentManifest": "API HTTP personalizada (manifiesto)"
1294
+ },
1295
+ "kubernetesEnv": {
1296
+ "label": "Nombre",
1297
+ "labelPlaceholder": "Clúster de vista previa",
1298
+ "apiServerUrl": "URL del API server",
1299
+ "apiServerUrlHelp": "La raíz del kube-apiserver, p. ej. https://10.0.0.1:6443. El orquestador aplica los manifiestos de cada PR a través del apiserver, así que solo este endpoint debe ser accesible.",
1300
+ "apiToken": "Token de ServiceAccount",
1301
+ "apiTokenHelp": "Un token bearer con permisos RBAC para crear namespaces y aplicar los recursos del operador. Se almacena cifrado; no se vuelve a mostrar.",
1302
+ "manifestSourceLabel": "Origen de los manifiestos",
1303
+ "sourceColocated": "En el mismo repo de la PR",
1304
+ "sourceSeparate": "Repo aparte",
1305
+ "repo": "Repo de manifiestos",
1306
+ "repoHelp": "El owner/repo que contiene los manifiestos de Kubernetes, p. ej. acme/preview-manifests.",
1307
+ "ref": "Ref",
1308
+ "refHelp": "Rama, etiqueta o SHA donde leer los manifiestos. Vacío usa la rama por defecto del repo.",
1309
+ "path": "Ruta del manifiesto",
1310
+ "pathHelp": "Archivo o directorio dentro del repo que contiene los recursos a aplicar.",
1311
+ "urlSourceLabel": "URL del entorno",
1312
+ "urlIngressTemplate": "Plantilla de host del Ingress",
1313
+ "urlIngressStatus": "Leer estado del Ingress",
1314
+ "urlServiceStatus": "Leer estado del Service",
1315
+ "hostTemplate": "Plantilla de host",
1316
+ "hostTemplateHelp": "Plantilla de host renderizada con las variables de aprovisionamiento como branch y pullNumber; el host resultante se convierte en la URL del entorno.",
1317
+ "ingressName": "Nombre del Ingress",
1318
+ "ingressNameHelp": "Ingress del que leer el host del balanceador. Vacío usa el único Ingress aplicado.",
1319
+ "serviceName": "Nombre del Service",
1320
+ "port": "Puerto",
1321
+ "scheme": "Esquema de URL",
1322
+ "schemeDefault": "https (por defecto)",
1323
+ "namespaceTemplate": "Plantilla de namespace",
1324
+ "namespaceTemplateHelp": "Nombre del namespace por PR renderizado con las variables de aprovisionamiento como pullNumber. Vacío deriva uno del número de la PR.",
1325
+ "imageTemplate": "Plantilla de imagen",
1326
+ "imageTemplateHelp": "Referencia de imagen expuesta a los manifiestos, renderizada con las variables de aprovisionamiento como branch y sha.",
1327
+ "caCertPem": "Certificado CA del clúster (PEM)",
1328
+ "caCertPemHelp": "Pega el bundle CA del clúster para que el certificado TLS del apiserver se verifique. Omítelo solo para una CA de confianza pública.",
1329
+ "insecureSkipTlsVerify": "Omitir verificación TLS",
1330
+ "insecureSkipTlsVerifyHelp": "Muy desaconsejado. Desactiva la verificación TLS del apiserver; úsalo solo para clústeres kind/dev."
1292
1331
  },
1293
1332
  "kubernetes": {
1294
1333
  "label": "Nombre",
@@ -1288,7 +1288,46 @@
1288
1288
  "backend": {
1289
1289
  "selectorLabel": "Backend d'exécution",
1290
1290
  "manifest": "Pool auto-hébergé (manifeste)",
1291
- "kubernetes": "Kubernetes"
1291
+ "kubernetes": "Kubernetes",
1292
+ "environmentSelectorLabel": "Backend d'environnement",
1293
+ "environmentManifest": "API HTTP personnalisée (manifeste)"
1294
+ },
1295
+ "kubernetesEnv": {
1296
+ "label": "Nom",
1297
+ "labelPlaceholder": "Cluster de prévisualisation",
1298
+ "apiServerUrl": "URL du serveur d'API",
1299
+ "apiServerUrlHelp": "La racine du kube-apiserver, p. ex. https://10.0.0.1:6443. L'orchestrateur applique les manifestes de chaque PR via l'apiserver, donc seul ce point d'accès doit être accessible.",
1300
+ "apiToken": "Jeton de ServiceAccount",
1301
+ "apiTokenHelp": "Un jeton bearer avec les droits RBAC pour créer des namespaces et appliquer les ressources de l'opérateur. Stocké chiffré ; jamais réaffiché.",
1302
+ "manifestSourceLabel": "Source des manifestes",
1303
+ "sourceColocated": "Dans le dépôt de la PR",
1304
+ "sourceSeparate": "Dépôt séparé",
1305
+ "repo": "Dépôt des manifestes",
1306
+ "repoHelp": "Le owner/repo qui contient les manifestes Kubernetes, p. ex. acme/preview-manifests.",
1307
+ "ref": "Réf",
1308
+ "refHelp": "Branche, tag ou SHA où lire les manifestes. Vide utilise la branche par défaut du dépôt.",
1309
+ "path": "Chemin du manifeste",
1310
+ "pathHelp": "Fichier ou répertoire du dépôt contenant les ressources à appliquer.",
1311
+ "urlSourceLabel": "URL de l'environnement",
1312
+ "urlIngressTemplate": "Modèle d'hôte Ingress",
1313
+ "urlIngressStatus": "Lire l'état de l'Ingress",
1314
+ "urlServiceStatus": "Lire l'état du Service",
1315
+ "hostTemplate": "Modèle d'hôte",
1316
+ "hostTemplateHelp": "Modèle d'hôte rendu avec les variables de provisionnement telles que branch et pullNumber ; l'hôte obtenu devient l'URL de l'environnement.",
1317
+ "ingressName": "Nom de l'Ingress",
1318
+ "ingressNameHelp": "Ingress dont lire l'hôte du load-balancer. Vide utilise le seul Ingress appliqué.",
1319
+ "serviceName": "Nom du Service",
1320
+ "port": "Port",
1321
+ "scheme": "Schéma d'URL",
1322
+ "schemeDefault": "https (par défaut)",
1323
+ "namespaceTemplate": "Modèle de namespace",
1324
+ "namespaceTemplateHelp": "Nom du namespace par PR rendu avec les variables de provisionnement telles que pullNumber. Vide en dérive un à partir du numéro de la PR.",
1325
+ "imageTemplate": "Modèle d'image",
1326
+ "imageTemplateHelp": "Référence d'image exposée aux manifestes, rendue avec les variables de provisionnement telles que branch et sha.",
1327
+ "caCertPem": "Certificat CA du cluster (PEM)",
1328
+ "caCertPemHelp": "Collez le bundle CA du cluster pour que le certificat TLS de l'apiserver soit vérifié. À omettre uniquement pour une CA publiquement approuvée.",
1329
+ "insecureSkipTlsVerify": "Ignorer la vérification TLS",
1330
+ "insecureSkipTlsVerifyHelp": "Fortement déconseillé. Désactive la vérification TLS de l'apiserver ; à utiliser uniquement pour des clusters kind/dev."
1292
1331
  },
1293
1332
  "kubernetes": {
1294
1333
  "label": "Nom",
@@ -1288,7 +1288,46 @@
1288
1288
  "backend": {
1289
1289
  "selectorLabel": "Backend wykonawczy",
1290
1290
  "manifest": "Własny pool (manifest)",
1291
- "kubernetes": "Kubernetes"
1291
+ "kubernetes": "Kubernetes",
1292
+ "environmentSelectorLabel": "Backend środowiska",
1293
+ "environmentManifest": "Własne API HTTP (manifest)"
1294
+ },
1295
+ "kubernetesEnv": {
1296
+ "label": "Nazwa",
1297
+ "labelPlaceholder": "Klaster podglądu",
1298
+ "apiServerUrl": "URL serwera API",
1299
+ "apiServerUrlHelp": "Główny adres kube-apiserver, np. https://10.0.0.1:6443. Orkiestrator stosuje manifesty każdego PR przez apiserver, więc tylko ten endpoint musi być osiągalny.",
1300
+ "apiToken": "Token ServiceAccount",
1301
+ "apiTokenHelp": "Token bearer z uprawnieniami RBAC do tworzenia namespace’ów i stosowania zasobów operatora. Przechowywany w postaci zaszyfrowanej; nie jest ponownie pokazywany.",
1302
+ "manifestSourceLabel": "Źródło manifestów",
1303
+ "sourceColocated": "W repozytorium PR",
1304
+ "sourceSeparate": "Osobne repozytorium",
1305
+ "repo": "Repozytorium manifestów",
1306
+ "repoHelp": "Owner/repo zawierające manifesty Kubernetes, np. acme/preview-manifests.",
1307
+ "ref": "Ref",
1308
+ "refHelp": "Gałąź, tag lub SHA, z którego czytać manifesty. Puste używa domyślnej gałęzi repozytorium.",
1309
+ "path": "Ścieżka manifestu",
1310
+ "pathHelp": "Plik lub katalog w repozytorium zawierający zasoby do zastosowania.",
1311
+ "urlSourceLabel": "URL środowiska",
1312
+ "urlIngressTemplate": "Szablon hosta Ingress",
1313
+ "urlIngressStatus": "Odczytaj stan Ingress",
1314
+ "urlServiceStatus": "Odczytaj stan Service",
1315
+ "hostTemplate": "Szablon hosta",
1316
+ "hostTemplateHelp": "Szablon hosta renderowany ze zmiennymi provisioningu, takimi jak branch i pullNumber; wynikowy host staje się URL-em środowiska.",
1317
+ "ingressName": "Nazwa Ingress",
1318
+ "ingressNameHelp": "Ingress, z którego odczytać host load-balancera. Puste używa jedynego zastosowanego Ingress.",
1319
+ "serviceName": "Nazwa Service",
1320
+ "port": "Port",
1321
+ "scheme": "Schemat URL",
1322
+ "schemeDefault": "https (domyślnie)",
1323
+ "namespaceTemplate": "Szablon namespace",
1324
+ "namespaceTemplateHelp": "Nazwa namespace per PR renderowana ze zmiennych provisioningu, takich jak pullNumber. Puste wyprowadza ją z numeru PR.",
1325
+ "imageTemplate": "Szablon obrazu",
1326
+ "imageTemplateHelp": "Referencja obrazu udostępniana manifestom, renderowana ze zmiennych provisioningu, takich jak branch i sha.",
1327
+ "caCertPem": "Certyfikat CA klastra (PEM)",
1328
+ "caCertPemHelp": "Wklej pakiet CA klastra, aby certyfikat TLS apiservera został zweryfikowany. Pomiń tylko dla publicznie zaufanego CA.",
1329
+ "insecureSkipTlsVerify": "Pomiń weryfikację TLS",
1330
+ "insecureSkipTlsVerifyHelp": "Zdecydowanie odradzane. Wyłącza weryfikację TLS apiservera; używaj tylko dla klastrów kind/dev."
1292
1331
  },
1293
1332
  "kubernetes": {
1294
1333
  "label": "Nazwa",
@@ -1288,7 +1288,46 @@
1288
1288
  "backend": {
1289
1289
  "selectorLabel": "Бекенд виконавця",
1290
1290
  "manifest": "Власний пул (маніфест)",
1291
- "kubernetes": "Kubernetes"
1291
+ "kubernetes": "Kubernetes",
1292
+ "environmentSelectorLabel": "Бекенд середовища",
1293
+ "environmentManifest": "Власний HTTP API (маніфест)"
1294
+ },
1295
+ "kubernetesEnv": {
1296
+ "label": "Назва",
1297
+ "labelPlaceholder": "Кластер попереднього перегляду",
1298
+ "apiServerUrl": "URL сервера API",
1299
+ "apiServerUrlHelp": "Кореневий адрес kube-apiserver, напр. https://10.0.0.1:6443. Оркестратор застосовує маніфести кожного PR через apiserver, тож лише цей endpoint має бути доступним.",
1300
+ "apiToken": "Токен ServiceAccount",
1301
+ "apiTokenHelp": "Bearer-токен з правами RBAC на створення просторів імен і застосування ресурсів оператора. Зберігається зашифрованим; більше не показується.",
1302
+ "manifestSourceLabel": "Джерело маніфестів",
1303
+ "sourceColocated": "У репозиторії PR",
1304
+ "sourceSeparate": "Окремий репозиторій",
1305
+ "repo": "Репозиторій маніфестів",
1306
+ "repoHelp": "Owner/repo, що містить маніфести Kubernetes, напр. acme/preview-manifests.",
1307
+ "ref": "Реф",
1308
+ "refHelp": "Гілка, тег або SHA, з якого читати маніфести. Порожнє використовує гілку за замовчуванням репозиторію.",
1309
+ "path": "Шлях маніфесту",
1310
+ "pathHelp": "Файл або каталог у репозиторії, що містить ресурси для застосування.",
1311
+ "urlSourceLabel": "URL середовища",
1312
+ "urlIngressTemplate": "Шаблон хоста Ingress",
1313
+ "urlIngressStatus": "Зчитати стан Ingress",
1314
+ "urlServiceStatus": "Зчитати стан Service",
1315
+ "hostTemplate": "Шаблон хоста",
1316
+ "hostTemplateHelp": "Шаблон хоста, відрендерений зі змінними провіженингу, такими як branch і pullNumber; отриманий хост стає URL середовища.",
1317
+ "ingressName": "Назва Ingress",
1318
+ "ingressNameHelp": "Ingress, з якого зчитати хост балансувальника. Порожнє використовує єдиний застосований Ingress.",
1319
+ "serviceName": "Назва Service",
1320
+ "port": "Порт",
1321
+ "scheme": "Схема URL",
1322
+ "schemeDefault": "https (за замовчуванням)",
1323
+ "namespaceTemplate": "Шаблон простору імен",
1324
+ "namespaceTemplateHelp": "Назва простору імен на кожен PR, відрендерена зі змінних провіженингу, таких як pullNumber. Порожнє виводить її з номера PR.",
1325
+ "imageTemplate": "Шаблон образу",
1326
+ "imageTemplateHelp": "Посилання на образ, доступне маніфестам, відрендерене зі змінними провіженингу, такими як branch і sha.",
1327
+ "caCertPem": "Сертифікат CA кластера (PEM)",
1328
+ "caCertPemHelp": "Вставте CA-набір кластера, щоб TLS-сертифікат apiserver проходив перевірку. Пропустіть лише для публічно довіреного CA.",
1329
+ "insecureSkipTlsVerify": "Пропустити перевірку TLS",
1330
+ "insecureSkipTlsVerifyHelp": "Наполегливо не рекомендується. Вимикає перевірку TLS apiserver; використовуйте лише для кластерів kind/dev."
1292
1331
  },
1293
1332
  "kubernetes": {
1294
1333
  "label": "Назва",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.49.0",
3
+ "version": "0.49.2",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -34,7 +34,7 @@
34
34
  "valibot": "^1.4.2",
35
35
  "vue": "^3.5.39",
36
36
  "wretch": "^3.0.9",
37
- "@cat-factory/contracts": "0.50.0"
37
+ "@cat-factory/contracts": "0.51.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@toad-contracts/testing": "0.3.2",