@rulebricks/cli 2.1.6 → 2.3.1

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.
Files changed (114) hide show
  1. package/README.md +75 -14
  2. package/cluster-setup/aws/README.md +123 -0
  3. package/cluster-setup/aws/check-aws-access.sh +242 -0
  4. package/cluster-setup/aws/parameters.json +13 -0
  5. package/cluster-setup/aws/rulebricks-cluster.cfn.yaml +355 -0
  6. package/cluster-setup/azure/README.md +141 -0
  7. package/cluster-setup/azure/check-aks-prereqs.sh +276 -0
  8. package/cluster-setup/azure/parameters.json +30 -0
  9. package/cluster-setup/azure/rulebricks-cluster.bicep +546 -0
  10. package/cluster-setup/gcp/README.md +189 -0
  11. package/cluster-setup/gcp/check-gke-prereqs.sh +260 -0
  12. package/dist/commands/backup.d.ts +5 -0
  13. package/dist/commands/backup.js +104 -0
  14. package/dist/commands/deploy.d.ts +3 -1
  15. package/dist/commands/deploy.js +226 -326
  16. package/dist/commands/destroy.d.ts +1 -1
  17. package/dist/commands/destroy.js +73 -123
  18. package/dist/commands/init.d.ts +5 -1
  19. package/dist/commands/init.js +78 -47
  20. package/dist/commands/list.d.ts +1 -0
  21. package/dist/commands/list.js +74 -0
  22. package/dist/commands/open.d.ts +1 -1
  23. package/dist/commands/open.js +4 -12
  24. package/dist/commands/redeploy.d.ts +6 -0
  25. package/dist/commands/redeploy.js +310 -0
  26. package/dist/commands/restore.d.ts +5 -0
  27. package/dist/commands/restore.js +338 -0
  28. package/dist/commands/status.js +62 -49
  29. package/dist/commands/upgrade.js +74 -51
  30. package/dist/components/DNSWaitScreen.d.ts +5 -1
  31. package/dist/components/DNSWaitScreen.js +47 -41
  32. package/dist/components/Wizard/WizardContext.d.ts +174 -29
  33. package/dist/components/Wizard/WizardContext.js +896 -91
  34. package/dist/components/Wizard/steps/CloudProviderStep.js +192 -102
  35. package/dist/components/Wizard/steps/DomainStep.js +5 -24
  36. package/dist/components/Wizard/steps/ExternalServicesStep.d.ts +6 -0
  37. package/dist/components/Wizard/steps/ExternalServicesStep.js +645 -0
  38. package/dist/components/Wizard/steps/FeatureConfigStep.d.ts +2 -1
  39. package/dist/components/Wizard/steps/FeatureConfigStep.js +959 -248
  40. package/dist/components/Wizard/steps/FeaturesStep.js +31 -35
  41. package/dist/components/Wizard/steps/ObservabilityStep.d.ts +6 -0
  42. package/dist/components/Wizard/steps/ObservabilityStep.js +137 -0
  43. package/dist/components/Wizard/steps/ReviewStep.d.ts +2 -1
  44. package/dist/components/Wizard/steps/ReviewStep.js +56 -7
  45. package/dist/components/Wizard/steps/StorageStep.d.ts +9 -0
  46. package/dist/components/Wizard/steps/StorageStep.js +592 -0
  47. package/dist/components/Wizard/steps/SupabaseCredentialsStep.js +20 -21
  48. package/dist/components/Wizard/steps/VersionStep.js +45 -23
  49. package/dist/components/Wizard/steps/index.d.ts +3 -3
  50. package/dist/components/Wizard/steps/index.js +3 -3
  51. package/dist/components/common/CommandApproval.d.ts +12 -0
  52. package/dist/components/common/CommandApproval.js +91 -0
  53. package/dist/components/common/DeploymentPicker.d.ts +14 -0
  54. package/dist/components/common/DeploymentPicker.js +16 -0
  55. package/dist/components/common/index.d.ts +2 -0
  56. package/dist/components/common/index.js +2 -0
  57. package/dist/index.js +94 -62
  58. package/dist/lib/cloudCli.d.ts +134 -63
  59. package/dist/lib/cloudCli.js +512 -220
  60. package/dist/lib/clusterSetupDefaults.d.ts +30 -0
  61. package/dist/lib/clusterSetupDefaults.js +64 -0
  62. package/dist/lib/commandApproval.d.ts +26 -0
  63. package/dist/lib/commandApproval.js +114 -0
  64. package/dist/lib/config.d.ts +12 -10
  65. package/dist/lib/config.js +91 -33
  66. package/dist/lib/configFixtures.d.ts +5 -0
  67. package/dist/lib/configFixtures.js +513 -0
  68. package/dist/lib/deploymentHealth.d.ts +32 -0
  69. package/dist/lib/deploymentHealth.js +157 -0
  70. package/dist/lib/dns.d.ts +1 -1
  71. package/dist/lib/dns.js +19 -1
  72. package/dist/lib/dns.test.d.ts +1 -0
  73. package/dist/lib/dns.test.js +27 -0
  74. package/dist/lib/dockerHub.d.ts +12 -1
  75. package/dist/lib/dockerHub.js +18 -8
  76. package/dist/lib/helm.d.ts +4 -0
  77. package/dist/lib/helm.js +16 -0
  78. package/dist/lib/helmValues.d.ts +25 -0
  79. package/dist/lib/helmValues.js +1937 -259
  80. package/dist/lib/helmValues.test.d.ts +1 -0
  81. package/dist/lib/helmValues.test.js +966 -0
  82. package/dist/lib/htpasswd.d.ts +1 -0
  83. package/dist/lib/htpasswd.js +15 -0
  84. package/dist/lib/kubernetes.d.ts +126 -13
  85. package/dist/lib/kubernetes.js +624 -134
  86. package/dist/lib/secrets.d.ts +23 -0
  87. package/dist/lib/secrets.js +158 -0
  88. package/dist/lib/validateValues.d.ts +31 -0
  89. package/dist/lib/validateValues.js +253 -0
  90. package/dist/lib/versions.d.ts +82 -11
  91. package/dist/lib/versions.js +131 -31
  92. package/dist/lib/versions.test.d.ts +1 -0
  93. package/dist/lib/versions.test.js +81 -0
  94. package/dist/lib/wizardSteps.d.ts +14 -0
  95. package/dist/lib/wizardSteps.js +23 -0
  96. package/dist/lib/workloadIdentity.d.ts +26 -0
  97. package/dist/lib/workloadIdentity.js +323 -0
  98. package/dist/lib/workloadIdentity.test.d.ts +1 -0
  99. package/dist/lib/workloadIdentity.test.js +57 -0
  100. package/dist/types/index.d.ts +2152 -95
  101. package/dist/types/index.js +554 -286
  102. package/package.json +10 -4
  103. package/schema/values.schema.json +1934 -0
  104. package/dist/components/Wizard/steps/CredentialsStep.d.ts +0 -6
  105. package/dist/components/Wizard/steps/CredentialsStep.js +0 -22
  106. package/dist/components/Wizard/steps/DeploymentModeStep.d.ts +0 -5
  107. package/dist/components/Wizard/steps/DeploymentModeStep.js +0 -26
  108. package/dist/components/Wizard/steps/TierStep.d.ts +0 -6
  109. package/dist/components/Wizard/steps/TierStep.js +0 -29
  110. package/dist/lib/terraform.d.ts +0 -66
  111. package/dist/lib/terraform.js +0 -754
  112. package/terraform/aws/main.tf +0 -355
  113. package/terraform/azure/main.tf +0 -371
  114. package/terraform/gcp/main.tf +0 -407
