@ultimat3/cli 3.0.0 → 4.0.0
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.
- package/CLAUDE.md +69 -10
- package/package.json +24 -24
- package/src/budgets.ts +8 -5
- package/src/cmd-deploy.ts +42 -14
- package/src/cmd-docs.ts +7 -3
- package/src/cmd-fix.ts +15 -3
- package/src/cmd-generate.ts +29 -4
- package/src/cmd-help.ts +25 -4
- package/src/cmd-i18n.ts +8 -5
- package/src/cmd-jobs.ts +6 -5
- package/src/cmd-mcp.ts +16 -12
- package/src/cmd-new.ts +9 -13
- package/src/cmd-planned.ts +13 -0
- package/src/cmd-policy.ts +8 -6
- package/src/cmd-registries.ts +7 -6
- package/src/cmd-routes.ts +27 -4
- package/src/cmd-secrets.ts +6 -6
- package/src/cmd-verify.ts +55 -3
- package/src/command.ts +10 -2
- package/src/dev-cache.ts +9 -9
- package/src/dev-render.ts +6 -1
- package/src/dev-runtime.ts +2 -2
- package/src/dispatch.ts +33 -4
- package/src/error-codes.ts +5 -0
- package/src/error-contract.ts +31 -4
- package/src/fix-command.ts +9 -2
- package/src/fix-imports.ts +118 -0
- package/src/fix-scan.ts +251 -0
- package/src/flag-reads.ts +114 -0
- package/src/i18n-audit.ts +2 -1
- package/src/index.ts +8 -1
- package/src/jobs-drain.ts +6 -1
- package/src/mcp-errors.ts +5 -0
- package/src/mcp-host.ts +4 -2
- package/src/messages.ts +3 -0
- package/src/otlp-export.ts +14 -0
- package/src/parse.ts +6 -1
- package/src/seo-meta.ts +105 -0
- package/src/templates/action.ts +39 -7
- package/src/templates/backfill.ts +3 -1
- package/src/templates/index.ts +10 -1
- package/src/templates/job.ts +6 -2
- package/src/templates/query.ts +6 -1
- package/src/templates/route.ts +18 -9
- package/src/templates/scaffold-api.ts +100 -0
- package/src/templates/scaffold-app.ts +8 -48
- package/src/templates/scaffold-container.ts +44 -9
- package/src/templates/scaffold-helm-templates.ts +327 -0
- package/src/templates/scaffold-helm.ts +144 -0
- package/src/templates/scaffold-repo.ts +25 -8
- package/src/ts-scan.ts +12 -174
- package/src/verify-step.ts +5 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// The Kubernetes objects the scaffolded chart renders: one Deployment and one Service per enabled
|
|
2
|
+
// role, the release-phase Job, an optional ingress and an optional per-role autoscaler.
|
|
3
|
+
// Split from scaffold-helm.ts, which holds the chart's INPUTS (Chart.yaml, values.yaml).
|
|
4
|
+
|
|
5
|
+
import type { GeneratedFile, NameSet } from './naming';
|
|
6
|
+
|
|
7
|
+
const helpers = (
|
|
8
|
+
app: NameSet,
|
|
9
|
+
): string => `{{/* Shared naming and the one container spec every role renders. */}}
|
|
10
|
+
|
|
11
|
+
{{- define "${app.kebab}.name" -}}
|
|
12
|
+
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
|
|
13
|
+
{{- end -}}
|
|
14
|
+
|
|
15
|
+
{{- define "${app.kebab}.fullname" -}}
|
|
16
|
+
{{- printf "%s-%s" .Release.Name (include "${app.kebab}.name" .) | trunc 63 | trimSuffix "-" -}}
|
|
17
|
+
{{- end -}}
|
|
18
|
+
|
|
19
|
+
{{/*
|
|
20
|
+
repository:tag, and NOT a single "image" string: x deploy --method helm passes
|
|
21
|
+
--set image.repository=... --set image.tag=..., because replacing the map with a string makes
|
|
22
|
+
every workload below fail on .repository and deploy nothing.
|
|
23
|
+
*/}}
|
|
24
|
+
{{- define "${app.kebab}.image" -}}
|
|
25
|
+
{{- printf "%s:%s" .Values.image.repository (default .Chart.AppVersion .Values.image.tag) -}}
|
|
26
|
+
{{- end -}}
|
|
27
|
+
|
|
28
|
+
{{- define "${app.kebab}.labels" -}}
|
|
29
|
+
app.kubernetes.io/name: {{ include "${app.kebab}.name" . }}
|
|
30
|
+
app.kubernetes.io/instance: {{ .Release.Name }}
|
|
31
|
+
app.kubernetes.io/version: {{ default .Chart.AppVersion .Values.image.tag | quote }}
|
|
32
|
+
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
|
33
|
+
{{- end -}}
|
|
34
|
+
|
|
35
|
+
{{/*
|
|
36
|
+
The container spec for a role. ROLE is the only thing that varies, exactly as in
|
|
37
|
+
docker-compose.prod.yml: one image, one entry point, N processes.
|
|
38
|
+
|
|
39
|
+
migrate is the one role that opens no socket at all — it applies migrations and exits — so it
|
|
40
|
+
declares no port and no scrape target.
|
|
41
|
+
*/}}
|
|
42
|
+
{{- define "${app.kebab}.container" -}}
|
|
43
|
+
{{- $role := .role -}}
|
|
44
|
+
{{- $cfg := .cfg -}}
|
|
45
|
+
{{- $root := .root -}}
|
|
46
|
+
{{- $scraped := ne $role "migrate" -}}
|
|
47
|
+
{{/*
|
|
48
|
+
roles.<role>.port is the port the role LISTENS on — the one number a Service, an ingress backend
|
|
49
|
+
and two probes can all use. PORT is a different number for exactly one role: the sync node binds
|
|
50
|
+
PORT + 1, so a sync container told PORT=3001 opens 3002 and the readiness probe polls a socket
|
|
51
|
+
nobody bound. Derived here rather than stated twice in values.yaml, where the two would drift.
|
|
52
|
+
*/}}
|
|
53
|
+
{{- $envPort := $cfg.port -}}
|
|
54
|
+
{{- if and $cfg.port (eq $role "sync") -}}
|
|
55
|
+
{{- $envPort = sub (int $cfg.port) 1 -}}
|
|
56
|
+
{{- end -}}
|
|
57
|
+
- name: {{ $role }}
|
|
58
|
+
image: {{ include "${app.kebab}.image" $root }}
|
|
59
|
+
imagePullPolicy: {{ $root.Values.image.pullPolicy }}
|
|
60
|
+
securityContext: {{- toYaml $root.Values.securityContext | nindent 4 }}
|
|
61
|
+
env:
|
|
62
|
+
- name: ROLE
|
|
63
|
+
value: {{ $role | quote }}
|
|
64
|
+
{{- if $cfg.port }}
|
|
65
|
+
- name: PORT
|
|
66
|
+
value: {{ $envPort | quote }}
|
|
67
|
+
{{- end }}
|
|
68
|
+
{{- if $scraped }}
|
|
69
|
+
- name: METRICS_PORT
|
|
70
|
+
value: {{ $root.Values.metricsPort | quote }}
|
|
71
|
+
{{- end }}
|
|
72
|
+
{{- range $key, $value := $root.Values.env }}
|
|
73
|
+
- name: {{ $key }}
|
|
74
|
+
value: {{ $value | quote }}
|
|
75
|
+
{{- end }}
|
|
76
|
+
envFrom:
|
|
77
|
+
- secretRef:
|
|
78
|
+
name: {{ $root.Values.existingSecret }}
|
|
79
|
+
{{- if or $cfg.port $scraped }}
|
|
80
|
+
ports:
|
|
81
|
+
{{- if $cfg.port }}
|
|
82
|
+
- name: http
|
|
83
|
+
containerPort: {{ $cfg.port }}
|
|
84
|
+
{{- end }}
|
|
85
|
+
{{- if $scraped }}
|
|
86
|
+
- name: metrics
|
|
87
|
+
containerPort: {{ $root.Values.metricsPort | int }}
|
|
88
|
+
{{- end }}
|
|
89
|
+
{{- end }}
|
|
90
|
+
{{/*
|
|
91
|
+
Probes follow the role, because the roles do not agree on what they open. web and sync serve
|
|
92
|
+
HTTP and get both. worker, scheduler and replicator open no HTTP socket at all — the scrape
|
|
93
|
+
listener is their only port — so they take liveness on it and NO readiness: nothing routes to
|
|
94
|
+
them, and a readiness flap would drop the pod out of the Service and so out of the scrape.
|
|
95
|
+
*/}}
|
|
96
|
+
{{- if $cfg.port }}
|
|
97
|
+
readinessProbe:
|
|
98
|
+
httpGet: { path: /readyz, port: http }
|
|
99
|
+
periodSeconds: 5
|
|
100
|
+
livenessProbe:
|
|
101
|
+
httpGet: { path: /healthz, port: http }
|
|
102
|
+
periodSeconds: 15
|
|
103
|
+
{{- else if $scraped }}
|
|
104
|
+
livenessProbe:
|
|
105
|
+
httpGet: { path: /metrics, port: metrics }
|
|
106
|
+
periodSeconds: 15
|
|
107
|
+
failureThreshold: 4
|
|
108
|
+
{{- end }}
|
|
109
|
+
resources: {{- toYaml $cfg.resources | nindent 4 }}
|
|
110
|
+
volumeMounts:
|
|
111
|
+
- name: tmp
|
|
112
|
+
mountPath: /tmp
|
|
113
|
+
{{- end -}}
|
|
114
|
+
`;
|
|
115
|
+
|
|
116
|
+
const deployments = (app: NameSet): string => `{{/*
|
|
117
|
+
One Deployment per enabled role, from one image. terminationGracePeriodSeconds matches the
|
|
118
|
+
framework's SIGTERM drain: in-flight requests, open websockets and running job steps finish.
|
|
119
|
+
*/}}
|
|
120
|
+
{{- range $role, $cfg := .Values.roles }}
|
|
121
|
+
{{- if $cfg.enabled }}
|
|
122
|
+
---
|
|
123
|
+
apiVersion: apps/v1
|
|
124
|
+
kind: Deployment
|
|
125
|
+
metadata:
|
|
126
|
+
name: {{ include "${app.kebab}.fullname" $ }}-{{ $role }}
|
|
127
|
+
labels:
|
|
128
|
+
{{- include "${app.kebab}.labels" $ | nindent 4 }}
|
|
129
|
+
app.kubernetes.io/component: {{ $role }}
|
|
130
|
+
spec:
|
|
131
|
+
{{/* Ask whether the block EXISTS before reading through it: scheduler and replicator declare
|
|
132
|
+
none, and without the guard the whole chart fails to render. */}}
|
|
133
|
+
{{- if not (and $cfg.autoscaling $cfg.autoscaling.enabled) }}
|
|
134
|
+
replicas: {{ $cfg.replicas }}
|
|
135
|
+
{{- end }}
|
|
136
|
+
selector:
|
|
137
|
+
matchLabels:
|
|
138
|
+
app.kubernetes.io/instance: {{ $.Release.Name }}
|
|
139
|
+
app.kubernetes.io/component: {{ $role }}
|
|
140
|
+
strategy:
|
|
141
|
+
type: RollingUpdate
|
|
142
|
+
rollingUpdate: { maxUnavailable: 0, maxSurge: 1 }
|
|
143
|
+
template:
|
|
144
|
+
metadata:
|
|
145
|
+
labels:
|
|
146
|
+
{{- include "${app.kebab}.labels" $ | nindent 8 }}
|
|
147
|
+
app.kubernetes.io/component: {{ $role }}
|
|
148
|
+
spec:
|
|
149
|
+
{{/* No role in this image calls the Kubernetes API — ROLE comes from the env and everything
|
|
150
|
+
else from app.config.ts. Mounting the token anyway hands a JWT for this namespace to any
|
|
151
|
+
code that reaches the filesystem, which for a web role is one path traversal. */}}
|
|
152
|
+
automountServiceAccountToken: false
|
|
153
|
+
securityContext: {{- toYaml $.Values.podSecurityContext | nindent 8 }}
|
|
154
|
+
{{/* At least the framework's drain deadline (25s by default), which is what
|
|
155
|
+
X_SHUTDOWN_TIMEOUT's fix line tells an operator. Raise this WITH configureLifecycle({
|
|
156
|
+
deadlineMs }), never instead of it: the drain abandons its hooks at its own deadline. */}}
|
|
157
|
+
terminationGracePeriodSeconds: 45
|
|
158
|
+
containers:
|
|
159
|
+
{{- include "${app.kebab}.container" (dict "role" $role "cfg" $cfg "root" $) | nindent 8 }}
|
|
160
|
+
volumes:
|
|
161
|
+
- name: tmp
|
|
162
|
+
emptyDir: {}
|
|
163
|
+
{{- end }}
|
|
164
|
+
{{- end }}
|
|
165
|
+
`;
|
|
166
|
+
|
|
167
|
+
const service = (app: NameSet): string => `{{/*
|
|
168
|
+
One Service per enabled role. The roles that take no traffic still get one, HEADLESS: it exists so
|
|
169
|
+
a scrape target and a DNS name exist, and a headless Service allocates no ClusterIP, so nothing
|
|
170
|
+
about it invites traffic it would not answer.
|
|
171
|
+
*/}}
|
|
172
|
+
{{- range $role, $cfg := .Values.roles }}
|
|
173
|
+
{{- if $cfg.enabled }}
|
|
174
|
+
---
|
|
175
|
+
apiVersion: v1
|
|
176
|
+
kind: Service
|
|
177
|
+
metadata:
|
|
178
|
+
name: {{ include "${app.kebab}.fullname" $ }}-{{ $role }}
|
|
179
|
+
labels:
|
|
180
|
+
{{- include "${app.kebab}.labels" $ | nindent 4 }}
|
|
181
|
+
app.kubernetes.io/component: {{ $role }}
|
|
182
|
+
spec:
|
|
183
|
+
type: ClusterIP
|
|
184
|
+
{{- if not $cfg.port }}
|
|
185
|
+
clusterIP: None
|
|
186
|
+
{{- end }}
|
|
187
|
+
selector:
|
|
188
|
+
app.kubernetes.io/instance: {{ $.Release.Name }}
|
|
189
|
+
app.kubernetes.io/component: {{ $role }}
|
|
190
|
+
ports:
|
|
191
|
+
{{- if $cfg.port }}
|
|
192
|
+
- name: http
|
|
193
|
+
port: 80
|
|
194
|
+
targetPort: http
|
|
195
|
+
{{- end }}
|
|
196
|
+
- name: metrics
|
|
197
|
+
port: {{ $.Values.metricsPort | int }}
|
|
198
|
+
targetPort: metrics
|
|
199
|
+
{{- end }}
|
|
200
|
+
{{- end }}
|
|
201
|
+
`;
|
|
202
|
+
|
|
203
|
+
const migrateJob = (app: NameSet): string => `{{/*
|
|
204
|
+
The release phase, as a pre-install / pre-upgrade hook: ROLE=migrate applies every pending
|
|
205
|
+
migration under an advisory lock and exits, before any serving role starts. Same image, same entry
|
|
206
|
+
point — there is no second migration engine and no toolchain in the container.
|
|
207
|
+
*/}}
|
|
208
|
+
{{- if .Values.migrate.enabled }}
|
|
209
|
+
apiVersion: batch/v1
|
|
210
|
+
kind: Job
|
|
211
|
+
metadata:
|
|
212
|
+
name: {{ include "${app.kebab}.fullname" . }}-migrate
|
|
213
|
+
labels:
|
|
214
|
+
{{- include "${app.kebab}.labels" . | nindent 4 }}
|
|
215
|
+
app.kubernetes.io/component: migrate
|
|
216
|
+
annotations:
|
|
217
|
+
helm.sh/hook: pre-install,pre-upgrade
|
|
218
|
+
helm.sh/hook-weight: "-5"
|
|
219
|
+
helm.sh/hook-delete-policy: before-hook-creation
|
|
220
|
+
spec:
|
|
221
|
+
backoffLimit: {{ .Values.migrate.backoffLimit }}
|
|
222
|
+
template:
|
|
223
|
+
metadata:
|
|
224
|
+
labels:
|
|
225
|
+
{{- include "${app.kebab}.labels" . | nindent 8 }}
|
|
226
|
+
app.kubernetes.io/component: migrate
|
|
227
|
+
spec:
|
|
228
|
+
restartPolicy: Never
|
|
229
|
+
automountServiceAccountToken: false
|
|
230
|
+
securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }}
|
|
231
|
+
containers:
|
|
232
|
+
{{- include "${app.kebab}.container" (dict "role" "migrate" "cfg" .Values.migrate "root" .) | nindent 8 }}
|
|
233
|
+
volumes:
|
|
234
|
+
- name: tmp
|
|
235
|
+
emptyDir: {}
|
|
236
|
+
{{- end }}
|
|
237
|
+
`;
|
|
238
|
+
|
|
239
|
+
const ingress = (
|
|
240
|
+
app: NameSet,
|
|
241
|
+
): string => `{{/* Standard ingress, no vendor edge primitives. Off by default: the host is yours to name. */}}
|
|
242
|
+
{{- if .Values.ingress.enabled }}
|
|
243
|
+
apiVersion: networking.k8s.io/v1
|
|
244
|
+
kind: Ingress
|
|
245
|
+
metadata:
|
|
246
|
+
name: {{ include "${app.kebab}.fullname" . }}
|
|
247
|
+
labels:
|
|
248
|
+
{{- include "${app.kebab}.labels" . | nindent 4 }}
|
|
249
|
+
spec:
|
|
250
|
+
ingressClassName: {{ .Values.ingress.className }}
|
|
251
|
+
{{- if .Values.ingress.tls }}
|
|
252
|
+
tls:
|
|
253
|
+
- hosts: [{{ .Values.ingress.host | quote }}]
|
|
254
|
+
secretName: {{ include "${app.kebab}.fullname" . }}-tls
|
|
255
|
+
{{- end }}
|
|
256
|
+
rules:
|
|
257
|
+
- host: {{ .Values.ingress.host | quote }}
|
|
258
|
+
http:
|
|
259
|
+
paths:
|
|
260
|
+
{{- /* The path the sync node actually serves. Routing /_sync instead sends every
|
|
261
|
+
websocket to the web role, which answers no upgrade. */}}
|
|
262
|
+
- path: /_x/sync
|
|
263
|
+
pathType: Prefix
|
|
264
|
+
backend:
|
|
265
|
+
service:
|
|
266
|
+
name: {{ include "${app.kebab}.fullname" . }}-sync
|
|
267
|
+
port: { name: http }
|
|
268
|
+
- path: /
|
|
269
|
+
pathType: Prefix
|
|
270
|
+
backend:
|
|
271
|
+
service:
|
|
272
|
+
name: {{ include "${app.kebab}.fullname" . }}-web
|
|
273
|
+
port: { name: http }
|
|
274
|
+
{{- end }}
|
|
275
|
+
`;
|
|
276
|
+
|
|
277
|
+
const hpa = (app: NameSet): string => `{{/*
|
|
278
|
+
Per-role autoscalers, off until you turn one on. Each role scales on the signal that predicts ITS
|
|
279
|
+
saturation — CPU is a lagging proxy for all three and scales the wrong thing at the wrong time.
|
|
280
|
+
A Pods metric needs a metrics adapter in the cluster; without one the HPA reads <unknown> and
|
|
281
|
+
holds at minReplicas, which is why these are opt-in rather than a default nobody wired.
|
|
282
|
+
*/}}
|
|
283
|
+
{{- range $role, $cfg := .Values.roles }}
|
|
284
|
+
{{- if and $cfg.enabled $cfg.autoscaling $cfg.autoscaling.enabled }}
|
|
285
|
+
---
|
|
286
|
+
apiVersion: autoscaling/v2
|
|
287
|
+
kind: HorizontalPodAutoscaler
|
|
288
|
+
metadata:
|
|
289
|
+
name: {{ include "${app.kebab}.fullname" $ }}-{{ $role }}
|
|
290
|
+
labels:
|
|
291
|
+
{{- include "${app.kebab}.labels" $ | nindent 4 }}
|
|
292
|
+
app.kubernetes.io/component: {{ $role }}
|
|
293
|
+
spec:
|
|
294
|
+
scaleTargetRef:
|
|
295
|
+
apiVersion: apps/v1
|
|
296
|
+
kind: Deployment
|
|
297
|
+
name: {{ include "${app.kebab}.fullname" $ }}-{{ $role }}
|
|
298
|
+
minReplicas: {{ $cfg.autoscaling.minReplicas }}
|
|
299
|
+
maxReplicas: {{ $cfg.autoscaling.maxReplicas }}
|
|
300
|
+
metrics:
|
|
301
|
+
- type: Pods
|
|
302
|
+
pods:
|
|
303
|
+
metric:
|
|
304
|
+
name: {{ $cfg.autoscaling.metric }}
|
|
305
|
+
target:
|
|
306
|
+
type: AverageValue
|
|
307
|
+
averageValue: {{ $cfg.autoscaling.targetAverageValue | quote }}
|
|
308
|
+
behavior:
|
|
309
|
+
scaleUp:
|
|
310
|
+
stabilizationWindowSeconds: 30
|
|
311
|
+
scaleDown:
|
|
312
|
+
stabilizationWindowSeconds: 300
|
|
313
|
+
{{- end }}
|
|
314
|
+
{{- end }}
|
|
315
|
+
`;
|
|
316
|
+
|
|
317
|
+
/** The chart's `templates/` directory, in the order a reader meets it. */
|
|
318
|
+
export function helmTemplateFiles(app: NameSet): readonly GeneratedFile[] {
|
|
319
|
+
return [
|
|
320
|
+
{ path: 'docker/helm/templates/_helpers.tpl', contents: helpers(app) },
|
|
321
|
+
{ path: 'docker/helm/templates/deployments.yaml', contents: deployments(app) },
|
|
322
|
+
{ path: 'docker/helm/templates/service.yaml', contents: service(app) },
|
|
323
|
+
{ path: 'docker/helm/templates/migrate-job.yaml', contents: migrateJob(app) },
|
|
324
|
+
{ path: 'docker/helm/templates/ingress.yaml', contents: ingress(app) },
|
|
325
|
+
{ path: 'docker/helm/templates/hpa.yaml', contents: hpa(app) },
|
|
326
|
+
];
|
|
327
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// The chart `x deploy --method helm` runs, written by `x new`. The other method's topology file
|
|
2
|
+
// (`docker/docker-compose.prod.yml`) has always been scaffolded; a deploy method whose artifact
|
|
3
|
+
// only exists in a repository the app never had is a command that cannot run.
|
|
4
|
+
//
|
|
5
|
+
// This file is the chart's INPUTS — Chart.yaml and values.yaml. The objects they render are
|
|
6
|
+
// scaffold-helm-templates.ts.
|
|
7
|
+
|
|
8
|
+
import type { GeneratedFile, NameSet } from './naming';
|
|
9
|
+
import { helmTemplateFiles } from './scaffold-helm-templates';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `appVersion` is the default image tag (`image.tag: ""` below), so it must name a tag that can
|
|
13
|
+
* exist. `x deploy --image <ref>` overrides both keys, which is the path that actually ships.
|
|
14
|
+
*/
|
|
15
|
+
const chart = (app: NameSet): string => `apiVersion: v2
|
|
16
|
+
name: ${app.kebab}
|
|
17
|
+
description: One image, N roles. Per-role deployments for ${app.kebab}.
|
|
18
|
+
type: application
|
|
19
|
+
version: 0.1.0
|
|
20
|
+
appVersion: "0.1.0"
|
|
21
|
+
kubeVersion: ">=1.27.0-0"
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
const values = (
|
|
25
|
+
app: NameSet,
|
|
26
|
+
): string => `# One image for every role; ROLE is the only difference between the workloads.
|
|
27
|
+
image:
|
|
28
|
+
repository: ${app.kebab}
|
|
29
|
+
tag: "" # defaults to .Chart.AppVersion; x deploy --image sets this and repository
|
|
30
|
+
pullPolicy: IfNotPresent
|
|
31
|
+
|
|
32
|
+
# Non-secret configuration. Secrets come from an existing Secret, never from this file:
|
|
33
|
+
# kubectl create secret generic ${app.kebab}-secrets --from-literal=DATABASE_URL=...
|
|
34
|
+
env:
|
|
35
|
+
NODE_ENV: production
|
|
36
|
+
|
|
37
|
+
existingSecret: ${app.kebab}-secrets # DATABASE_URL, NATS_URL, S3_*, AUTH_SECRET
|
|
38
|
+
|
|
39
|
+
# The scrape listener every serving role opens, on its own port and never the app's: the ingress
|
|
40
|
+
# routes / to web, so /metrics beside /healthz on 3000 is /metrics on the internet. This is
|
|
41
|
+
# METRICS_PORT in the container; move one and the other follows.
|
|
42
|
+
metricsPort: 9090
|
|
43
|
+
|
|
44
|
+
podSecurityContext:
|
|
45
|
+
runAsNonRoot: true
|
|
46
|
+
runAsUser: 65532
|
|
47
|
+
seccompProfile: { type: RuntimeDefault }
|
|
48
|
+
|
|
49
|
+
securityContext:
|
|
50
|
+
allowPrivilegeEscalation: false
|
|
51
|
+
readOnlyRootFilesystem: true
|
|
52
|
+
capabilities: { drop: [ALL] }
|
|
53
|
+
|
|
54
|
+
# The release phase. Runs to completion before any serving role starts.
|
|
55
|
+
migrate:
|
|
56
|
+
enabled: true
|
|
57
|
+
backoffLimit: 1
|
|
58
|
+
resources:
|
|
59
|
+
requests: { cpu: 100m, memory: 128Mi }
|
|
60
|
+
limits: { memory: 512Mi }
|
|
61
|
+
|
|
62
|
+
# One entry per role this chart may run. \`port\` is the port the role LISTENS on, not the value of
|
|
63
|
+
# PORT: they differ for sync, which binds PORT + 1, and the chart derives the env from this number
|
|
64
|
+
# so there is only ever one to move.
|
|
65
|
+
#
|
|
66
|
+
# Autoscaling is off until you wire a metrics adapter — each \`metric\` below is a Pods metric the
|
|
67
|
+
# role exports, and an HPA with no adapter behind it reads <unknown> and holds at minReplicas.
|
|
68
|
+
roles:
|
|
69
|
+
web:
|
|
70
|
+
enabled: true
|
|
71
|
+
replicas: 2
|
|
72
|
+
port: 3000
|
|
73
|
+
resources:
|
|
74
|
+
requests: { cpu: 200m, memory: 256Mi }
|
|
75
|
+
limits: { memory: 512Mi }
|
|
76
|
+
autoscaling:
|
|
77
|
+
enabled: false
|
|
78
|
+
minReplicas: 2
|
|
79
|
+
maxReplicas: 30
|
|
80
|
+
metric: rps # requests per second
|
|
81
|
+
targetAverageValue: "50"
|
|
82
|
+
|
|
83
|
+
sync:
|
|
84
|
+
enabled: true
|
|
85
|
+
replicas: 2
|
|
86
|
+
port: 3001
|
|
87
|
+
resources:
|
|
88
|
+
requests: { cpu: 200m, memory: 512Mi }
|
|
89
|
+
limits: { memory: 1Gi }
|
|
90
|
+
autoscaling:
|
|
91
|
+
enabled: false
|
|
92
|
+
minReplicas: 2
|
|
93
|
+
maxReplicas: 40
|
|
94
|
+
metric: connections # concurrent websockets per pod
|
|
95
|
+
targetAverageValue: "2000"
|
|
96
|
+
|
|
97
|
+
worker:
|
|
98
|
+
enabled: true
|
|
99
|
+
replicas: 2
|
|
100
|
+
resources:
|
|
101
|
+
requests: { cpu: 200m, memory: 256Mi }
|
|
102
|
+
limits: { memory: 1Gi }
|
|
103
|
+
autoscaling:
|
|
104
|
+
enabled: false
|
|
105
|
+
minReplicas: 1
|
|
106
|
+
maxReplicas: 50
|
|
107
|
+
metric: queue_depth # jobs waiting, exported by the worker role
|
|
108
|
+
targetAverageValue: "100"
|
|
109
|
+
|
|
110
|
+
scheduler:
|
|
111
|
+
enabled: true
|
|
112
|
+
# Fixed 1, and a second replica is safe but pointless: leadership is an expiring row in
|
|
113
|
+
# x_scheduler_leader, so the extra pod stands by.
|
|
114
|
+
replicas: 1
|
|
115
|
+
resources:
|
|
116
|
+
requests: { cpu: 50m, memory: 128Mi }
|
|
117
|
+
limits: { memory: 256Mi }
|
|
118
|
+
|
|
119
|
+
replicator:
|
|
120
|
+
enabled: false # exactly one per database; enable when the change feed is in use
|
|
121
|
+
replicas: 1
|
|
122
|
+
resources:
|
|
123
|
+
requests: { cpu: 100m, memory: 256Mi }
|
|
124
|
+
limits: { memory: 512Mi }
|
|
125
|
+
|
|
126
|
+
ingress:
|
|
127
|
+
enabled: false
|
|
128
|
+
className: nginx
|
|
129
|
+
host: ${app.kebab}.example.com
|
|
130
|
+
tls: true
|
|
131
|
+
`;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The chart for a new app: five kinds of object, all of them core Kubernetes. A ServiceMonitor and
|
|
135
|
+
* a PodDisruptionBudget are deliberately absent — the first needs a CRD `helm install` fails on
|
|
136
|
+
* where no Prometheus operator is installed, and both are cluster policy rather than app topology.
|
|
137
|
+
*/
|
|
138
|
+
export function helmFiles(app: NameSet): readonly GeneratedFile[] {
|
|
139
|
+
return [
|
|
140
|
+
{ path: 'docker/helm/Chart.yaml', contents: chart(app) },
|
|
141
|
+
{ path: 'docker/helm/values.yaml', contents: values(app) },
|
|
142
|
+
...helmTemplateFiles(app),
|
|
143
|
+
];
|
|
144
|
+
}
|
|
@@ -120,6 +120,13 @@ const envDeclaration = (): string => `${envSchemaSource()}
|
|
|
120
120
|
*/
|
|
121
121
|
export const env = defineEnv(envSchema);`;
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* No `installPrompt`. `PwaConfig` declares it, `defineConfig` defaults it, and NO file reads it —
|
|
125
|
+
* `packages/core/src/config.ts` carries the marker saying so. Scaffolding it wrote a switch with
|
|
126
|
+
* no wire into every generated app, and the note belongs HERE rather than in the emitted file: an
|
|
127
|
+
* app author has no use for a comment about a framework key their config does not name. Deleting
|
|
128
|
+
* the key itself is core's edit; not writing it is this template's half.
|
|
129
|
+
*/
|
|
123
130
|
const appConfig = (
|
|
124
131
|
app: NameSet,
|
|
125
132
|
): string => `// The one config file. Everything the app needs to boot is here, typed and validated at startup —
|
|
@@ -142,7 +149,7 @@ export const config = defineConfig({
|
|
|
142
149
|
jobs: { driver: 'postgres', queues: ['${app.kebab}-default'], concurrency: 4 },
|
|
143
150
|
// In-process transport by default; set urlEnv and transport: 'nats' to scale past one node.
|
|
144
151
|
realtime: { enabled: true, tier: 'live-queries', transport: 'memory' },
|
|
145
|
-
pwa: { enabled: true, offline: 'runtime'
|
|
152
|
+
pwa: { enabled: true, offline: 'runtime' },
|
|
146
153
|
ai: { mcp: { expose: true, path: '/mcp' } },
|
|
147
154
|
});
|
|
148
155
|
`;
|
|
@@ -189,11 +196,16 @@ const biome = (): string => `{
|
|
|
189
196
|
* to any root with an `app.config.ts`. Typed as `VerifyStepName`, so a name the gate does not run
|
|
190
197
|
* is a compile error rather than a floor that covers nothing.
|
|
191
198
|
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
199
|
+
* Plus `contract`, which every variant now ships a file for — `apps/web/api/health.contract.test.ts`
|
|
200
|
+
* — and, with the example slice, `live` and `job`: its query is a `liveTest` and its job a
|
|
201
|
+
* `jobTest`, and both were written into plain `<name>.test.ts` files that the `unit` step ran while
|
|
202
|
+
* `x test live` and `x test job` answered X_TEST_NO_FILES. This is the commit that makes the app's
|
|
203
|
+
* own gate run them, which is what the paragraph below always said the condition was.
|
|
204
|
+
*
|
|
205
|
+
* Two remain absent. `e2e` has a scaffolded file and it is an `e2eTest` — `test.skip` until the app
|
|
206
|
+
* registers a browser driver, so the step would run zero tests and fail the ratchet on the
|
|
207
|
+
* scaffold's own placeholder. `contract-diff` needs a committed `x.manifest.json`, which
|
|
208
|
+
* `x manifest` writes later. Each joins the list in the commit that makes the app's gate run it.
|
|
197
209
|
*/
|
|
198
210
|
const SCAFFOLD_FLOOR: readonly VerifyStepName[] = [
|
|
199
211
|
'typecheck',
|
|
@@ -203,13 +215,18 @@ const SCAFFOLD_FLOOR: readonly VerifyStepName[] = [
|
|
|
203
215
|
'package-shape',
|
|
204
216
|
'errors',
|
|
205
217
|
'unit',
|
|
218
|
+
'contract',
|
|
206
219
|
'eval',
|
|
207
220
|
'drift',
|
|
208
221
|
'budgets',
|
|
209
222
|
'manifest',
|
|
210
223
|
];
|
|
211
224
|
|
|
212
|
-
|
|
225
|
+
/** The two suites only the example slice writes a file for — a floor step with no file is red. */
|
|
226
|
+
const EXAMPLE_FLOOR: readonly VerifyStepName[] = ['live', 'job'];
|
|
227
|
+
|
|
228
|
+
const verifyFloor = (example: boolean): string =>
|
|
229
|
+
`${JSON.stringify({ steps: example ? [...SCAFFOLD_FLOOR, ...EXAMPLE_FLOOR] : SCAFFOLD_FLOOR }, null, 2)}\n`;
|
|
213
230
|
|
|
214
231
|
const bunfig = (): string => `[test]
|
|
215
232
|
root = "."
|
|
@@ -276,7 +293,7 @@ export function repoFiles(
|
|
|
276
293
|
{ path: 'biome.json', contents: biome() },
|
|
277
294
|
{ path: 'bunfig.toml', contents: bunfig() },
|
|
278
295
|
{ path: 'app.config.ts', contents: appConfig(app) },
|
|
279
|
-
{ path: VERIFY_FLOOR_FILE, contents: verifyFloor() },
|
|
296
|
+
{ path: VERIFY_FLOOR_FILE, contents: verifyFloor(example) },
|
|
280
297
|
{ path: 'types/scss.d.ts', contents: scssTypes() },
|
|
281
298
|
{ path: '.gitignore', contents: gitignore() },
|
|
282
299
|
{ path: '.env.development', contents: envDevelopment() },
|