@rulebricks/cli 2.1.7 → 2.3.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.
- package/README.md +51 -16
- package/cluster-setup/aws/README.md +96 -47
- package/cluster-setup/aws/check-aws-access.sh +216 -52
- package/cluster-setup/aws/parameters.json +13 -0
- package/cluster-setup/aws/rulebricks-cluster.cfn.yaml +355 -0
- package/cluster-setup/azure/README.md +103 -55
- package/cluster-setup/azure/check-aks-prereqs.sh +236 -56
- package/cluster-setup/azure/parameters.json +30 -0
- package/cluster-setup/azure/rulebricks-cluster.bicep +546 -0
- package/cluster-setup/gcp/README.md +51 -34
- package/cluster-setup/gcp/check-gke-prereqs.sh +222 -60
- package/dist/commands/backup.d.ts +5 -0
- package/dist/commands/backup.js +104 -0
- package/dist/commands/deploy.d.ts +3 -1
- package/dist/commands/deploy.js +226 -326
- package/dist/commands/destroy.d.ts +1 -1
- package/dist/commands/destroy.js +73 -123
- package/dist/commands/init.d.ts +5 -1
- package/dist/commands/init.js +78 -54
- package/dist/commands/list.d.ts +1 -0
- package/dist/commands/list.js +74 -0
- package/dist/commands/open.d.ts +1 -1
- package/dist/commands/open.js +4 -12
- package/dist/commands/redeploy.d.ts +6 -0
- package/dist/commands/redeploy.js +310 -0
- package/dist/commands/restore.d.ts +5 -0
- package/dist/commands/restore.js +338 -0
- package/dist/commands/status.js +62 -49
- package/dist/commands/upgrade.js +74 -51
- package/dist/components/DNSWaitScreen.d.ts +5 -1
- package/dist/components/DNSWaitScreen.js +47 -41
- package/dist/components/Wizard/WizardContext.d.ts +157 -36
- package/dist/components/Wizard/WizardContext.js +872 -160
- package/dist/components/Wizard/steps/CloudProviderStep.js +192 -107
- package/dist/components/Wizard/steps/DomainStep.js +5 -24
- package/dist/components/Wizard/steps/ExternalServicesStep.d.ts +6 -0
- package/dist/components/Wizard/steps/ExternalServicesStep.js +645 -0
- package/dist/components/Wizard/steps/FeatureConfigStep.d.ts +2 -1
- package/dist/components/Wizard/steps/FeatureConfigStep.js +739 -425
- package/dist/components/Wizard/steps/FeaturesStep.js +31 -35
- package/dist/components/Wizard/steps/ObservabilityStep.d.ts +6 -0
- package/dist/components/Wizard/steps/ObservabilityStep.js +137 -0
- package/dist/components/Wizard/steps/ReviewStep.d.ts +2 -1
- package/dist/components/Wizard/steps/ReviewStep.js +56 -12
- package/dist/components/Wizard/steps/StorageStep.d.ts +9 -0
- package/dist/components/Wizard/steps/StorageStep.js +592 -0
- package/dist/components/Wizard/steps/SupabaseCredentialsStep.js +20 -21
- package/dist/components/Wizard/steps/VersionStep.js +45 -23
- package/dist/components/Wizard/steps/index.d.ts +3 -3
- package/dist/components/Wizard/steps/index.js +3 -3
- package/dist/components/common/CommandApproval.d.ts +12 -0
- package/dist/components/common/CommandApproval.js +91 -0
- package/dist/components/common/DeploymentPicker.d.ts +14 -0
- package/dist/components/common/DeploymentPicker.js +16 -0
- package/dist/components/common/index.d.ts +2 -0
- package/dist/components/common/index.js +2 -0
- package/dist/index.js +94 -62
- package/dist/lib/cloudCli.d.ts +134 -63
- package/dist/lib/cloudCli.js +512 -220
- package/dist/lib/clusterSetupDefaults.d.ts +30 -0
- package/dist/lib/clusterSetupDefaults.js +64 -0
- package/dist/lib/commandApproval.d.ts +26 -0
- package/dist/lib/commandApproval.js +114 -0
- package/dist/lib/config.d.ts +12 -10
- package/dist/lib/config.js +91 -33
- package/dist/lib/configFixtures.d.ts +5 -0
- package/dist/lib/configFixtures.js +513 -0
- package/dist/lib/deploymentHealth.d.ts +32 -0
- package/dist/lib/deploymentHealth.js +157 -0
- package/dist/lib/dns.d.ts +1 -1
- package/dist/lib/dns.js +19 -1
- package/dist/lib/dns.test.d.ts +1 -0
- package/dist/lib/dns.test.js +27 -0
- package/dist/lib/dockerHub.d.ts +12 -1
- package/dist/lib/dockerHub.js +18 -8
- package/dist/lib/helm.d.ts +4 -0
- package/dist/lib/helm.js +16 -0
- package/dist/lib/helmValues.d.ts +25 -0
- package/dist/lib/helmValues.js +1841 -289
- package/dist/lib/helmValues.test.d.ts +1 -0
- package/dist/lib/helmValues.test.js +1012 -0
- package/dist/lib/htpasswd.d.ts +1 -0
- package/dist/lib/htpasswd.js +15 -0
- package/dist/lib/kubernetes.d.ts +124 -17
- package/dist/lib/kubernetes.js +576 -145
- package/dist/lib/secrets.d.ts +23 -0
- package/dist/lib/secrets.js +158 -0
- package/dist/lib/validateValues.d.ts +31 -0
- package/dist/lib/validateValues.js +253 -0
- package/dist/lib/versions.d.ts +82 -11
- package/dist/lib/versions.js +131 -31
- package/dist/lib/versions.test.d.ts +1 -0
- package/dist/lib/versions.test.js +81 -0
- package/dist/lib/wizardSteps.d.ts +14 -0
- package/dist/lib/wizardSteps.js +23 -0
- package/dist/lib/workloadIdentity.d.ts +26 -0
- package/dist/lib/workloadIdentity.js +323 -0
- package/dist/lib/workloadIdentity.test.d.ts +1 -0
- package/dist/lib/workloadIdentity.test.js +57 -0
- package/dist/types/index.d.ts +1860 -164
- package/dist/types/index.js +518 -295
- package/package.json +9 -4
- package/schema/values.schema.json +1934 -0
- package/cluster-setup/aws/cluster.yaml +0 -33
- package/cluster-setup/azure/main.bicep +0 -282
- package/cluster-setup/azure/main.parameters.json +0 -21
- package/dist/components/Wizard/steps/CredentialsStep.d.ts +0 -6
- package/dist/components/Wizard/steps/CredentialsStep.js +0 -22
- package/dist/components/Wizard/steps/DeploymentModeStep.d.ts +0 -5
- package/dist/components/Wizard/steps/DeploymentModeStep.js +0 -26
- package/dist/components/Wizard/steps/TierStep.d.ts +0 -6
- package/dist/components/Wizard/steps/TierStep.js +0 -29
- package/dist/lib/terraform.d.ts +0 -66
- package/dist/lib/terraform.js +0 -754
- package/terraform/aws/main.tf +0 -355
- package/terraform/azure/main.tf +0 -371
- package/terraform/gcp/main.tf +0 -407
|
@@ -0,0 +1,1012 @@
|
|
|
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, `${getReleaseName(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, `${getReleaseName(config.name)}-supabase-db-bootstrap`);
|
|
735
|
+
assert.equal(sb.externalDatabase.bootstrap.masterPassword, undefined);
|
|
736
|
+
assert.equal(sb.secret.db.secretRef, `${getReleaseName(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, app secrets kept out of values (license stays inline for the pull secret)", () => {
|
|
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, `${getReleaseName(config.name)}-app-secrets`);
|
|
769
|
+
assert.equal(values.supabase.secret.db.secretRef, `${getReleaseName(config.name)}-supabase-db`);
|
|
770
|
+
assert.equal(values.supabase.secret.jwt.secretRef, `${getReleaseName(config.name)}-supabase-jwt`);
|
|
771
|
+
assert.equal(values.supabase.secret.dashboard.secretRef, `${getReleaseName(config.name)}-supabase-dashboard`);
|
|
772
|
+
assert.equal(values.supabase.secret.realtime.secretRef, `${getReleaseName(config.name)}-supabase-realtime`);
|
|
773
|
+
// Genuinely-sensitive app secrets are stripped (delivered via secretRef).
|
|
774
|
+
assert.equal(values.global.supabase.jwtSecret, undefined);
|
|
775
|
+
assert.equal(values.global.ai.openaiApiKey, undefined);
|
|
776
|
+
// These two MUST stay inline: the standard (unmodified) chart consumes them at
|
|
777
|
+
// Helm TEMPLATE time with no secretRef seam.
|
|
778
|
+
// - licenseKey -> registry-secret.yaml builds the <release>-regcred pull
|
|
779
|
+
// secret. Stripping it => dckr_pat_evaluation => 401 on every private image.
|
|
780
|
+
// - anonKey (public) -> app-configmap.yaml NEXT_PUBLIC_SUPABASE_PUBLIC_KEY.
|
|
781
|
+
assert.equal(values.global.licenseKey, license);
|
|
782
|
+
assert.ok(values.global.supabase.anonKey, "public anonKey must remain inline in k8s mode for the app ConfigMap");
|
|
783
|
+
// The genuinely-sensitive app secrets never appear in the generated values.
|
|
784
|
+
const dump = JSON.stringify(values);
|
|
785
|
+
for (const [label, secret] of [
|
|
786
|
+
["db password", dbPw],
|
|
787
|
+
["jwt secret", jwt],
|
|
788
|
+
["dashboard password", dashPw],
|
|
789
|
+
["openai key", openai],
|
|
790
|
+
]) {
|
|
791
|
+
assert.ok(!dump.includes(secret), `${label} leaked into k8s-mode values`);
|
|
792
|
+
}
|
|
793
|
+
});
|
|
794
|
+
test("k8s secret mode: SSO + AI configs validate against the chart schema", () => {
|
|
795
|
+
// SSO clientId/clientSecret and the OpenAI key are redacted into the app
|
|
796
|
+
// Secret in k8s mode; the chart schema must accept global.secrets.secretRef
|
|
797
|
+
// in place of the inline values (delivered via envFrom).
|
|
798
|
+
const config = cloneFixture("aws-all-features");
|
|
799
|
+
const values = buildHelmValues(config, { secretMode: "k8s" });
|
|
800
|
+
const result = validateHelmValues(values);
|
|
801
|
+
assert.ok(result.valid, `k8s-mode SSO/AI values should satisfy the chart schema:\n${result.errors.join("\n")}`);
|
|
802
|
+
assert.equal(values.global.sso.clientId, undefined);
|
|
803
|
+
assert.equal(values.global.sso.clientSecret, undefined);
|
|
804
|
+
assert.equal(values.global.ai.openaiApiKey, undefined);
|
|
805
|
+
assert.equal(values.global.secrets.secretRef, `${getReleaseName(config.name)}-app-secrets`);
|
|
806
|
+
});
|
|
807
|
+
test("k8s secret mode: managed Supabase config validates against the chart schema", () => {
|
|
808
|
+
// Managed (Supabase Cloud) redacts the access token into the app Secret; the
|
|
809
|
+
// schema must accept secretRef instead of an inline global.supabase.accessToken.
|
|
810
|
+
const config = cloneFixture("aws-supabase-cloud");
|
|
811
|
+
const values = buildHelmValues(config, { secretMode: "k8s" });
|
|
812
|
+
const result = validateHelmValues(values);
|
|
813
|
+
assert.ok(result.valid, `k8s-mode managed-Supabase values should satisfy the chart schema:\n${result.errors.join("\n")}`);
|
|
814
|
+
assert.equal(values.global.supabase.accessToken, undefined);
|
|
815
|
+
assert.ok(values.global.supabase.url);
|
|
816
|
+
assert.equal(values.global.secrets.secretRef, `${getReleaseName(config.name)}-app-secrets`);
|
|
817
|
+
});
|
|
818
|
+
test("inline secret mode keeps secrets in values (dev path)", () => {
|
|
819
|
+
const config = cloneFixture("aws-self-hosted-minimal");
|
|
820
|
+
const values = buildHelmValues(config, { secretMode: "inline" });
|
|
821
|
+
assert.equal(values.supabase.secret.db.password, config.database.supabaseDbPassword);
|
|
822
|
+
// realtime keys derived (no shipped default) and present inline
|
|
823
|
+
assert.ok(values.supabase.secret.realtime.secretKeyBase);
|
|
824
|
+
assert.equal(values.supabase.secret.realtime.dbEncKey.length, 16);
|
|
825
|
+
// no consolidated app secretRef in inline mode
|
|
826
|
+
assert.equal(values.global.secrets?.secretRef ?? "", "");
|
|
827
|
+
});
|
|
828
|
+
test("buildDeploymentSecrets: app + supabase secrets with JWT-derived keys", () => {
|
|
829
|
+
const config = cloneFixture("aws-self-hosted-minimal");
|
|
830
|
+
const jwt = config.database.supabaseJwtSecret;
|
|
831
|
+
const byName = Object.fromEntries(buildDeploymentSecrets(config).map((s) => [s.name, s.stringData]));
|
|
832
|
+
const base = getReleaseName(config.name);
|
|
833
|
+
assert.equal(byName[`${base}-app-secrets`].LICENSE_KEY, config.licenseKey);
|
|
834
|
+
assert.equal(byName[`${base}-supabase-db`].password, config.database.supabaseDbPassword);
|
|
835
|
+
assert.equal(byName[`${base}-supabase-jwt`].anonKey, signSupabaseJwt("anon", jwt));
|
|
836
|
+
assert.equal(byName[`${base}-supabase-jwt`].serviceKey, signSupabaseJwt("service_role", jwt));
|
|
837
|
+
// realtime keys match the chart-side derivation + 16-byte DB_ENC_KEY
|
|
838
|
+
const rt = deriveRealtimeSecrets(jwt);
|
|
839
|
+
assert.equal(byName[`${base}-supabase-realtime`].SECRET_KEY_BASE, rt.secretKeyBase);
|
|
840
|
+
assert.equal(byName[`${base}-supabase-realtime`].DB_ENC_KEY.length, 16);
|
|
841
|
+
});
|
|
842
|
+
test("buildDeploymentSecrets includes external Postgres host/port and bootstrap creds", () => {
|
|
843
|
+
const config = cloneFixture("aws-external-postgres");
|
|
844
|
+
const byName = Object.fromEntries(buildDeploymentSecrets(config).map((s) => [s.name, s.stringData]));
|
|
845
|
+
const base = getReleaseName(config.name);
|
|
846
|
+
assert.deepEqual(byName[`${base}-supabase-db`], {
|
|
847
|
+
username: "postgres",
|
|
848
|
+
password: config.database.supabaseDbPassword,
|
|
849
|
+
database: "postgres",
|
|
850
|
+
host: "db.cluster-xxxx.us-east-1.rds.amazonaws.com",
|
|
851
|
+
port: "5432",
|
|
852
|
+
});
|
|
853
|
+
assert.deepEqual(byName[`${base}-supabase-db-bootstrap`], {
|
|
854
|
+
"master-username": "postgres",
|
|
855
|
+
"master-password": "master-pw-change-me",
|
|
856
|
+
"service-password": config.database.supabaseDbPassword,
|
|
857
|
+
});
|
|
858
|
+
});
|
|
859
|
+
test("external Postgres wires migrations.externalDb host/port for the migration hook", () => {
|
|
860
|
+
// templates/migration-job.yaml reads DB_HOST from .Values.migrations.externalDb
|
|
861
|
+
// (not supabase.externalDatabase). If unset, pg_isready gets an empty host and
|
|
862
|
+
// the migrate hook hangs until Helm times out. Guards that regression.
|
|
863
|
+
const config = cloneFixture("aws-external-postgres");
|
|
864
|
+
const values = buildHelmValues(config, { secretMode: "k8s" });
|
|
865
|
+
assert.ok(values.migrations?.externalDb, "migrations.externalDb must be set for external Postgres");
|
|
866
|
+
assert.ok(values.migrations.externalDb.host, "migration-hook DB_HOST must be non-empty");
|
|
867
|
+
assert.equal(values.migrations.externalDb.host, values.supabase.externalDatabase.host);
|
|
868
|
+
assert.equal(values.migrations.externalDb.port, "5432");
|
|
869
|
+
// Migrations run as the master (bootstrap only sets service-role passwords, not
|
|
870
|
+
// the master's), so DB_PASSWORD must come from the bootstrap Secret's
|
|
871
|
+
// master-password, NOT the service password in <release>-supabase-db.
|
|
872
|
+
assert.equal(values.migrations.externalDb.existingSecret, `${getReleaseName(config.name)}-supabase-db-bootstrap`);
|
|
873
|
+
assert.equal(values.migrations.externalDb.existingSecretKey, "master-password");
|
|
874
|
+
// Bundled-Postgres deploys must NOT set it (chart uses the internal service).
|
|
875
|
+
const internal = buildHelmValues(cloneFixture("aws-self-hosted-minimal"), {
|
|
876
|
+
secretMode: "k8s",
|
|
877
|
+
});
|
|
878
|
+
assert.equal(internal.migrations?.externalDb, undefined);
|
|
879
|
+
});
|
|
880
|
+
test("supabase kong ingress carries Traefik websecure router annotations under TLS", () => {
|
|
881
|
+
// The supabase subchart's kong ingress doesn't emit router.entrypoints/tls
|
|
882
|
+
// itself, so Traefik only builds a web router and https://supabase.<domain>
|
|
883
|
+
// 404s. The CLI must inject them (via the subchart's annotations passthrough).
|
|
884
|
+
const config = cloneFixture("aws-self-hosted-minimal");
|
|
885
|
+
const tls = buildHelmValues(config, {
|
|
886
|
+
tlsEnabled: true,
|
|
887
|
+
secretMode: "k8s",
|
|
888
|
+
});
|
|
889
|
+
const a = tls.supabase.kong.ingress.annotations;
|
|
890
|
+
assert.equal(a["traefik.ingress.kubernetes.io/router.entrypoints"], "websecure");
|
|
891
|
+
assert.equal(a["traefik.ingress.kubernetes.io/router.tls"], "true");
|
|
892
|
+
const notls = buildHelmValues(config, {
|
|
893
|
+
tlsEnabled: false,
|
|
894
|
+
secretMode: "k8s",
|
|
895
|
+
});
|
|
896
|
+
const b = notls.supabase.kong.ingress.annotations;
|
|
897
|
+
assert.equal(b["traefik.ingress.kubernetes.io/router.entrypoints"], "web");
|
|
898
|
+
assert.equal(b["traefik.ingress.kubernetes.io/router.tls"], "false");
|
|
899
|
+
});
|
|
900
|
+
// ===========================================================================
|
|
901
|
+
// Image registry / digest pinning (docker.io/rulebricks/* + global.imageRegistry)
|
|
902
|
+
// ===========================================================================
|
|
903
|
+
test("default image refs use the rulebricks/* split shape with no legacy hosts", () => {
|
|
904
|
+
const config = cloneFixture("aws-self-hosted-minimal");
|
|
905
|
+
const values = buildHelmValues(config);
|
|
906
|
+
// app/hps use the split { registry, repository } shape (host never in repo).
|
|
907
|
+
assert.deepEqual(values.rulebricks.app.image.registry, "docker.io");
|
|
908
|
+
assert.equal(values.rulebricks.app.image.repository, "rulebricks/app");
|
|
909
|
+
assert.equal(values.rulebricks.hps.image.registry, "docker.io");
|
|
910
|
+
assert.equal(values.rulebricks.hps.image.repository, "rulebricks/hps");
|
|
911
|
+
// clickstack images keep the split shape too.
|
|
912
|
+
assert.equal(values.clickstack.hyperdx.image.registry, "docker.io");
|
|
913
|
+
assert.equal(values.clickstack.hyperdx.image.repository, "rulebricks/hyperdx");
|
|
914
|
+
assert.equal(values.clickstack.collector.image.registry, "docker.io");
|
|
915
|
+
assert.equal(values.clickstack.collector.image.repository, "rulebricks/clickstack-otel-collector");
|
|
916
|
+
assert.equal(values.clickstack.ferretdb.image.registry, "docker.io");
|
|
917
|
+
assert.equal(values.clickstack.ferretdb.image.repository, "rulebricks/ferretdb");
|
|
918
|
+
assert.equal(values.clickstack.ferretdb.postgresImage.repository, "rulebricks/postgres-documentdb");
|
|
919
|
+
// Whole-output guard: no dhi.io and no index.docker.io anywhere.
|
|
920
|
+
const dump = JSON.stringify(values);
|
|
921
|
+
assert.ok(!dump.includes("dhi.io"), "dhi.io must not appear in output");
|
|
922
|
+
assert.ok(!dump.includes("index.docker.io"), "index.docker.io must not appear in output");
|
|
923
|
+
assert.ok(!dump.includes("grepplabs"), "grepplabs must not appear in output");
|
|
924
|
+
});
|
|
925
|
+
test("global.imageDigests is always present and threaded into global", () => {
|
|
926
|
+
const config = cloneFixture("aws-self-hosted-minimal");
|
|
927
|
+
const values = buildHelmValues(config);
|
|
928
|
+
assert.ok(values.global.imageDigests !== undefined, "global.imageDigests must be present");
|
|
929
|
+
assert.equal(typeof values.global.imageDigests, "object");
|
|
930
|
+
// No imageRegistry override emitted when config.imageRegistry is unset.
|
|
931
|
+
assert.equal(values.global.imageRegistry, undefined);
|
|
932
|
+
});
|
|
933
|
+
test("imageRegistry override rewrites every image host to the custom registry", () => {
|
|
934
|
+
const config = cloneFixture("aws-self-hosted-minimal");
|
|
935
|
+
config.imageRegistry = "myacr.azurecr.io";
|
|
936
|
+
// Enable external-dns so its image block is emitted and can be asserted.
|
|
937
|
+
config.dns = { provider: "route53", autoManage: true };
|
|
938
|
+
const values = buildHelmValues(config);
|
|
939
|
+
const reg = "myacr.azurecr.io";
|
|
940
|
+
// global passthrough
|
|
941
|
+
assert.equal(values.global.imageRegistry, reg);
|
|
942
|
+
// app / hps / clickstack / supabase split shapes
|
|
943
|
+
assert.equal(values.rulebricks.app.image.registry, reg);
|
|
944
|
+
assert.equal(values.rulebricks.app.image.repository, "rulebricks/app");
|
|
945
|
+
assert.equal(values.rulebricks.hps.image.registry, reg);
|
|
946
|
+
assert.equal(values.clickstack.hyperdx.image.registry, reg);
|
|
947
|
+
assert.equal(values.clickstack.collector.image.registry, reg);
|
|
948
|
+
assert.equal(values.clickstack.ferretdb.image.registry, reg);
|
|
949
|
+
assert.equal(values.clickstack.ferretdb.postgresImage.registry, reg);
|
|
950
|
+
assert.equal(values.supabase.db.image.registry, reg);
|
|
951
|
+
// kube-prometheus-stack sub-images
|
|
952
|
+
const kps = values["kube-prometheus-stack"];
|
|
953
|
+
assert.equal(kps.prometheus.prometheusSpec.image.registry, reg);
|
|
954
|
+
assert.equal(kps.prometheus.prometheusSpec.image.repository, "rulebricks/prometheus");
|
|
955
|
+
assert.equal(kps.alertmanager.alertmanagerSpec.image.registry, reg);
|
|
956
|
+
assert.equal(kps.alertmanager.alertmanagerSpec.image.repository, "rulebricks/alertmanager");
|
|
957
|
+
assert.equal(kps.prometheusOperator.image.registry, reg);
|
|
958
|
+
assert.equal(kps.prometheusOperator.image.repository, "rulebricks/prometheus-operator");
|
|
959
|
+
assert.equal(kps.prometheusOperator.prometheusConfigReloader.image.registry, reg);
|
|
960
|
+
assert.equal(kps.prometheusOperator.prometheusConfigReloader.image.repository, "rulebricks/prometheus-config-reloader");
|
|
961
|
+
assert.equal(kps.prometheusOperator.admissionWebhooks.patch.image.registry, reg);
|
|
962
|
+
assert.equal(kps.prometheusOperator.admissionWebhooks.patch.image.repository, "rulebricks/kube-webhook-certgen");
|
|
963
|
+
assert.equal(kps.grafana.image.registry, reg);
|
|
964
|
+
assert.equal(kps.grafana.image.repository, "rulebricks/grafana");
|
|
965
|
+
assert.equal(kps.grafana.sidecar.image.registry, reg);
|
|
966
|
+
assert.equal(kps.grafana.sidecar.image.repository, "rulebricks/k8s-sidecar");
|
|
967
|
+
assert.equal(kps["kube-state-metrics"].image.registry, reg);
|
|
968
|
+
assert.equal(kps["kube-state-metrics"].image.repository, "rulebricks/kube-state-metrics");
|
|
969
|
+
assert.equal(kps["prometheus-node-exporter"].image.registry, reg);
|
|
970
|
+
assert.equal(kps["prometheus-node-exporter"].image.repository, "rulebricks/node-exporter");
|
|
971
|
+
// cert-manager (registry + repository per component)
|
|
972
|
+
const cm = values["cert-manager"];
|
|
973
|
+
assert.equal(cm.image.registry, reg);
|
|
974
|
+
assert.equal(cm.image.repository, "rulebricks/cert-manager-controller");
|
|
975
|
+
assert.equal(cm.webhook.image.registry, reg);
|
|
976
|
+
assert.equal(cm.webhook.image.repository, "rulebricks/cert-manager-webhook");
|
|
977
|
+
assert.equal(cm.cainjector.image.registry, reg);
|
|
978
|
+
assert.equal(cm.cainjector.image.repository, "rulebricks/cert-manager-cainjector");
|
|
979
|
+
assert.equal(cm.startupapicheck.image.registry, reg);
|
|
980
|
+
assert.equal(cm.startupapicheck.image.repository, "rulebricks/cert-manager-startupapicheck");
|
|
981
|
+
assert.equal(cm.acmesolver.image.registry, reg);
|
|
982
|
+
assert.equal(cm.acmesolver.image.repository, "rulebricks/cert-manager-acmesolver");
|
|
983
|
+
// traefik (registry + repository)
|
|
984
|
+
assert.equal(values.traefik.image.registry, reg);
|
|
985
|
+
assert.equal(values.traefik.image.repository, "rulebricks/traefik");
|
|
986
|
+
// keda (global.image.registry host + per-comp repositories)
|
|
987
|
+
assert.equal(values.keda.global.image.registry, reg);
|
|
988
|
+
assert.equal(values.keda.image.keda.registry, reg);
|
|
989
|
+
assert.equal(values.keda.image.keda.repository, "rulebricks/keda");
|
|
990
|
+
assert.equal(values.keda.image.metricsApiServer.repository, "rulebricks/keda-metrics-apiserver");
|
|
991
|
+
assert.equal(values.keda.image.webhooks.repository, "rulebricks/keda-admission-webhooks");
|
|
992
|
+
// vector + external-dns (full-path repository incl. host)
|
|
993
|
+
assert.equal(values.vector.image.repository, `${reg}/rulebricks/vector`);
|
|
994
|
+
assert.equal(values["external-dns"].image.repository, `${reg}/rulebricks/external-dns`);
|
|
995
|
+
// Every image host is the custom registry: no docker.io image refs remain.
|
|
996
|
+
const dump = JSON.stringify(values);
|
|
997
|
+
assert.ok(!dump.includes("dhi.io"));
|
|
998
|
+
assert.ok(!dump.includes("index.docker.io"));
|
|
999
|
+
assert.ok(!dump.includes('"docker.io/rulebricks'), "no full docker.io/rulebricks path refs remain when overridden");
|
|
1000
|
+
});
|
|
1001
|
+
test("per-chart imagePullSecrets are still emitted for private rulebricks/*", () => {
|
|
1002
|
+
const config = cloneFixture("aws-self-hosted-minimal");
|
|
1003
|
+
const values = buildHelmValues(config);
|
|
1004
|
+
const expected = [{ name: `${getReleaseName(config.name)}-regcred` }];
|
|
1005
|
+
assert.deepEqual(values.global.imagePullSecrets, expected);
|
|
1006
|
+
assert.deepEqual(values["strimzi-kafka-operator"].image.imagePullSecrets, expected);
|
|
1007
|
+
assert.deepEqual(values.traefik.deployment.imagePullSecrets, expected);
|
|
1008
|
+
assert.deepEqual(values.keda.imagePullSecrets, expected);
|
|
1009
|
+
assert.deepEqual(values.vector.image.pullSecrets, expected);
|
|
1010
|
+
// global has no legacy dhi.io reference.
|
|
1011
|
+
assert.ok(!JSON.stringify(values.global).includes("dhi.io"));
|
|
1012
|
+
});
|