@@ -0,0 +1,966 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { buildHelmValues, signSupabaseJwt } from "./helmValues.js";
6
+ import { getActiveWizardSteps } from "./wizardSteps.js";
7
+ import { collectConfigIssues, configToWizardState, } from "../components/Wizard/WizardContext.js";
8
+ import { storageProviderForCloud, storageRegionForCloud, } from "../components/Wizard/steps/StorageStep.js";
9
+ import { validateHelmValues, validateValuesInvariants, } from "./validateValues.js";
10
+ import { buildConfigMatrix } from "./configFixtures.js";
11
+ import { DeploymentConfigSchema, validateRemoteWriteConfig, getReleaseName, } from "../types/index.js";
12
+ const matrix = buildConfigMatrix();
13
+ const BURST_POOL_TOLERATION = {
14
+ key: "rulebricks.com/pool",
15
+ operator: "Equal",
16
+ value: "burst",
17
+ effect: "NoSchedule",
18
+ };
19
+ function cloneFixture(name) {
20
+ const entry = matrix.find((c) => c.name === name);
21
+ assert.ok(entry, `missing matrix fixture ${name}`);
22
+ return JSON.parse(JSON.stringify(entry.config));
23
+ }
24
+ function assertNoBareExistsToleration(label, tolerations) {
25
+ assert.ok(tolerations.every((tol) => !(tol.operator === "Exists" && !tol.key)), `${label}: must not contain a bare operator: Exists toleration`);
26
+ }
27
+ function assertIncludesToleration(label, tolerations, expected) {
28
+ assert.ok(tolerations.some((tol) => Object.entries(expected).every(([key, value]) => tol[key] === value)), `${label}: expected toleration ${JSON.stringify(expected)}, got ${JSON.stringify(tolerations)}`);
29
+ }
30
+ test("config matrix parses against the deployment schema", () => {
31
+ for (const { name, config } of matrix) {
32
+ const result = DeploymentConfigSchema.safeParse(config);
33
+ assert.ok(result.success, `${name}: expected a valid DeploymentConfig but got ${result.success ? "" : JSON.stringify(result.error.issues, null, 2)}`);
34
+ }
35
+ });
36
+ test("generated Helm values are valid against the chart schema for every config", () => {
37
+ for (const { name, config } of matrix) {
38
+ const values = buildHelmValues(config);
39
+ const result = validateHelmValues(values);
40
+ assert.ok(result.valid, `${name}: generated values failed schema validation:\n${result.errors
41
+ .map((e) => ` - ${e}`)
42
+ .join("\n")}`);
43
+ }
44
+ });
45
+ test("generated values are valid both with and without TLS", () => {
46
+ for (const { name, config } of matrix) {
47
+ for (const tlsEnabled of [true, false]) {
48
+ const values = buildHelmValues(config, { tlsEnabled });
49
+ const result = validateHelmValues(values);
50
+ assert.ok(result.valid, `${name} (tls=${tlsEnabled}): ${result.errors.join("; ")}`);
51
+ }
52
+ }
53
+ });
54
+ test("ClickStack is the default in-cluster observability backend", () => {
55
+ const config = cloneFixture("aws-self-hosted-minimal");
56
+ const values = buildHelmValues(config);
57
+ assert.equal(values.global.clickstack.enabled, true);
58
+ assert.equal(typeof values.global.supabase.anonKey, "string");
59
+ assert.equal(typeof values.global.supabase.serviceKey, "string");
60
+ assert.ok(values.global.supabase.anonKey.length > 0);
61
+ assert.ok(values.global.supabase.serviceKey.length > 0);
62
+ assert.equal(values.decisionLogs, undefined);
63
+ assert.equal(values.global.clickstack.clickhouse, undefined);
64
+ assert.equal(values.clickstack.enabled, true);
65
+ assert.equal(values.clickhouse.persistence.enabled, true);
66
+ assert.equal(values.clickhouse.persistence.size, "100Gi");
67
+ assert.deepEqual(values.clickhouse.resources, {
68
+ requests: { cpu: "1000m", memory: "4Gi" },
69
+ limits: { cpu: "4", memory: "12Gi" },
70
+ });
71
+ assert.deepEqual(values.clickhouse.otelQueryLimits, {
72
+ maxMemoryUsage: 4294967296,
73
+ maxThreads: 8,
74
+ maxExecutionTime: 120,
75
+ });
76
+ assert.equal(values.clickstack.clickhouse.retentionDays, 7);
77
+ assert.equal(values.clickstack.clickhouse.ttl, "");
78
+ assert.equal(values.clickstack.clickhouse.decisionLogs, undefined);
79
+ assert.deepEqual(values.clickstack.hyperdx.resources, {
80
+ requests: { cpu: "250m", memory: "512Mi" },
81
+ limits: { cpu: "1000m", memory: "1Gi" },
82
+ });
83
+ assert.deepEqual(values.clickstack.collector.gateway.resources, {
84
+ requests: { cpu: "250m", memory: "512Mi" },
85
+ limits: { cpu: "2000m", memory: "1Gi" },
86
+ });
87
+ assert.deepEqual(values.clickstack.collector.agent.resources, {
88
+ requests: { cpu: "100m", memory: "256Mi" },
89
+ limits: { cpu: "500m", memory: "512Mi" },
90
+ });
91
+ assert.deepEqual(values.clickstack.ferretdb.persistence, {
92
+ enabled: true,
93
+ size: "10Gi",
94
+ storageClassName: "gp3",
95
+ });
96
+ assert.deepEqual(values.clickstack.ferretdb.resources.ferretdb, {
97
+ requests: { cpu: "100m", memory: "256Mi" },
98
+ limits: { cpu: "500m", memory: "512Mi" },
99
+ });
100
+ assert.deepEqual(values.clickstack.ferretdb.resources.postgres, {
101
+ requests: { cpu: "250m", memory: "512Mi" },
102
+ limits: { cpu: "1000m", memory: "1Gi" },
103
+ });
104
+ assert.equal(values["vector-agent"].enabled, false);
105
+ assert.equal(values.vector.customConfig.sinks.decision_logs_clickhouse, undefined);
106
+ assert.ok(values.vector.customConfig.sinks.decision_logs);
107
+ assert.equal(values.vector.env.some((entry) => entry.name === "CLICKHOUSE_PASSWORD"), false);
108
+ assert.equal(values.global.tracing, undefined);
109
+ assert.equal(values.traefik.tracing.otlp.enabled, true);
110
+ const remoteWrite = values["kube-prometheus-stack"].prometheus.prometheusSpec.remoteWrite;
111
+ assert.deepEqual(remoteWrite, []);
112
+ });
113
+ test("built-in observability settings flow into generated Helm values", () => {
114
+ const config = cloneFixture("aws-self-hosted-minimal");
115
+ config.features.observability = {
116
+ clickstack: {
117
+ enabled: true,
118
+ telemetryRetentionDays: 14,
119
+ clickHouseStorageSize: "250Gi",
120
+ },
121
+ };
122
+ const values = buildHelmValues(config);
123
+ assert.equal(values.clickstack.clickhouse.retentionDays, 14);
124
+ assert.equal(values.clickstack.clickhouse.decisionLogs, undefined);
125
+ assert.equal(values.global.clickstack.clickhouse, undefined);
126
+ assert.equal(values.clickhouse.persistence.size, "250Gi");
127
+ assert.equal(values.clickstack.ferretdb.persistence.size, "10Gi");
128
+ });
129
+ test("buildHelmValues rejects self-hosted Supabase without a JWT secret early", () => {
130
+ const config = cloneFixture("aws-self-hosted-minimal");
131
+ delete config.database.supabaseJwtSecret;
132
+ assert.throws(() => buildHelmValues(config), /Self-hosted Supabase is missing a JWT secret/);
133
+ });
134
+ test("buildHelmValues rejects enabled AI without an OpenAI key early", () => {
135
+ const config = cloneFixture("aws-self-hosted-minimal");
136
+ config.features.ai = { enabled: true };
137
+ assert.throws(() => buildHelmValues(config), /AI features are enabled but the OpenAI API key is missing/);
138
+ });
139
+ test("redeploy wizard backfills missing self-hosted Supabase JWT secret", () => {
140
+ const config = cloneFixture("aws-self-hosted-minimal");
141
+ delete config.database.supabaseJwtSecret;
142
+ const state = configToWizardState(config);
143
+ assert.equal(typeof state.supabaseJwtSecret, "string");
144
+ assert.ok(state.supabaseJwtSecret.length >= 32);
145
+ assert.equal(collectConfigIssues({ ...state, name: "redeploy-test" }).some((issue) => issue.includes("JWT secret")), false);
146
+ });
147
+ test("self-hosted Supabase keys derive from the configured JWT secret", () => {
148
+ const config = cloneFixture("aws-self-hosted-minimal");
149
+ config.database.supabaseJwtSecret = "test-jwt-secret-used-for-derived-keys";
150
+ const values = buildHelmValues(config);
151
+ assert.equal(values.global.supabase.anonKey, signSupabaseJwt("anon", config.database.supabaseJwtSecret));
152
+ assert.equal(values.global.supabase.serviceKey, signSupabaseJwt("service_role", config.database.supabaseJwtSecret));
153
+ assert.equal(values.supabase.secret.jwt.secret, config.database.supabaseJwtSecret);
154
+ });
155
+ test("wizard orders storage before observability and skips feature config for built-in observability alone", () => {
156
+ const state = {
157
+ databaseType: "self-hosted",
158
+ aiEnabled: false,
159
+ ssoEnabled: false,
160
+ clickStackEnabled: true,
161
+ metricsExportEnabled: false,
162
+ tracingEnabled: false,
163
+ appLogsEnabled: false,
164
+ valkeyAdminEnabled: false,
165
+ loggingSink: "console",
166
+ customEmailsEnabled: false,
167
+ };
168
+ const steps = getActiveWizardSteps(state, "create");
169
+ assert.deepEqual(steps.slice(steps.indexOf("external-services"), steps.indexOf("version")), ["external-services", "storage", "observability", "features"]);
170
+ assert.equal(steps.includes("feature-config"), false);
171
+ });
172
+ test("wizard routes enabled AI without key through feature config", () => {
173
+ const steps = getActiveWizardSteps({
174
+ databaseType: "self-hosted",
175
+ aiEnabled: true,
176
+ ssoEnabled: false,
177
+ clickStackEnabled: true,
178
+ metricsExportEnabled: false,
179
+ tracingEnabled: false,
180
+ appLogsEnabled: false,
181
+ valkeyAdminEnabled: false,
182
+ loggingSink: "console",
183
+ customEmailsEnabled: false,
184
+ }, "create");
185
+ assert.ok(steps.includes("feature-config"));
186
+ });
187
+ test("wizard includes feature config for BYO observability signals", () => {
188
+ const steps = getActiveWizardSteps({
189
+ databaseType: "self-hosted",
190
+ aiEnabled: false,
191
+ ssoEnabled: false,
192
+ clickStackEnabled: false,
193
+ metricsExportEnabled: true,
194
+ tracingEnabled: true,
195
+ appLogsEnabled: false,
196
+ valkeyAdminEnabled: false,
197
+ loggingSink: "console",
198
+ customEmailsEnabled: false,
199
+ }, "create");
200
+ assert.ok(steps.indexOf("storage") < steps.indexOf("observability"));
201
+ assert.ok(steps.indexOf("observability") < steps.indexOf("features"));
202
+ assert.ok(steps.includes("feature-config"));
203
+ });
204
+ test("wizard includes feature config for Valkey Admin options", () => {
205
+ const steps = getActiveWizardSteps({
206
+ databaseType: "self-hosted",
207
+ aiEnabled: false,
208
+ ssoEnabled: false,
209
+ clickStackEnabled: true,
210
+ metricsExportEnabled: false,
211
+ tracingEnabled: false,
212
+ appLogsEnabled: false,
213
+ valkeyAdminEnabled: true,
214
+ loggingSink: "console",
215
+ customEmailsEnabled: false,
216
+ }, "create");
217
+ assert.ok(steps.includes("feature-config"));
218
+ });
219
+ test("Valkey Admin ingress emits public hostname and BasicAuth users", () => {
220
+ const config = cloneFixture("aws-self-hosted-minimal");
221
+ config.features.cache = {
222
+ valkeyAdmin: {
223
+ enabled: true,
224
+ exposure: "ingress",
225
+ basicAuthUsers: ["admin:$2a$10$abcdefghijklmnopqrstuv"],
226
+ allowedIPs: ["203.0.113.0/24"],
227
+ },
228
+ redisExporter: { enabled: true },
229
+ kafkaExporter: { enabled: true },
230
+ };
231
+ const values = buildHelmValues(config);
232
+ const valkeyAdmin = values.rulebricks.cache.valkeyAdmin;
233
+ assert.equal(valkeyAdmin.enabled, true);
234
+ assert.equal(valkeyAdmin.exposure, "ingress");
235
+ assert.equal(valkeyAdmin.ingress.enabled, true);
236
+ assert.equal(valkeyAdmin.ingress.hostname, "valkey.rb.example.com");
237
+ assert.deepEqual(valkeyAdmin.ingress.basicAuth.users, [
238
+ "admin:$2a$10$abcdefghijklmnopqrstuv",
239
+ ]);
240
+ assert.deepEqual(valkeyAdmin.ingress.allowedIPs, ["203.0.113.0/24"]);
241
+ });
242
+ test("ClickHouse decision-log bootstrap reads only the object-storage archive", (t) => {
243
+ const candidates = [
244
+ process.env.RULEBRICKS_CHART_DIR,
245
+ path.resolve(process.cwd(), "../private/helm"),
246
+ path.resolve(process.cwd(), "../helm"),
247
+ ].filter(Boolean);
248
+ const chartDir = candidates.find((candidate) => fs.existsSync(path.join(candidate, "templates", "_defaults.tpl")));
249
+ if (!chartDir) {
250
+ t.skip("Helm chart templates not available in this checkout");
251
+ return;
252
+ }
253
+ const defaults = fs.readFileSync(path.join(chartDir, "templates", "_defaults.tpl"), "utf8");
254
+ assert.match(defaults, /rulebricks\.decision_logs_archive/);
255
+ assert.match(defaults, /CREATE OR REPLACE VIEW rulebricks\.decision_logs AS SELECT/);
256
+ assert.doesNotMatch(defaults, /rulebricks\.decision_logs_recent/);
257
+ assert.doesNotMatch(defaults, /TTL toDateTime\(timestamp\)/);
258
+ });
259
+ test("BYO observability opt-out disables ClickStack and keeps export paths", () => {
260
+ const config = cloneFixture("aws-tracing-elastic");
261
+ const values = buildHelmValues(config);
262
+ assert.equal(values.global.clickstack.enabled, false);
263
+ assert.equal(values.global.clickstack.clickhouse, undefined);
264
+ assert.equal(values.clickstack.enabled, false);
265
+ assert.equal(values.clickhouse.persistence.enabled, false);
266
+ assert.equal(values.vector.customConfig.sinks.decision_logs_clickhouse, undefined);
267
+ assert.equal(values.global.tracing.destination, "elastic");
268
+ assert.deepEqual(values["kube-prometheus-stack"].prometheus.prometheusSpec.remoteWrite, []);
269
+ });
270
+ function tierFixture(name) {
271
+ const entry = matrix.find((c) => c.name === name);
272
+ assert.ok(entry, `missing matrix fixture ${name}`);
273
+ return buildHelmValues(entry.config);
274
+ }
275
+ test("external MSK IAM topic-provisioning toggle maps to kafka.provisioning.enabled", () => {
276
+ const entry = matrix.find((c) => c.name === "aws-external-kafka-msk");
277
+ assert.ok(entry, "missing aws-external-kafka-msk fixture");
278
+ // Default (provisionTopics unset): the chart provisions topics.
279
+ const on = buildHelmValues(entry.config);
280
+ assert.equal(on.kafka.provisioning?.enabled, true);
281
+ // Locked-down: operator manages topics -> provisioning disabled, but the topic
282
+ // list is still emitted (so it's documented; the Job just won't render).
283
+ const cfg = JSON.parse(JSON.stringify(entry.config));
284
+ cfg.externalServices.kafka.external.provisionTopics = false;
285
+ const off = buildHelmValues(cfg);
286
+ assert.equal(off.kafka.provisioning?.enabled, false);
287
+ assert.equal(off.kafka.topics?.length, 3);
288
+ });
289
+ test("in-cluster provisioning uses baseline partitions and the (empty) prefix", () => {
290
+ // Tiers were removed: partition sizing is now a fixed baseline that mirrors
291
+ // the chart defaults, identical across every in-cluster deployment.
292
+ for (const fixtureName of [
293
+ "aws-self-hosted-minimal",
294
+ "gcp-self-hosted",
295
+ "azure-workload-identity",
296
+ ]) {
297
+ const values = tierFixture(fixtureName);
298
+ // In-cluster installs run UNPREFIXED; provisioning names must match.
299
+ assert.equal(values.rulebricks.app.logging.kafkaTopicPrefix, "");
300
+ assert.equal(values.kafka.enabled, true);
301
+ const topics = values.kafka.topics;
302
+ const byName = Object.fromEntries(topics.map((t) => [t.name, t]));
303
+ assert.deepEqual(Object.keys(byName).sort(), ["logs", "solution", "solution-response"], `${fixtureName}: topic names must be unprefixed`);
304
+ // Baseline partitions: the structural contract between provisioning,
305
+ // workers.solutionPartitions, and the chart defaults.
306
+ assert.equal(byName["solution"].partitions, 128);
307
+ assert.equal(byName["solution-response"].partitions, 128);
308
+ assert.equal(byName["logs"].partitions, 24);
309
+ // Single in-cluster broker: every topic stays RF 1.
310
+ assert.equal(byName["solution"].replicas, 1);
311
+ assert.equal(byName["logs"].replicas, 1);
312
+ // MAX_WORKERS source must match the solution topic exactly.
313
+ assert.equal(values.rulebricks.hps.workers.solutionPartitions, 128);
314
+ // Sizing (worker replicas/resources, keda min/max) is no longer emitted;
315
+ // it falls back to the chart defaults.
316
+ assert.equal(values.rulebricks.hps.workers.resources, undefined);
317
+ assert.equal(values.rulebricks.hps.workers.keda.maxReplicaCount, undefined);
318
+ // Non-tier scale-out tuning is still emitted (aggressive early scale-out).
319
+ assert.equal(values.rulebricks.hps.workers.keda.lagThreshold, 50);
320
+ // num.partitions is decoupled from worker count (auto-create default only).
321
+ assert.equal(values.kafka.config["num.partitions"], "12", `${fixtureName}: num.partitions must no longer track max workers`);
322
+ }
323
+ });
324
+ test("external MSK IAM populates topics for provisioning; other external Kafka stays customer-managed", () => {
325
+ // AWS MSK IAM: the chart's kafka-topic-provision Job creates these on the
326
+ // managed broker (through the proxy bridge), so they MUST be populated - MSK
327
+ // Serverless won't auto-create them.
328
+ const msk = tierFixture("aws-external-kafka-msk");
329
+ assert.equal(msk.kafka.enabled, false, "msk: kafka subchart off");
330
+ assert.equal(msk.kafka.topics?.length ?? 0, 3, "msk: topics populated so the provisioning Job can create them");
331
+ // GCP managed Kafka (no kafka-proxy bridge - a plain client reaches it
332
+ // directly): topics remain customer-managed, so the CLI emits none.
333
+ const gcp = tierFixture("gcp-external-kafka");
334
+ assert.equal(gcp.kafka.enabled, false, "gcp: kafka subchart off");
335
+ assert.equal(gcp.kafka.topics?.length ?? 0, 0, "gcp: no managed topics for non-bridge external Kafka");
336
+ });
337
+ test("invariant checker catches partition/worker and prefix drift", () => {
338
+ const base = tierFixture("aws-self-hosted-minimal");
339
+ // Healthy values pass.
340
+ assert.deepEqual(validateValuesInvariants(base), []);
341
+ // Workers above the partition ceiling.
342
+ const tooManyWorkers = JSON.parse(JSON.stringify(base));
343
+ tooManyWorkers.rulebricks.hps.workers.keda.maxReplicaCount =
344
+ tooManyWorkers.rulebricks.hps.workers.solutionPartitions + 1;
345
+ assert.ok(validateValuesInvariants(tooManyWorkers).some((e) => e.includes("maxReplicaCount")));
346
+ // Prefixed provisioning names while the app runs unprefixed (the original
347
+ // CLI/chart drift this guard exists for).
348
+ const wrongPrefix = JSON.parse(JSON.stringify(base));
349
+ for (const topic of wrongPrefix.kafka.topics) {
350
+ topic.name = `com.rulebricks.${topic.name}`;
351
+ }
352
+ assert.ok(validateValuesInvariants(wrongPrefix).some((e) => e.includes('must include "solution"')));
353
+ // Solution topic partitions diverging from solutionPartitions (MAX_WORKERS).
354
+ const divergedPartitions = JSON.parse(JSON.stringify(base));
355
+ divergedPartitions.kafka.topics[0].partitions += 8;
356
+ assert.ok(validateValuesInvariants(divergedPartitions).some((e) => e.includes("MAX_WORKERS")));
357
+ // Worker CPU request exceeding the limit is rejected (K8s would reject it).
358
+ // The CLI no longer emits worker resources (chart defaults apply), but the
359
+ // invariant must still catch a hand-edited values file that sets them wrong.
360
+ const requestOverLimit = JSON.parse(JSON.stringify(base));
361
+ requestOverLimit.rulebricks.hps.workers.resources = {
362
+ requests: { cpu: "4000m" },
363
+ limits: { cpu: "1000m" },
364
+ };
365
+ assert.ok(validateValuesInvariants(requestOverLimit).some((e) => e.includes("must not exceed limit")));
366
+ });
367
+ test("self-hosted deployments emit supabase.db.enabled so backup validation holds", () => {
368
+ const selfHosted = matrix.find((c) => c.name === "aws-backup-enabled");
369
+ assert.ok(selfHosted);
370
+ const values = buildHelmValues(selfHosted.config);
371
+ assert.equal(values.backup.enabled, true);
372
+ assert.equal(values.supabase.db.enabled, true);
373
+ });
374
+ test("external Postgres disables backups even with stale backup config", () => {
375
+ const config = cloneFixture("aws-external-postgres");
376
+ config.backup = {
377
+ enabled: true,
378
+ schedule: "0 2 * * *",
379
+ retentionDays: 14,
380
+ };
381
+ const values = buildHelmValues(config);
382
+ assert.equal(values.backup.enabled, false);
383
+ assert.equal(values.backup.schedule, "0 2 * * *");
384
+ assert.equal(values.backup.retentionDays, 14);
385
+ });
386
+ test("storage wizard derives provider from selected cloud", () => {
387
+ assert.equal(storageProviderForCloud("aws"), "s3");
388
+ assert.equal(storageProviderForCloud("azure"), "azure-blob");
389
+ assert.equal(storageProviderForCloud("gcp"), "gcs");
390
+ assert.equal(storageProviderForCloud(null), "s3");
391
+ assert.equal(storageRegionForCloud("aws", "us-east-1", "azure-blob", "eastus"), "us-east-1");
392
+ assert.equal(storageRegionForCloud("azure", "eastus", "azure-blob", "eastus2"), "eastus2");
393
+ assert.equal(storageRegionForCloud("gcp", "us-central1-a", undefined, "eastus"), "us-central1");
394
+ });
395
+ test("non-semver product versions are omitted from global.version", () => {
396
+ const latest = matrix.find((c) => c.name === "aws-version-latest");
397
+ assert.ok(latest);
398
+ const values = buildHelmValues(latest.config);
399
+ assert.equal(values.global.version, undefined);
400
+ });
401
+ test("semver product versions are emitted to global.version", () => {
402
+ const base = matrix.find((c) => c.name === "aws-self-hosted-minimal");
403
+ const values = buildHelmValues(base.config);
404
+ assert.equal(values.global.version, "1.8.17");
405
+ });
406
+ test("validateRemoteWriteConfig enforces per-destination requirements", () => {
407
+ const azureNoClientId = {
408
+ destination: "azure-monitor",
409
+ url: "https://example.monitor.azure.com/api/v1/write",
410
+ authType: "managed-identity",
411
+ };
412
+ assert.ok(validateRemoteWriteConfig(azureNoClientId).length > 0);
413
+ const azureUndefinedAuth = {
414
+ destination: "azure-monitor",
415
+ url: "https://example.monitor.azure.com/api/v1/write",
416
+ };
417
+ assert.ok(validateRemoteWriteConfig(azureUndefinedAuth).length > 0, "undefined Azure authType should be treated as managed identity and require a client ID");
418
+ const ampNoRegion = {
419
+ destination: "aws-amp",
420
+ url: "https://aps.example.com/api/v1/remote_write",
421
+ };
422
+ assert.ok(validateRemoteWriteConfig(ampNoRegion).length > 0);
423
+ const validAzure = {
424
+ destination: "azure-monitor",
425
+ url: "https://example.eastus.metrics.ingest.monitor.azure.com/dataCollectionRules/dcr-1/streams/Microsoft-PrometheusMetrics/api/v1/write?api-version=2023-04-24",
426
+ authType: "managed-identity",
427
+ clientId: "00000000-0000-0000-0000-000000000000",
428
+ };
429
+ assert.deepEqual(validateRemoteWriteConfig(validAzure), []);
430
+ // A bare DCE host (missing the dataCollectionRules/streams path) is rejected.
431
+ const azureBareHost = {
432
+ destination: "azure-monitor",
433
+ url: "https://example.eastus-1.ingest.monitor.azure.com",
434
+ authType: "workload-identity",
435
+ clientId: "00000000-0000-0000-0000-000000000000",
436
+ tenantId: "00000000-0000-0000-0000-000000000000",
437
+ };
438
+ assert.ok(validateRemoteWriteConfig(azureBareHost).some((e) => e.includes("full DCE metrics-ingestion path")));
439
+ });
440
+ test("DeploymentConfigSchema rejects incomplete Azure Monitor remote write", () => {
441
+ const base = matrix.find((c) => c.name === "azure-remote-write-managed");
442
+ const broken = {
443
+ ...base.config,
444
+ features: {
445
+ ...base.config.features,
446
+ monitoring: {
447
+ enabled: true,
448
+ destination: "azure-monitor",
449
+ remoteWrite: {
450
+ destination: "azure-monitor",
451
+ url: "https://example.monitor.azure.com/api/v1/write",
452
+ authType: "managed-identity",
453
+ // clientId intentionally omitted
454
+ },
455
+ },
456
+ },
457
+ };
458
+ const result = DeploymentConfigSchema.safeParse(broken);
459
+ assert.equal(result.success, false);
460
+ });
461
+ test("buildHelmValues throws on a hand-broken remote write config", () => {
462
+ const base = matrix.find((c) => c.name === "azure-remote-write-managed");
463
+ // Bypass Zod to simulate a hand-edited values/config reaching generation.
464
+ const broken = JSON.parse(JSON.stringify(base.config));
465
+ delete broken.features.monitoring.remoteWrite.clientId;
466
+ assert.throws(() => buildHelmValues(broken));
467
+ });
468
+ test("Azure Monitor workload identity maps to azureAd.sdk (not workloadIdentity)", () => {
469
+ const base = matrix.find((c) => c.name === "azure-remote-write-workload");
470
+ const values = buildHelmValues(base.config);
471
+ const rw = values["kube-prometheus-stack"]?.prometheus?.prometheusSpec
472
+ ?.remoteWrite?.[0];
473
+ assert.ok(rw, "expected a remoteWrite entry");
474
+ const azureAd = rw.azureAd;
475
+ // The prometheus-operator schema only accepts managedIdentity/oauth/sdk.
476
+ assert.equal(azureAd?.workloadIdentity, undefined);
477
+ assert.equal(azureAd?.sdk?.tenantId, "22222222-2222-2222-2222-222222222222");
478
+ });
479
+ test("remote write URL is stripped of stray control characters", () => {
480
+ const base = matrix.find((c) => c.name === "azure-remote-write-workload");
481
+ const dirty = JSON.parse(JSON.stringify(base.config));
482
+ // Simulate a CRLF-pasted DCE URL reaching generation.
483
+ dirty.features.monitoring.remoteWrite.url =
484
+ base.config.features.monitoring.remoteWrite.url + "\r";
485
+ const values = buildHelmValues(dirty);
486
+ const url = values["kube-prometheus-stack"]?.prometheus?.prometheusSpec
487
+ ?.remoteWrite?.[0]?.url;
488
+ assert.ok(url && !/[\r\n]/.test(url), "expected no carriage returns in url");
489
+ });
490
+ function vectorKafkaSasl(config) {
491
+ const values = buildHelmValues(config);
492
+ return values.vector?.customConfig?.sources?.kafka?.sasl;
493
+ }
494
+ test("vector kafka SASL never emits an empty-default credential (would render as YAML null)", () => {
495
+ // Helm's toYaml drops the quotes around "${VAR:-}", so an empty default
496
+ // interpolates to a bare value that YAML parses as null, which Vector rejects
497
+ // at config load ("invalid type: unit value, expected any valid TOML value").
498
+ for (const { name, config } of matrix) {
499
+ const sasl = vectorKafkaSasl(config);
500
+ assert.ok(sasl, `${name}: expected a vector kafka sasl block`);
501
+ for (const key of ["username", "password"]) {
502
+ const value = sasl[key];
503
+ assert.ok(value === undefined ||
504
+ (typeof value === "string" && !value.includes(":-")), `${name}: vector kafka sasl.${key}=${JSON.stringify(value)} would render as YAML null`);
505
+ }
506
+ }
507
+ });
508
+ test("vector kafka SASL omits creds for in-cluster/bridge Kafka and sets them for direct SASL", () => {
509
+ const inCluster = vectorKafkaSasl(matrix.find((c) => c.name === "aws-self-hosted-minimal").config);
510
+ assert.equal(inCluster?.username, undefined);
511
+ assert.equal(inCluster?.password, undefined);
512
+ const mskBridge = vectorKafkaSasl(matrix.find((c) => c.name === "aws-external-kafka-msk").config);
513
+ assert.equal(mskBridge?.username, undefined);
514
+ assert.equal(mskBridge?.password, undefined);
515
+ const directSasl = vectorKafkaSasl(matrix.find((c) => c.name === "gcp-external-kafka").config);
516
+ assert.equal(directSasl?.username, "${KAFKA_SASL_USERNAME}");
517
+ assert.equal(directSasl?.password, "${KAFKA_SASL_PASSWORD}");
518
+ });
519
+ function vectorSinks(config) {
520
+ const values = buildHelmValues(config);
521
+ return values.vector?.customConfig?.sinks ?? {};
522
+ }
523
+ test("decision_logs sink writes gzipped NDJSON (never parquet) for every cloud", () => {
524
+ // Vector's azure_blob/gcs sinks have no parquet encoder and `parquet` is not a
525
+ // valid encoding.codec; ClickHouse reads these blobs as JSONEachRow.
526
+ for (const name of [
527
+ "aws-self-hosted-minimal", // s3
528
+ "gcp-self-hosted", // gcs
529
+ "azure-workload-identity", // azure_blob
530
+ ]) {
531
+ const sink = vectorSinks(matrix.find((c) => c.name === name).config)
532
+ .decision_logs;
533
+ assert.ok(sink, `${name}: expected a decision_logs sink`);
534
+ const encoding = sink.encoding;
535
+ const framing = sink.framing;
536
+ assert.equal(encoding?.codec, "json", `${name}: encoding.codec`);
537
+ assert.equal(framing?.method, "newline_delimited", `${name}: framing.method`);
538
+ assert.equal(sink.compression, "gzip", `${name}: compression`);
539
+ // azure_blob has no filename_extension field (always writes .log/.log.gz);
540
+ // aws_s3 and gcs support it and we set ndjson.
541
+ if (sink.type === "azure_blob") {
542
+ assert.equal(sink.filename_extension, undefined, `${name}: azure_blob must not set filename_extension`);
543
+ }
544
+ else {
545
+ assert.equal(sink.filename_extension, "ndjson", `${name}: filename_extension`);
546
+ }
547
+ }
548
+ });
549
+ test("no vector sink uses the unsupported parquet codec or extension", () => {
550
+ for (const { name, config } of matrix) {
551
+ for (const [key, sink] of Object.entries(vectorSinks(config))) {
552
+ const encoding = sink.encoding;
553
+ assert.notEqual(encoding?.codec, "parquet", `${name}: sink ${key} uses unsupported codec parquet`);
554
+ assert.notEqual(sink.filename_extension, "parquet", `${name}: sink ${key} uses parquet filename_extension`);
555
+ }
556
+ }
557
+ });
558
+ test("Grafana dashboard references only classified metric families", (t) => {
559
+ const candidates = [
560
+ process.env.RULEBRICKS_CHART_DIR,
561
+ path.resolve(process.cwd(), "../private/helm"),
562
+ path.resolve(process.cwd(), "../helm"),
563
+ ].filter(Boolean);
564
+ const chartDir = candidates.find((candidate) => fs.existsSync(path.join(candidate, "dashboards", "rulebricks-overview.json")));
565
+ if (!chartDir) {
566
+ t.skip("Helm chart dashboard not available in this checkout");
567
+ return;
568
+ }
569
+ const dashboardPath = path.join(chartDir, "dashboards", "rulebricks-overview.json");
570
+ const dashboard = JSON.parse(fs.readFileSync(dashboardPath, "utf8"));
571
+ const expressions = (dashboard.panels ?? [])
572
+ .flatMap((panel) => panel.targets ?? [])
573
+ .map((target) => target.expr)
574
+ .filter((expr) => Boolean(expr));
575
+ const knownFamilies = [
576
+ // Rulebricks-owned metrics from app/HPS/worker code.
577
+ /^rulebricks_app_(http_requests_total|http_request_duration_seconds_(bucket|sum|count)|http_rejections_total|frontend_errors_total|redis_operations_total|redis_operation_duration_seconds_(bucket|sum|count)|nodejs_.*)$/,
578
+ /^rulebricks_hps_(http_requests_total|http_request_duration_seconds_(bucket|sum|count)|rejections_total|kafka_request_duration_seconds_(bucket|sum|count)|kafka_errors_total|bulk_items_total|decision_log_failures_total|decision_logs_total|decision_log_bytes_total|chunks_per_request_(bucket|sum|count)|chunk_failures_total|chunk_processing_ms_(bucket|sum|count)|chunk_cost_ms_per_item|chunk_cost_ms_per_byte|cache_items|cache_max_entries|cache_requests_total|redis_cache_operations_total|redis_cache_operation_duration_seconds_(bucket|sum|count)|nodejs_.*)$/,
579
+ /^rulebricks_worker_(messages_total|processing_duration_seconds_(bucket|sum|count)|redis_cache_operations_total|redis_cache_operation_duration_seconds_(bucket|sum|count)|nodejs_.*)$/,
580
+ // kube-prometheus-stack / cAdvisor / node-exporter families.
581
+ /^container_(cpu_usage_seconds_total|memory_working_set_bytes|oom_events_total|cpu_cfs_throttled_periods_total|cpu_cfs_periods_total)$/,
582
+ /^kube_(pod_container_status_restarts_total|pod_status_phase|pod_status_unschedulable|deployment_.*|horizontalpodautoscaler_.*)$/,
583
+ /^kubelet_volume_stats_(used_bytes|capacity_bytes)$/,
584
+ /^node_(cpu_seconds_total|memory_MemAvailable_bytes|memory_MemTotal_bytes|filesystem_avail_bytes|filesystem_size_bytes)$/,
585
+ // Optional exporter families.
586
+ /^redis_(commands_processed_total|connected_clients|memory_used_bytes|memory_max_bytes|keyspace_hits_total|keyspace_misses_total|evicted_keys_total)$/,
587
+ /^kafka_(consumergroup_lag|log_log_size|network_requestchannel_requestqueuesize_value|network_requestchannel_responsequeuesize_value|server_brokertopicmetrics_total_failedproducerequestspersec_count|server_brokertopicmetrics_total_failedfetchrequestspersec_count)$/,
588
+ /^traefik_(service_requests_total|service_request_duration_seconds_bucket)$/,
589
+ /^ClickHouse(Metrics_Query|Metrics_MemoryTracking|ProfileEvents_Query)$/,
590
+ ];
591
+ const metricToken = /\b(?:rulebricks_[a-zA-Z0-9_:]+|container_[a-zA-Z0-9_:]+|kube_[a-zA-Z0-9_:]+|kubelet_[a-zA-Z0-9_:]+|node_[a-zA-Z0-9_:]+|redis_[a-zA-Z0-9_:]+|kafka_[a-zA-Z0-9_:]+|traefik_[a-zA-Z0-9_:]+|ClickHouse[a-zA-Z0-9_:]+)\b/g;
592
+ const metrics = new Set();
593
+ for (const expr of expressions) {
594
+ for (const match of expr.matchAll(metricToken)) {
595
+ metrics.add(match[0]);
596
+ }
597
+ }
598
+ const unknown = [...metrics].filter((metric) => !knownFamilies.some((family) => family.test(metric)));
599
+ assert.deepEqual(unknown.sort(), []);
600
+ });
601
+ test("BYO tracing is disabled by default while ClickStack owns OTLP routing", () => {
602
+ const values = buildHelmValues(matrix.find((c) => c.name === "aws-self-hosted-minimal").config);
603
+ assert.equal(values.global.tracing, undefined);
604
+ assert.equal(values.traefik.tracing.otlp.enabled, true);
605
+ assert.equal(values["vector-agent"].enabled, false);
606
+ });
607
+ test("tracing enabled wires global.tracing, traefik OTLP, and Elastic auth", () => {
608
+ const values = buildHelmValues(matrix.find((c) => c.name === "aws-tracing-elastic").config);
609
+ assert.equal(values.global.tracing.enabled, true);
610
+ assert.equal(values.global.tracing.elastic.endpoint, "https://rb-deployment.apm.us-east-1.aws.elastic-cloud.com:443");
611
+ assert.equal(values.global.tracing.elastic.authMode, "secret-token");
612
+ assert.equal(values.global.tracing.elastic.secretToken, "elastic-apm-secret-token");
613
+ // Default destination is elastic when none is specified.
614
+ assert.equal(values.global.tracing.destination, "elastic");
615
+ // Traefik becomes the root span and points at the in-cluster collector.
616
+ assert.equal(values.traefik.tracing.otlp.enabled, true);
617
+ assert.match(values.traefik.tracing.otlp.http.endpoint, /-otel-collector:4318\/v1\/traces$/);
618
+ });
619
+ test("tracing destination otlp wires a generic OTLP backend with bearer auth", () => {
620
+ const values = buildHelmValues(matrix.find((c) => c.name === "aws-tracing-otlp").config);
621
+ assert.equal(values.global.tracing.enabled, true);
622
+ assert.equal(values.global.tracing.destination, "otlp");
623
+ assert.equal(values.global.tracing.elastic, undefined);
624
+ assert.equal(values.global.tracing.otlp.endpoint, "https://otlp-gateway.example.com/otlp");
625
+ assert.equal(values.global.tracing.otlp.authMode, "bearer");
626
+ assert.equal(values.global.tracing.otlp.token, "otlp-bearer-token");
627
+ // Collector is still the in-cluster receiver; only the export target differs.
628
+ assert.equal(values.traefik.tracing.otlp.enabled, true);
629
+ });
630
+ test("tracing destination azure-monitor wires the Application Insights backend", () => {
631
+ const values = buildHelmValues(matrix.find((c) => c.name === "azure-tracing-azure-monitor").config);
632
+ assert.equal(values.global.tracing.enabled, true);
633
+ assert.equal(values.global.tracing.destination, "azure-monitor");
634
+ assert.equal(values.global.tracing.elastic, undefined);
635
+ assert.match(values.global.tracing.azureMonitor.connectionString, /^InstrumentationKey=/);
636
+ });
637
+ test("appLogs enabled produces a vector-agent with an elasticsearch sink", () => {
638
+ const values = buildHelmValues(matrix.find((c) => c.name === "aws-app-logs-elasticsearch").config);
639
+ const agent = values["vector-agent"];
640
+ assert.equal(agent.enabled, true);
641
+ assert.equal(agent.role, "Agent");
642
+ assertNoBareExistsToleration("vector-agent", agent.tolerations);
643
+ assert.deepEqual(agent.tolerations, [BURST_POOL_TOLERATION]);
644
+ assert.equal(agent.customConfig.sources.kubernetes_logs.type, "kubernetes_logs");
645
+ // The agent must not scrape the Vector pods: the aggregator re-emits decision
646
+ // logs on stdout (ClickHouse-only) and self-scraping the agent would loop.
647
+ assert.match(agent.customConfig.sources.kubernetes_logs.extra_label_selector, /notin \(vector,vector-agent\)/);
648
+ const sink = agent.customConfig.sinks.elasticsearch;
649
+ assert.equal(sink.type, "elasticsearch");
650
+ assert.deepEqual(sink.endpoints, [
651
+ "https://rb-deployment.es.us-east-1.aws.elastic-cloud.com:9243",
652
+ ]);
653
+ assert.equal(sink.auth.strategy, "basic");
654
+ assert.equal(sink.auth.user, "elastic");
655
+ });
656
+ test("operational DaemonSets use explicit safe tolerations", () => {
657
+ const values = buildHelmValues(matrix.find((c) => c.name === "aws-self-hosted-minimal").config);
658
+ const prepullTolerations = values.rulebricks.hps.imagePrepull
659
+ .tolerations;
660
+ assertNoBareExistsToleration("imagePrepull", prepullTolerations);
661
+ assert.deepEqual(prepullTolerations, [BURST_POOL_TOLERATION]);
662
+ });
663
+ test("operational DaemonSet tolerations include ARM and burst pools explicitly", () => {
664
+ const config = cloneFixture("azure-workload-identity");
665
+ const appLogsConfig = cloneFixture("aws-app-logs-elasticsearch");
666
+ config.infrastructure.arm64TolerationRequired = true;
667
+ config.features.logging.appLogs = appLogsConfig.features.logging.appLogs;
668
+ const values = buildHelmValues(config);
669
+ const expectedTolerations = [
670
+ {
671
+ key: "kubernetes.io/arch",
672
+ operator: "Equal",
673
+ value: "arm64",
674
+ effect: "NoSchedule",
675
+ },
676
+ BURST_POOL_TOLERATION,
677
+ ];
678
+ for (const [label, tolerations] of [
679
+ ["imagePrepull", values.rulebricks.hps.imagePrepull.tolerations],
680
+ ["clickstack-collector-agent", values.clickstack.collector.agent.tolerations],
681
+ ]) {
682
+ assertNoBareExistsToleration(label, tolerations);
683
+ for (const expected of expectedTolerations) {
684
+ assertIncludesToleration(label, tolerations, expected);
685
+ }
686
+ }
687
+ });
688
+ test("worker metrics path/port are emitted for the worker ServiceMonitor", () => {
689
+ const values = buildHelmValues(matrix.find((c) => c.name === "aws-self-hosted-minimal").config);
690
+ assert.equal(values.rulebricks.metrics.worker.path, "/metrics");
691
+ assert.equal(values.rulebricks.metrics.worker.port, 3000);
692
+ });
693
+ test("invariant rejects enabled tracing without an Elastic endpoint", () => {
694
+ const values = buildHelmValues(matrix.find((c) => c.name === "aws-tracing-elastic").config);
695
+ values.global.tracing.elastic.endpoint = "";
696
+ const errors = validateValuesInvariants(values);
697
+ assert.ok(errors.some((e) => e.includes("tracing.elastic.endpoint")), `expected a tracing endpoint invariant error, got: ${errors.join("; ")}`);
698
+ });
699
+ test("external Postgres maps to supabase.externalDatabase with bootstrap creds", () => {
700
+ const config = cloneFixture("aws-external-postgres");
701
+ const values = buildHelmValues(config);
702
+ const sb = values.supabase;
703
+ assert.equal(sb.enabled, true);
704
+ // Bundled DB off; externalDatabase is the single switch.
705
+ assert.equal(sb.db.enabled, false);
706
+ assert.equal(sb.externalDatabase.enabled, true);
707
+ assert.equal(sb.externalDatabase.host, "db.cluster-xxxx.us-east-1.rds.amazonaws.com");
708
+ assert.equal(sb.externalDatabase.port, 5432);
709
+ // Bootstrap (one-time init) carries inline master creds + app role.
710
+ assert.equal(sb.externalDatabase.bootstrap.enabled, true);
711
+ assert.equal(sb.externalDatabase.bootstrap.masterUsername, "postgres");
712
+ assert.equal(sb.externalDatabase.bootstrap.masterPassword, "master-pw-change-me");
713
+ // The shared service-role password the chart hands every service.
714
+ assert.ok(typeof sb.secret.db.password === "string");
715
+ assert.equal(sb.secret.db.database, "postgres");
716
+ });
717
+ test("external Postgres k8s secret mode keeps compatibility and uses secret refs", () => {
718
+ const config = cloneFixture("aws-external-postgres");
719
+ const values = buildHelmValues(config, { secretMode: "k8s" });
720
+ const sb = values.supabase;
721
+ assert.equal(sb.externalDatabase.enabled, true);
722
+ // Keep inline host/port so older charts still render, while new charts prefer
723
+ // the Secret keys below.
724
+ assert.equal(sb.externalDatabase.host, "db.cluster-xxxx.us-east-1.rds.amazonaws.com");
725
+ assert.equal(sb.externalDatabase.port, 5432);
726
+ assert.equal(sb.externalDatabase.secretRef, `${config.name}-supabase-db`);
727
+ assert.deepEqual(sb.externalDatabase.secretRefKey, {
728
+ host: "host",
729
+ port: "port",
730
+ username: "username",
731
+ password: "password",
732
+ database: "database",
733
+ });
734
+ assert.equal(sb.externalDatabase.bootstrap.secretRef, `${config.name}-supabase-db-bootstrap`);
735
+ assert.equal(sb.externalDatabase.bootstrap.masterPassword, undefined);
736
+ assert.equal(sb.secret.db.secretRef, `${config.name}-supabase-db`);
737
+ assert.deepEqual(sb.secret.db.secretRefKey, {
738
+ host: "host",
739
+ port: "port",
740
+ username: "username",
741
+ password: "password",
742
+ database: "database",
743
+ });
744
+ });
745
+ test("embedded Postgres still deploys the bundled database", () => {
746
+ const config = cloneFixture("aws-self-hosted-minimal");
747
+ const values = buildHelmValues(config);
748
+ assert.equal(values.supabase.db.enabled, true);
749
+ assert.equal(values.supabase.externalDatabase, undefined);
750
+ });
751
+ import { buildDeploymentSecrets } from "./secrets.js";
752
+ import { deriveRealtimeSecrets } from "./helmValues.js";
753
+ test("k8s secret mode: secretRefs set, zero plaintext secrets in values", () => {
754
+ const config = cloneFixture("aws-self-hosted-minimal");
755
+ config.features.ai = {
756
+ enabled: true,
757
+ openaiApiKey: "sk-test-openai-key-for-secret-mode",
758
+ };
759
+ const dbPw = config.database.supabaseDbPassword;
760
+ const jwt = config.database.supabaseJwtSecret;
761
+ const dashPw = config.database.supabaseDashboardPass;
762
+ const license = config.licenseKey;
763
+ const openai = config.features.ai.openaiApiKey;
764
+ const values = buildHelmValues(config, { secretMode: "k8s" });
765
+ const schemaResult = validateHelmValues(values);
766
+ assert.ok(schemaResult.valid, `k8s secret-mode values should satisfy chart schema:\n${schemaResult.errors.join("\n")}`);
767
+ // secretRef seams point at the CLI-created Secrets
768
+ assert.equal(values.global.secrets.secretRef, `${config.name}-app-secrets`);
769
+ assert.equal(values.supabase.secret.db.secretRef, `${config.name}-supabase-db`);
770
+ assert.equal(values.supabase.secret.jwt.secretRef, `${config.name}-supabase-jwt`);
771
+ assert.equal(values.supabase.secret.dashboard.secretRef, `${config.name}-supabase-dashboard`);
772
+ assert.equal(values.supabase.secret.realtime.secretRef, `${config.name}-supabase-realtime`);
773
+ // inline plaintext stripped
774
+ assert.equal(values.global.supabase.jwtSecret, undefined);
775
+ assert.equal(values.global.ai.openaiApiKey, undefined);
776
+ assert.equal(values.global.licenseKey, undefined);
777
+ // no secret value appears anywhere in the generated values
778
+ const dump = JSON.stringify(values);
779
+ for (const [label, secret] of [
780
+ ["db password", dbPw],
781
+ ["jwt secret", jwt],
782
+ ["dashboard password", dashPw],
783
+ ["license key", license],
784
+ ["openai key", openai],
785
+ ]) {
786
+ assert.ok(!dump.includes(secret), `${label} leaked into k8s-mode values`);
787
+ }
788
+ });
789
+ test("k8s secret mode: SSO + AI configs validate against the chart schema", () => {
790
+ // SSO clientId/clientSecret and the OpenAI key are redacted into the app
791
+ // Secret in k8s mode; the chart schema must accept global.secrets.secretRef
792
+ // in place of the inline values (delivered via envFrom).
793
+ const config = cloneFixture("aws-all-features");
794
+ const values = buildHelmValues(config, { secretMode: "k8s" });
795
+ const result = validateHelmValues(values);
796
+ assert.ok(result.valid, `k8s-mode SSO/AI values should satisfy the chart schema:\n${result.errors.join("\n")}`);
797
+ assert.equal(values.global.sso.clientId, undefined);
798
+ assert.equal(values.global.sso.clientSecret, undefined);
799
+ assert.equal(values.global.ai.openaiApiKey, undefined);
800
+ assert.equal(values.global.secrets.secretRef, `${config.name}-app-secrets`);
801
+ });
802
+ test("k8s secret mode: managed Supabase config validates against the chart schema", () => {
803
+ // Managed (Supabase Cloud) redacts the access token into the app Secret; the
804
+ // schema must accept secretRef instead of an inline global.supabase.accessToken.
805
+ const config = cloneFixture("aws-supabase-cloud");
806
+ const values = buildHelmValues(config, { secretMode: "k8s" });
807
+ const result = validateHelmValues(values);
808
+ assert.ok(result.valid, `k8s-mode managed-Supabase values should satisfy the chart schema:\n${result.errors.join("\n")}`);
809
+ assert.equal(values.global.supabase.accessToken, undefined);
810
+ assert.ok(values.global.supabase.url);
811
+ assert.equal(values.global.secrets.secretRef, `${config.name}-app-secrets`);
812
+ });
813
+ test("inline secret mode keeps secrets in values (dev path)", () => {
814
+ const config = cloneFixture("aws-self-hosted-minimal");
815
+ const values = buildHelmValues(config, { secretMode: "inline" });
816
+ assert.equal(values.supabase.secret.db.password, config.database.supabaseDbPassword);
817
+ // realtime keys derived (no shipped default) and present inline
818
+ assert.ok(values.supabase.secret.realtime.secretKeyBase);
819
+ assert.equal(values.supabase.secret.realtime.dbEncKey.length, 16);
820
+ // no consolidated app secretRef in inline mode
821
+ assert.equal(values.global.secrets?.secretRef ?? "", "");
822
+ });
823
+ test("buildDeploymentSecrets: app + supabase secrets with JWT-derived keys", () => {
824
+ const config = cloneFixture("aws-self-hosted-minimal");
825
+ const jwt = config.database.supabaseJwtSecret;
826
+ const byName = Object.fromEntries(buildDeploymentSecrets(config).map((s) => [s.name, s.stringData]));
827
+ const base = config.name;
828
+ assert.equal(byName[`${base}-app-secrets`].LICENSE_KEY, config.licenseKey);
829
+ assert.equal(byName[`${base}-supabase-db`].password, config.database.supabaseDbPassword);
830
+ assert.equal(byName[`${base}-supabase-jwt`].anonKey, signSupabaseJwt("anon", jwt));
831
+ assert.equal(byName[`${base}-supabase-jwt`].serviceKey, signSupabaseJwt("service_role", jwt));
832
+ // realtime keys match the chart-side derivation + 16-byte DB_ENC_KEY
833
+ const rt = deriveRealtimeSecrets(jwt);
834
+ assert.equal(byName[`${base}-supabase-realtime`].SECRET_KEY_BASE, rt.secretKeyBase);
835
+ assert.equal(byName[`${base}-supabase-realtime`].DB_ENC_KEY.length, 16);
836
+ });
837
+ test("buildDeploymentSecrets includes external Postgres host/port and bootstrap creds", () => {
838
+ const config = cloneFixture("aws-external-postgres");
839
+ const byName = Object.fromEntries(buildDeploymentSecrets(config).map((s) => [s.name, s.stringData]));
840
+ const base = config.name;
841
+ assert.deepEqual(byName[`${base}-supabase-db`], {
842
+ username: "postgres",
843
+ password: config.database.supabaseDbPassword,
844
+ database: "postgres",
845
+ host: "db.cluster-xxxx.us-east-1.rds.amazonaws.com",
846
+ port: "5432",
847
+ });
848
+ assert.deepEqual(byName[`${base}-supabase-db-bootstrap`], {
849
+ "master-username": "postgres",
850
+ "master-password": "master-pw-change-me",
851
+ "service-password": config.database.supabaseDbPassword,
852
+ });
853
+ });
854
+ // ===========================================================================
855
+ // Image registry / digest pinning (docker.io/rulebricks/* + global.imageRegistry)
856
+ // ===========================================================================
857
+ test("default image refs use the rulebricks/* split shape with no legacy hosts", () => {
858
+ const config = cloneFixture("aws-self-hosted-minimal");
859
+ const values = buildHelmValues(config);
860
+ // app/hps use the split { registry, repository } shape (host never in repo).
861
+ assert.deepEqual(values.rulebricks.app.image.registry, "docker.io");
862
+ assert.equal(values.rulebricks.app.image.repository, "rulebricks/app");
863
+ assert.equal(values.rulebricks.hps.image.registry, "docker.io");
864
+ assert.equal(values.rulebricks.hps.image.repository, "rulebricks/hps");
865
+ // clickstack images keep the split shape too.
866
+ assert.equal(values.clickstack.hyperdx.image.registry, "docker.io");
867
+ assert.equal(values.clickstack.hyperdx.image.repository, "rulebricks/hyperdx");
868
+ assert.equal(values.clickstack.collector.image.registry, "docker.io");
869
+ assert.equal(values.clickstack.collector.image.repository, "rulebricks/clickstack-otel-collector");
870
+ assert.equal(values.clickstack.ferretdb.image.registry, "docker.io");
871
+ assert.equal(values.clickstack.ferretdb.image.repository, "rulebricks/ferretdb");
872
+ assert.equal(values.clickstack.ferretdb.postgresImage.repository, "rulebricks/postgres-documentdb");
873
+ // Whole-output guard: no dhi.io and no index.docker.io anywhere.
874
+ const dump = JSON.stringify(values);
875
+ assert.ok(!dump.includes("dhi.io"), "dhi.io must not appear in output");
876
+ assert.ok(!dump.includes("index.docker.io"), "index.docker.io must not appear in output");
877
+ assert.ok(!dump.includes("grepplabs"), "grepplabs must not appear in output");
878
+ });
879
+ test("global.imageDigests is always present and threaded into global", () => {
880
+ const config = cloneFixture("aws-self-hosted-minimal");
881
+ const values = buildHelmValues(config);
882
+ assert.ok(values.global.imageDigests !== undefined, "global.imageDigests must be present");
883
+ assert.equal(typeof values.global.imageDigests, "object");
884
+ // No imageRegistry override emitted when config.imageRegistry is unset.
885
+ assert.equal(values.global.imageRegistry, undefined);
886
+ });
887
+ test("imageRegistry override rewrites every image host to the custom registry", () => {
888
+ const config = cloneFixture("aws-self-hosted-minimal");
889
+ config.imageRegistry = "myacr.azurecr.io";
890
+ // Enable external-dns so its image block is emitted and can be asserted.
891
+ config.dns = { provider: "route53", autoManage: true };
892
+ const values = buildHelmValues(config);
893
+ const reg = "myacr.azurecr.io";
894
+ // global passthrough
895
+ assert.equal(values.global.imageRegistry, reg);
896
+ // app / hps / clickstack / supabase split shapes
897
+ assert.equal(values.rulebricks.app.image.registry, reg);
898
+ assert.equal(values.rulebricks.app.image.repository, "rulebricks/app");
899
+ assert.equal(values.rulebricks.hps.image.registry, reg);
900
+ assert.equal(values.clickstack.hyperdx.image.registry, reg);
901
+ assert.equal(values.clickstack.collector.image.registry, reg);
902
+ assert.equal(values.clickstack.ferretdb.image.registry, reg);
903
+ assert.equal(values.clickstack.ferretdb.postgresImage.registry, reg);
904
+ assert.equal(values.supabase.db.image.registry, reg);
905
+ // kube-prometheus-stack sub-images
906
+ const kps = values["kube-prometheus-stack"];
907
+ assert.equal(kps.prometheus.prometheusSpec.image.registry, reg);
908
+ assert.equal(kps.prometheus.prometheusSpec.image.repository, "rulebricks/prometheus");
909
+ assert.equal(kps.alertmanager.alertmanagerSpec.image.registry, reg);
910
+ assert.equal(kps.alertmanager.alertmanagerSpec.image.repository, "rulebricks/alertmanager");
911
+ assert.equal(kps.prometheusOperator.image.registry, reg);
912
+ assert.equal(kps.prometheusOperator.image.repository, "rulebricks/prometheus-operator");
913
+ assert.equal(kps.prometheusOperator.prometheusConfigReloader.image.registry, reg);
914
+ assert.equal(kps.prometheusOperator.prometheusConfigReloader.image.repository, "rulebricks/prometheus-config-reloader");
915
+ assert.equal(kps.prometheusOperator.admissionWebhooks.patch.image.registry, reg);
916
+ assert.equal(kps.prometheusOperator.admissionWebhooks.patch.image.repository, "rulebricks/kube-webhook-certgen");
917
+ assert.equal(kps.grafana.image.registry, reg);
918
+ assert.equal(kps.grafana.image.repository, "rulebricks/grafana");
919
+ assert.equal(kps.grafana.sidecar.image.registry, reg);
920
+ assert.equal(kps.grafana.sidecar.image.repository, "rulebricks/k8s-sidecar");
921
+ assert.equal(kps["kube-state-metrics"].image.registry, reg);
922
+ assert.equal(kps["kube-state-metrics"].image.repository, "rulebricks/kube-state-metrics");
923
+ assert.equal(kps["prometheus-node-exporter"].image.registry, reg);
924
+ assert.equal(kps["prometheus-node-exporter"].image.repository, "rulebricks/node-exporter");
925
+ // cert-manager (registry + repository per component)
926
+ const cm = values["cert-manager"];
927
+ assert.equal(cm.image.registry, reg);
928
+ assert.equal(cm.image.repository, "rulebricks/cert-manager-controller");
929
+ assert.equal(cm.webhook.image.registry, reg);
930
+ assert.equal(cm.webhook.image.repository, "rulebricks/cert-manager-webhook");
931
+ assert.equal(cm.cainjector.image.registry, reg);
932
+ assert.equal(cm.cainjector.image.repository, "rulebricks/cert-manager-cainjector");
933
+ assert.equal(cm.startupapicheck.image.registry, reg);
934
+ assert.equal(cm.startupapicheck.image.repository, "rulebricks/cert-manager-startupapicheck");
935
+ assert.equal(cm.acmesolver.image.registry, reg);
936
+ assert.equal(cm.acmesolver.image.repository, "rulebricks/cert-manager-acmesolver");
937
+ // traefik (registry + repository)
938
+ assert.equal(values.traefik.image.registry, reg);
939
+ assert.equal(values.traefik.image.repository, "rulebricks/traefik");
940
+ // keda (global.image.registry host + per-comp repositories)
941
+ assert.equal(values.keda.global.image.registry, reg);
942
+ assert.equal(values.keda.image.keda.registry, reg);
943
+ assert.equal(values.keda.image.keda.repository, "rulebricks/keda");
944
+ assert.equal(values.keda.image.metricsApiServer.repository, "rulebricks/keda-metrics-apiserver");
945
+ assert.equal(values.keda.image.webhooks.repository, "rulebricks/keda-admission-webhooks");
946
+ // vector + external-dns (full-path repository incl. host)
947
+ assert.equal(values.vector.image.repository, `${reg}/rulebricks/vector`);
948
+ assert.equal(values["external-dns"].image.repository, `${reg}/rulebricks/external-dns`);
949
+ // Every image host is the custom registry: no docker.io image refs remain.
950
+ const dump = JSON.stringify(values);
951
+ assert.ok(!dump.includes("dhi.io"));
952
+ assert.ok(!dump.includes("index.docker.io"));
953
+ assert.ok(!dump.includes('"docker.io/rulebricks'), "no full docker.io/rulebricks path refs remain when overridden");
954
+ });
955
+ test("per-chart imagePullSecrets are still emitted for private rulebricks/*", () => {
956
+ const config = cloneFixture("aws-self-hosted-minimal");
957
+ const values = buildHelmValues(config);
958
+ const expected = [{ name: `${getReleaseName(config.name)}-regcred` }];
959
+ assert.deepEqual(values.global.imagePullSecrets, expected);
960
+ assert.deepEqual(values["strimzi-kafka-operator"].image.imagePullSecrets, expected);
961
+ assert.deepEqual(values.traefik.deployment.imagePullSecrets, expected);
962
+ assert.deepEqual(values.keda.imagePullSecrets, expected);
963
+ assert.deepEqual(values.vector.image.pullSecrets, expected);
964
+ // global has no legacy dhi.io reference.
965
+ assert.ok(!JSON.stringify(values.global).includes("dhi.io"));
966
+ });