@crossdelta/infrastructure 0.5.5 → 0.6.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.
@@ -2,4 +2,6 @@ export * from './deploy-streams';
2
2
  export * from './discover-services';
3
3
  export * from './docker-hub-image';
4
4
  export * from './otel';
5
+ export * from './stream-job';
6
+ export * from './stream-setup';
5
7
  export * from './streams';
@@ -0,0 +1,68 @@
1
+ /**
2
+ * NATS JetStream Stream Materialization via K8s Job
3
+ *
4
+ * Creates streams inside the cluster using a K8s Job with nats-box.
5
+ * This solves the problem that Pulumi runs outside the cluster
6
+ * and cannot reach cluster-internal NATS URLs.
7
+ *
8
+ * Architecture:
9
+ * - Pure functions (stream-setup.ts) generate shell scripts
10
+ * - This file creates Pulumi K8s resources (ConfigMap + Job)
11
+ * - nats-box image provides the `nats` CLI (no custom image needed)
12
+ *
13
+ * @module @crossdelta/infrastructure/lib/helpers/stream-job
14
+ */
15
+ import type { Provider } from '@pulumi/kubernetes';
16
+ import * as k8s from '@pulumi/kubernetes';
17
+ import type { Output } from '@pulumi/pulumi';
18
+ import type { StreamPolicy } from './deploy-streams';
19
+ import { type ResolvedStream } from './stream-setup';
20
+ export type { ResolvedStream };
21
+ export { computeConfigHash, generateSetupScript, msToNatsDuration, resolveStreams } from './stream-setup';
22
+ export interface MaterializeStreamsConfig {
23
+ /** NATS connection URL (cluster-internal) */
24
+ natsUrl: Output<string>;
25
+ /** NATS auth credentials (optional) */
26
+ credentials?: {
27
+ user?: Output<string>;
28
+ password?: Output<string>;
29
+ };
30
+ /** Contracts module (e.g., `import * as contracts from '@my-org/contracts'`) */
31
+ contracts: Record<string, unknown>;
32
+ /** Stream-specific policy overrides */
33
+ policies?: Record<string, Partial<StreamPolicy>>;
34
+ /** Default policy for all streams */
35
+ defaults?: Partial<StreamPolicy>;
36
+ /** nats-box image override (default: natsio/nats-box:0.14.5) */
37
+ image?: string;
38
+ /** Job TTL after completion in seconds (default: 300) */
39
+ ttlAfterFinished?: number;
40
+ /** Max retry attempts (default: 3) */
41
+ backoffLimit?: number;
42
+ }
43
+ /**
44
+ * Materialize NATS JetStream streams via a K8s Job.
45
+ *
46
+ * Creates a ConfigMap with a setup script and a Job that runs it
47
+ * inside the cluster where NATS is reachable. Uses nats-box image
48
+ * (no custom image build needed).
49
+ *
50
+ * Validation runs at Pulumi plan time (outside cluster).
51
+ * Materialization runs at deploy time (inside cluster via K8s Job).
52
+ *
53
+ * @example
54
+ * ```typescript
55
+ * import { materializeStreams } from '@crossdelta/infrastructure'
56
+ * import * as contracts from '@my-org/contracts'
57
+ * import { STREAM_POLICIES, DEFAULT_POLICY } from '@my-org/contracts'
58
+ *
59
+ * materializeStreams(provider, 'my-namespace', {
60
+ * natsUrl: runtime.natsUrl,
61
+ * credentials: { user: natsUser, password: natsPassword },
62
+ * contracts,
63
+ * policies: STREAM_POLICIES,
64
+ * defaults: DEFAULT_POLICY,
65
+ * })
66
+ * ```
67
+ */
68
+ export declare const materializeStreams: (provider: Provider, namespace: string, config: MaterializeStreamsConfig) => k8s.batch.v1.Job;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Stream Setup Script Generator
3
+ *
4
+ * Pure functions for generating NATS JetStream setup scripts.
5
+ * No Pulumi or K8s dependencies — fully testable with Bun.
6
+ *
7
+ * Used by:
8
+ * - `stream-job.ts` (K8s Job materialization)
9
+ * - Tests (direct import)
10
+ *
11
+ * @module @crossdelta/infrastructure/lib/helpers/stream-setup
12
+ */
13
+ import type { StreamPolicy } from './deploy-streams';
14
+ import type { StreamDefinition } from './streams';
15
+ export interface ResolvedStream {
16
+ stream: string;
17
+ subjects: string[];
18
+ retention: string;
19
+ storage: string;
20
+ maxAge: string;
21
+ replicas: number;
22
+ maxMsgSize: number;
23
+ }
24
+ /**
25
+ * Convert milliseconds to NATS CLI duration format (e.g., "168h0m0s")
26
+ */
27
+ export declare const msToNatsDuration: (ms: number) => string;
28
+ /**
29
+ * Apply policies and produce resolved stream configs (pure)
30
+ */
31
+ export declare const resolveStreams: (definitions: StreamDefinition[], policies?: Record<string, Partial<StreamPolicy>>, defaults?: Partial<StreamPolicy>) => ResolvedStream[];
32
+ /**
33
+ * Generate a shell script that creates/updates NATS JetStream streams.
34
+ * Uses `nats` CLI from nats-box. Idempotent: creates if missing, updates if exists.
35
+ */
36
+ export declare const generateSetupScript: (streams: ResolvedStream[]) => string;
37
+ /**
38
+ * Compute a short hash from stream config for change detection.
39
+ * When config changes, the Job name changes → Pulumi recreates it.
40
+ */
41
+ export declare const computeConfigHash: (streams: ResolvedStream[]) => string;
package/dist/index.cjs CHANGED
@@ -44,8 +44,11 @@ var exports_lib = {};
44
44
  __export(exports_lib, {
45
45
  validateStreamDefinitionsPure: () => validateStreamDefinitionsPure,
46
46
  validateStreamDefinitions: () => validateStreamDefinitions,
47
+ resolveStreams: () => resolveStreams,
47
48
  registerRuntime: () => registerRuntime,
48
49
  ports: () => ports,
50
+ msToNatsDuration: () => msToNatsDuration,
51
+ materializeStreams: () => materializeStreams,
49
52
  hasPublicPort: () => hasPublicPort,
50
53
  getStreamNames: () => getStreamNames,
51
54
  getStreamDefinition: () => getStreamDefinition,
@@ -53,6 +56,7 @@ __export(exports_lib, {
53
56
  getPublicPorts: () => getPublicPorts,
54
57
  getPrimaryPort: () => getPrimaryPort,
55
58
  getAllPorts: () => getAllPorts,
59
+ generateSetupScript: () => generateSetupScript,
56
60
  generateLocalSetupScript: () => generateLocalSetupScript,
57
61
  generateKubectlApplyCommand: () => generateKubectlApplyCommand,
58
62
  generateK3dDeleteCommand: () => generateK3dDeleteCommand,
@@ -81,6 +85,7 @@ __export(exports_lib, {
81
85
  createImagePullSecret: () => createImagePullSecret,
82
86
  createDOKSCluster: () => createDOKSCluster,
83
87
  convertToComposeService: () => convertToComposeService,
88
+ computeConfigHash: () => computeConfigHash,
84
89
  collectStreamDefinitions: () => collectStreamDefinitions,
85
90
  buildOtelEnv: () => buildOtelEnv,
86
91
  buildNatsUrl: () => buildNatsUrl,
@@ -933,9 +938,175 @@ var buildOtelEnv = (serviceName, config) => {
933
938
  }
934
939
  return env;
935
940
  };
941
+ // lib/helpers/stream-job.ts
942
+ var k8s4 = __toESM(require("@pulumi/kubernetes"));
943
+
944
+ // lib/helpers/stream-setup.ts
945
+ var DEFAULT_POLICY = {
946
+ maxAge: 7 * 24 * 60 * 60 * 1000,
947
+ storage: "file",
948
+ replicas: 1,
949
+ maxMsgSize: 1024 * 1024
950
+ };
951
+ var msToNatsDuration = (ms) => {
952
+ const totalSeconds = Math.floor(ms / 1000);
953
+ const hours = Math.floor(totalSeconds / 3600);
954
+ const minutes = Math.floor(totalSeconds % 3600 / 60);
955
+ const seconds = totalSeconds % 60;
956
+ return `${hours}h${minutes}m${seconds}s`;
957
+ };
958
+ var resolveStreams = (definitions, policies = {}, defaults = DEFAULT_POLICY) => definitions.map(({ stream, subjects }) => {
959
+ const merged = { ...DEFAULT_POLICY, ...defaults, ...policies[stream] };
960
+ return {
961
+ stream,
962
+ subjects,
963
+ retention: "limits",
964
+ storage: merged.storage ?? "file",
965
+ maxAge: msToNatsDuration(merged.maxAge ?? 7 * 24 * 60 * 60 * 1000),
966
+ replicas: merged.replicas ?? 1,
967
+ maxMsgSize: merged.maxMsgSize ?? 1024 * 1024
968
+ };
969
+ });
970
+ var generateSetupScript = (streams) => {
971
+ const streamCommands = streams.map((s) => {
972
+ const subjects = s.subjects.join(",");
973
+ const args = [
974
+ `--retention ${s.retention}`,
975
+ `--storage ${s.storage}`,
976
+ `--max-age ${s.maxAge}`,
977
+ `--replicas ${s.replicas}`,
978
+ `--max-msg-size ${s.maxMsgSize}`
979
+ ].join(" ");
980
+ return [
981
+ `echo "Processing stream: ${s.stream} (${subjects})"`,
982
+ `if nats $NATS_OPTS stream info "${s.stream}" > /dev/null 2>&1; then`,
983
+ ` echo " Updating existing stream..."`,
984
+ ` nats $NATS_OPTS stream edit "${s.stream}" --subjects "${subjects}" ${args} --force`,
985
+ `else`,
986
+ ` echo " Creating new stream..."`,
987
+ ` nats $NATS_OPTS stream add "${s.stream}" --subjects "${subjects}" ${args} --defaults`,
988
+ `fi`,
989
+ `echo " ✅ ${s.stream} ready"`,
990
+ ``
991
+ ].join(`
992
+ `);
993
+ });
994
+ return [
995
+ `#!/bin/sh`,
996
+ `set -e`,
997
+ ``,
998
+ `NATS_OPTS="--server $NATS_URL"`,
999
+ `if [ -n "$NATS_USER" ] && [ -n "$NATS_PASSWORD" ]; then`,
1000
+ ` NATS_OPTS="$NATS_OPTS --user $NATS_USER --password $NATS_PASSWORD"`,
1001
+ `fi`,
1002
+ ``,
1003
+ `echo "\uD83D\uDE80 Materializing ${streams.length} stream(s)..."`,
1004
+ `echo ""`,
1005
+ ``,
1006
+ ...streamCommands,
1007
+ `echo ""`,
1008
+ `echo "✅ All streams materialized successfully"`
1009
+ ].join(`
1010
+ `);
1011
+ };
1012
+ var computeConfigHash = (streams) => {
1013
+ const data = JSON.stringify(streams);
1014
+ let hash = 0;
1015
+ for (let i = 0;i < data.length; i++) {
1016
+ const char = data.charCodeAt(i);
1017
+ hash = (hash << 5) - hash + char | 0;
1018
+ }
1019
+ return Math.abs(hash).toString(36).slice(0, 8);
1020
+ };
1021
+
1022
+ // lib/helpers/stream-job.ts
1023
+ var DEFAULT_POLICY2 = {
1024
+ maxAge: 7 * 24 * 60 * 60 * 1000,
1025
+ storage: "file",
1026
+ replicas: 1,
1027
+ maxMsgSize: 1024 * 1024
1028
+ };
1029
+ var NATS_BOX_IMAGE = "natsio/nats-box:0.14.5";
1030
+ var materializeStreams = (provider, namespace, config) => {
1031
+ const {
1032
+ contracts,
1033
+ policies = {},
1034
+ defaults = DEFAULT_POLICY2,
1035
+ image = NATS_BOX_IMAGE,
1036
+ ttlAfterFinished = 300,
1037
+ backoffLimit = 3
1038
+ } = config;
1039
+ validateStreamDefinitions(contracts);
1040
+ const definitions = collectStreamDefinitions(contracts);
1041
+ const resolved = resolveStreams(definitions, policies, defaults);
1042
+ const script = generateSetupScript(resolved);
1043
+ const configHash = computeConfigHash(resolved);
1044
+ console.log(`\uD83D\uDCCB Collecting stream definitions from contracts...`);
1045
+ console.log(`✅ Found ${definitions.length} stream(s):`);
1046
+ for (const { stream, subjects } of definitions) {
1047
+ console.log(` - ${stream}: ${subjects.join(", ")}`);
1048
+ }
1049
+ const configMap = new k8s4.core.v1.ConfigMap("stream-setup-config", {
1050
+ metadata: {
1051
+ name: `stream-setup-${configHash}`,
1052
+ namespace,
1053
+ labels: { app: "stream-setup", "config-hash": configHash }
1054
+ },
1055
+ data: {
1056
+ "setup.sh": script,
1057
+ "streams.json": JSON.stringify(resolved, null, 2)
1058
+ }
1059
+ }, { provider });
1060
+ const envVars = [{ name: "NATS_URL", value: config.natsUrl }];
1061
+ if (config.credentials?.user) {
1062
+ envVars.push({ name: "NATS_USER", value: config.credentials.user });
1063
+ }
1064
+ if (config.credentials?.password) {
1065
+ envVars.push({ name: "NATS_PASSWORD", value: config.credentials.password });
1066
+ }
1067
+ const job = new k8s4.batch.v1.Job("stream-setup", {
1068
+ metadata: {
1069
+ name: `stream-setup-${configHash}`,
1070
+ namespace,
1071
+ labels: { app: "stream-setup", "config-hash": configHash }
1072
+ },
1073
+ spec: {
1074
+ backoffLimit,
1075
+ ttlSecondsAfterFinished: ttlAfterFinished,
1076
+ template: {
1077
+ metadata: {
1078
+ labels: { app: "stream-setup" }
1079
+ },
1080
+ spec: {
1081
+ restartPolicy: "Never",
1082
+ containers: [
1083
+ {
1084
+ name: "stream-setup",
1085
+ image,
1086
+ command: ["sh", "/config/setup.sh"],
1087
+ env: envVars,
1088
+ volumeMounts: [{ name: "config", mountPath: "/config", readOnly: true }]
1089
+ }
1090
+ ],
1091
+ volumes: [
1092
+ {
1093
+ name: "config",
1094
+ configMap: { name: configMap.metadata.name }
1095
+ }
1096
+ ]
1097
+ }
1098
+ }
1099
+ }
1100
+ }, {
1101
+ provider,
1102
+ dependsOn: [configMap],
1103
+ deleteBeforeReplace: true
1104
+ });
1105
+ return job;
1106
+ };
936
1107
  // lib/runtimes/doks/cluster.ts
937
1108
  var digitalocean = __toESM(require("@pulumi/digitalocean"));
938
- var k8s4 = __toESM(require("@pulumi/kubernetes"));
1109
+ var k8s5 = __toESM(require("@pulumi/kubernetes"));
939
1110
  var pulumi4 = __toESM(require("@pulumi/pulumi"));
940
1111
  function createDOKSCluster(config) {
941
1112
  const stack = pulumi4.getStack();
@@ -962,6 +1133,8 @@ function createDOKSCluster(config) {
962
1133
  maxNodes: config.nodePool.autoScale?.maxNodes,
963
1134
  labels: config.nodePool.labels
964
1135
  }
1136
+ }, {
1137
+ ignoreChanges: ["version"]
965
1138
  });
966
1139
  const kubeconfig = cluster.kubeConfigs.apply((configs) => {
967
1140
  const firstConfig = configs?.[0];
@@ -970,7 +1143,7 @@ function createDOKSCluster(config) {
970
1143
  }
971
1144
  return firstConfig.rawConfig;
972
1145
  });
973
- const provider = new k8s4.Provider(`${config.name}-k8s-provider`, {
1146
+ const provider = new k8s5.Provider(`${config.name}-k8s-provider`, {
974
1147
  kubeconfig
975
1148
  });
976
1149
  return {
@@ -981,7 +1154,7 @@ function createDOKSCluster(config) {
981
1154
  };
982
1155
  }
983
1156
  function createK8sProviderFromKubeconfig(name, kubeconfig) {
984
- return new k8s4.Provider(name, { kubeconfig });
1157
+ return new k8s5.Provider(name, { kubeconfig });
985
1158
  }
986
1159
  // lib/runtimes/doks/vpc.ts
987
1160
  var digitalocean2 = __toESM(require("@pulumi/digitalocean"));
@@ -997,7 +1170,7 @@ function createVPC(config) {
997
1170
  });
998
1171
  }
999
1172
  // lib/runtimes/doks/workloads.ts
1000
- var k8s5 = __toESM(require("@pulumi/kubernetes"));
1173
+ var k8s6 = __toESM(require("@pulumi/kubernetes"));
1001
1174
  var pulumi6 = __toESM(require("@pulumi/pulumi"));
1002
1175
  var normalizeK8sConfig = (config) => {
1003
1176
  if (config.ports) {
@@ -1047,7 +1220,7 @@ var createImagePullSecret = (provider, namespace, name, config) => {
1047
1220
  }
1048
1221
  });
1049
1222
  });
1050
- return new k8s5.core.v1.Secret(name, {
1223
+ return new k8s6.core.v1.Secret(name, {
1051
1224
  metadata: {
1052
1225
  name,
1053
1226
  namespace,
@@ -1077,7 +1250,7 @@ var buildEnvVars = (config) => {
1077
1250
  })) : [];
1078
1251
  return [portEnv, ...plainEnvVars, ...secretEnvVars];
1079
1252
  };
1080
- var createServiceSecret = (provider, namespace, config, labels) => !config.secrets || Object.keys(config.secrets).length === 0 ? undefined : new k8s5.core.v1.Secret(`${config.name}-secret`, {
1253
+ var createServiceSecret = (provider, namespace, config, labels) => !config.secrets || Object.keys(config.secrets).length === 0 ? undefined : new k8s6.core.v1.Secret(`${config.name}-secret`, {
1081
1254
  metadata: {
1082
1255
  name: `${config.name}-secret`,
1083
1256
  namespace,
@@ -1090,7 +1263,7 @@ var createServiceVolumes = (provider, namespace, config, labels) => {
1090
1263
  if (!config.volumes) {
1091
1264
  return { pvcs: [], volumeMounts: [], volumes: [] };
1092
1265
  }
1093
- const pvcs = config.volumes.map((vol) => new k8s5.core.v1.PersistentVolumeClaim(`${config.name}-${vol.name}`, {
1266
+ const pvcs = config.volumes.map((vol) => new k8s6.core.v1.PersistentVolumeClaim(`${config.name}-${vol.name}`, {
1094
1267
  metadata: {
1095
1268
  name: `${config.name}-${vol.name}`,
1096
1269
  namespace,
@@ -1206,7 +1379,7 @@ var createServiceIngress = (provider, namespace, config, labels, service) => {
1206
1379
  const allHosts = [...primaryHosts, ...additionalHosts];
1207
1380
  const ingressRules = allHosts.length > 0 ? allHosts.map(createRule) : [createRule()];
1208
1381
  const tlsSecretName = config.ingress.tls?.secretName ?? `${config.name}-tls`;
1209
- return new k8s5.networking.v1.Ingress(`${config.name}-ingress`, {
1382
+ return new k8s6.networking.v1.Ingress(`${config.name}-ingress`, {
1210
1383
  metadata: {
1211
1384
  name: config.name,
1212
1385
  namespace,
@@ -1236,7 +1409,7 @@ var deployK8sService = (provider, namespace, config) => {
1236
1409
  const { livenessProbe, readinessProbe } = buildHealthProbes(normalizedConfig);
1237
1410
  const containerPorts = buildContainerPorts(normalizedConfig);
1238
1411
  const servicePorts = buildServicePorts(normalizedConfig);
1239
- const deployment = new k8s5.apps.v1.Deployment(`${normalizedConfig.name}-deployment`, {
1412
+ const deployment = new k8s6.apps.v1.Deployment(`${normalizedConfig.name}-deployment`, {
1240
1413
  metadata: {
1241
1414
  name: normalizedConfig.name,
1242
1415
  namespace,
@@ -1277,7 +1450,7 @@ var deployK8sService = (provider, namespace, config) => {
1277
1450
  }
1278
1451
  }
1279
1452
  }, { provider, dependsOn: pvcs.length > 0 ? pvcs : undefined });
1280
- const service = new k8s5.core.v1.Service(`${normalizedConfig.name}-service`, {
1453
+ const service = new k8s6.core.v1.Service(`${normalizedConfig.name}-service`, {
1281
1454
  metadata: {
1282
1455
  name: normalizedConfig.name,
1283
1456
  namespace,
@@ -1310,7 +1483,7 @@ var deployK8sServices = (provider, namespace, configs, options) => configs.filte
1310
1483
  results.set(config.name, deployK8sService(provider, namespace, configWithSecret));
1311
1484
  return results;
1312
1485
  }, new Map);
1313
- var createNamespace = (provider, name, labels) => new k8s5.core.v1.Namespace(name, {
1486
+ var createNamespace = (provider, name, labels) => new k8s6.core.v1.Namespace(name, {
1314
1487
  metadata: {
1315
1488
  name,
1316
1489
  labels: {
package/dist/index.js CHANGED
@@ -844,9 +844,175 @@ var buildOtelEnv = (serviceName, config) => {
844
844
  }
845
845
  return env;
846
846
  };
847
+ // lib/helpers/stream-job.ts
848
+ import * as k8s4 from "@pulumi/kubernetes";
849
+
850
+ // lib/helpers/stream-setup.ts
851
+ var DEFAULT_POLICY = {
852
+ maxAge: 7 * 24 * 60 * 60 * 1000,
853
+ storage: "file",
854
+ replicas: 1,
855
+ maxMsgSize: 1024 * 1024
856
+ };
857
+ var msToNatsDuration = (ms) => {
858
+ const totalSeconds = Math.floor(ms / 1000);
859
+ const hours = Math.floor(totalSeconds / 3600);
860
+ const minutes = Math.floor(totalSeconds % 3600 / 60);
861
+ const seconds = totalSeconds % 60;
862
+ return `${hours}h${minutes}m${seconds}s`;
863
+ };
864
+ var resolveStreams = (definitions, policies = {}, defaults = DEFAULT_POLICY) => definitions.map(({ stream, subjects }) => {
865
+ const merged = { ...DEFAULT_POLICY, ...defaults, ...policies[stream] };
866
+ return {
867
+ stream,
868
+ subjects,
869
+ retention: "limits",
870
+ storage: merged.storage ?? "file",
871
+ maxAge: msToNatsDuration(merged.maxAge ?? 7 * 24 * 60 * 60 * 1000),
872
+ replicas: merged.replicas ?? 1,
873
+ maxMsgSize: merged.maxMsgSize ?? 1024 * 1024
874
+ };
875
+ });
876
+ var generateSetupScript = (streams) => {
877
+ const streamCommands = streams.map((s) => {
878
+ const subjects = s.subjects.join(",");
879
+ const args = [
880
+ `--retention ${s.retention}`,
881
+ `--storage ${s.storage}`,
882
+ `--max-age ${s.maxAge}`,
883
+ `--replicas ${s.replicas}`,
884
+ `--max-msg-size ${s.maxMsgSize}`
885
+ ].join(" ");
886
+ return [
887
+ `echo "Processing stream: ${s.stream} (${subjects})"`,
888
+ `if nats $NATS_OPTS stream info "${s.stream}" > /dev/null 2>&1; then`,
889
+ ` echo " Updating existing stream..."`,
890
+ ` nats $NATS_OPTS stream edit "${s.stream}" --subjects "${subjects}" ${args} --force`,
891
+ `else`,
892
+ ` echo " Creating new stream..."`,
893
+ ` nats $NATS_OPTS stream add "${s.stream}" --subjects "${subjects}" ${args} --defaults`,
894
+ `fi`,
895
+ `echo " ✅ ${s.stream} ready"`,
896
+ ``
897
+ ].join(`
898
+ `);
899
+ });
900
+ return [
901
+ `#!/bin/sh`,
902
+ `set -e`,
903
+ ``,
904
+ `NATS_OPTS="--server $NATS_URL"`,
905
+ `if [ -n "$NATS_USER" ] && [ -n "$NATS_PASSWORD" ]; then`,
906
+ ` NATS_OPTS="$NATS_OPTS --user $NATS_USER --password $NATS_PASSWORD"`,
907
+ `fi`,
908
+ ``,
909
+ `echo "\uD83D\uDE80 Materializing ${streams.length} stream(s)..."`,
910
+ `echo ""`,
911
+ ``,
912
+ ...streamCommands,
913
+ `echo ""`,
914
+ `echo "✅ All streams materialized successfully"`
915
+ ].join(`
916
+ `);
917
+ };
918
+ var computeConfigHash = (streams) => {
919
+ const data = JSON.stringify(streams);
920
+ let hash = 0;
921
+ for (let i = 0;i < data.length; i++) {
922
+ const char = data.charCodeAt(i);
923
+ hash = (hash << 5) - hash + char | 0;
924
+ }
925
+ return Math.abs(hash).toString(36).slice(0, 8);
926
+ };
927
+
928
+ // lib/helpers/stream-job.ts
929
+ var DEFAULT_POLICY2 = {
930
+ maxAge: 7 * 24 * 60 * 60 * 1000,
931
+ storage: "file",
932
+ replicas: 1,
933
+ maxMsgSize: 1024 * 1024
934
+ };
935
+ var NATS_BOX_IMAGE = "natsio/nats-box:0.14.5";
936
+ var materializeStreams = (provider, namespace, config) => {
937
+ const {
938
+ contracts,
939
+ policies = {},
940
+ defaults = DEFAULT_POLICY2,
941
+ image = NATS_BOX_IMAGE,
942
+ ttlAfterFinished = 300,
943
+ backoffLimit = 3
944
+ } = config;
945
+ validateStreamDefinitions(contracts);
946
+ const definitions = collectStreamDefinitions(contracts);
947
+ const resolved = resolveStreams(definitions, policies, defaults);
948
+ const script = generateSetupScript(resolved);
949
+ const configHash = computeConfigHash(resolved);
950
+ console.log(`\uD83D\uDCCB Collecting stream definitions from contracts...`);
951
+ console.log(`✅ Found ${definitions.length} stream(s):`);
952
+ for (const { stream, subjects } of definitions) {
953
+ console.log(` - ${stream}: ${subjects.join(", ")}`);
954
+ }
955
+ const configMap = new k8s4.core.v1.ConfigMap("stream-setup-config", {
956
+ metadata: {
957
+ name: `stream-setup-${configHash}`,
958
+ namespace,
959
+ labels: { app: "stream-setup", "config-hash": configHash }
960
+ },
961
+ data: {
962
+ "setup.sh": script,
963
+ "streams.json": JSON.stringify(resolved, null, 2)
964
+ }
965
+ }, { provider });
966
+ const envVars = [{ name: "NATS_URL", value: config.natsUrl }];
967
+ if (config.credentials?.user) {
968
+ envVars.push({ name: "NATS_USER", value: config.credentials.user });
969
+ }
970
+ if (config.credentials?.password) {
971
+ envVars.push({ name: "NATS_PASSWORD", value: config.credentials.password });
972
+ }
973
+ const job = new k8s4.batch.v1.Job("stream-setup", {
974
+ metadata: {
975
+ name: `stream-setup-${configHash}`,
976
+ namespace,
977
+ labels: { app: "stream-setup", "config-hash": configHash }
978
+ },
979
+ spec: {
980
+ backoffLimit,
981
+ ttlSecondsAfterFinished: ttlAfterFinished,
982
+ template: {
983
+ metadata: {
984
+ labels: { app: "stream-setup" }
985
+ },
986
+ spec: {
987
+ restartPolicy: "Never",
988
+ containers: [
989
+ {
990
+ name: "stream-setup",
991
+ image,
992
+ command: ["sh", "/config/setup.sh"],
993
+ env: envVars,
994
+ volumeMounts: [{ name: "config", mountPath: "/config", readOnly: true }]
995
+ }
996
+ ],
997
+ volumes: [
998
+ {
999
+ name: "config",
1000
+ configMap: { name: configMap.metadata.name }
1001
+ }
1002
+ ]
1003
+ }
1004
+ }
1005
+ }
1006
+ }, {
1007
+ provider,
1008
+ dependsOn: [configMap],
1009
+ deleteBeforeReplace: true
1010
+ });
1011
+ return job;
1012
+ };
847
1013
  // lib/runtimes/doks/cluster.ts
848
1014
  import * as digitalocean from "@pulumi/digitalocean";
849
- import * as k8s4 from "@pulumi/kubernetes";
1015
+ import * as k8s5 from "@pulumi/kubernetes";
850
1016
  import * as pulumi4 from "@pulumi/pulumi";
851
1017
  function createDOKSCluster(config) {
852
1018
  const stack = pulumi4.getStack();
@@ -873,6 +1039,8 @@ function createDOKSCluster(config) {
873
1039
  maxNodes: config.nodePool.autoScale?.maxNodes,
874
1040
  labels: config.nodePool.labels
875
1041
  }
1042
+ }, {
1043
+ ignoreChanges: ["version"]
876
1044
  });
877
1045
  const kubeconfig = cluster.kubeConfigs.apply((configs) => {
878
1046
  const firstConfig = configs?.[0];
@@ -881,7 +1049,7 @@ function createDOKSCluster(config) {
881
1049
  }
882
1050
  return firstConfig.rawConfig;
883
1051
  });
884
- const provider = new k8s4.Provider(`${config.name}-k8s-provider`, {
1052
+ const provider = new k8s5.Provider(`${config.name}-k8s-provider`, {
885
1053
  kubeconfig
886
1054
  });
887
1055
  return {
@@ -892,7 +1060,7 @@ function createDOKSCluster(config) {
892
1060
  };
893
1061
  }
894
1062
  function createK8sProviderFromKubeconfig(name, kubeconfig) {
895
- return new k8s4.Provider(name, { kubeconfig });
1063
+ return new k8s5.Provider(name, { kubeconfig });
896
1064
  }
897
1065
  // lib/runtimes/doks/vpc.ts
898
1066
  import * as digitalocean2 from "@pulumi/digitalocean";
@@ -908,7 +1076,7 @@ function createVPC(config) {
908
1076
  });
909
1077
  }
910
1078
  // lib/runtimes/doks/workloads.ts
911
- import * as k8s5 from "@pulumi/kubernetes";
1079
+ import * as k8s6 from "@pulumi/kubernetes";
912
1080
  import * as pulumi6 from "@pulumi/pulumi";
913
1081
  var normalizeK8sConfig = (config) => {
914
1082
  if (config.ports) {
@@ -958,7 +1126,7 @@ var createImagePullSecret = (provider, namespace, name, config) => {
958
1126
  }
959
1127
  });
960
1128
  });
961
- return new k8s5.core.v1.Secret(name, {
1129
+ return new k8s6.core.v1.Secret(name, {
962
1130
  metadata: {
963
1131
  name,
964
1132
  namespace,
@@ -988,7 +1156,7 @@ var buildEnvVars = (config) => {
988
1156
  })) : [];
989
1157
  return [portEnv, ...plainEnvVars, ...secretEnvVars];
990
1158
  };
991
- var createServiceSecret = (provider, namespace, config, labels) => !config.secrets || Object.keys(config.secrets).length === 0 ? undefined : new k8s5.core.v1.Secret(`${config.name}-secret`, {
1159
+ var createServiceSecret = (provider, namespace, config, labels) => !config.secrets || Object.keys(config.secrets).length === 0 ? undefined : new k8s6.core.v1.Secret(`${config.name}-secret`, {
992
1160
  metadata: {
993
1161
  name: `${config.name}-secret`,
994
1162
  namespace,
@@ -1001,7 +1169,7 @@ var createServiceVolumes = (provider, namespace, config, labels) => {
1001
1169
  if (!config.volumes) {
1002
1170
  return { pvcs: [], volumeMounts: [], volumes: [] };
1003
1171
  }
1004
- const pvcs = config.volumes.map((vol) => new k8s5.core.v1.PersistentVolumeClaim(`${config.name}-${vol.name}`, {
1172
+ const pvcs = config.volumes.map((vol) => new k8s6.core.v1.PersistentVolumeClaim(`${config.name}-${vol.name}`, {
1005
1173
  metadata: {
1006
1174
  name: `${config.name}-${vol.name}`,
1007
1175
  namespace,
@@ -1117,7 +1285,7 @@ var createServiceIngress = (provider, namespace, config, labels, service) => {
1117
1285
  const allHosts = [...primaryHosts, ...additionalHosts];
1118
1286
  const ingressRules = allHosts.length > 0 ? allHosts.map(createRule) : [createRule()];
1119
1287
  const tlsSecretName = config.ingress.tls?.secretName ?? `${config.name}-tls`;
1120
- return new k8s5.networking.v1.Ingress(`${config.name}-ingress`, {
1288
+ return new k8s6.networking.v1.Ingress(`${config.name}-ingress`, {
1121
1289
  metadata: {
1122
1290
  name: config.name,
1123
1291
  namespace,
@@ -1147,7 +1315,7 @@ var deployK8sService = (provider, namespace, config) => {
1147
1315
  const { livenessProbe, readinessProbe } = buildHealthProbes(normalizedConfig);
1148
1316
  const containerPorts = buildContainerPorts(normalizedConfig);
1149
1317
  const servicePorts = buildServicePorts(normalizedConfig);
1150
- const deployment = new k8s5.apps.v1.Deployment(`${normalizedConfig.name}-deployment`, {
1318
+ const deployment = new k8s6.apps.v1.Deployment(`${normalizedConfig.name}-deployment`, {
1151
1319
  metadata: {
1152
1320
  name: normalizedConfig.name,
1153
1321
  namespace,
@@ -1188,7 +1356,7 @@ var deployK8sService = (provider, namespace, config) => {
1188
1356
  }
1189
1357
  }
1190
1358
  }, { provider, dependsOn: pvcs.length > 0 ? pvcs : undefined });
1191
- const service = new k8s5.core.v1.Service(`${normalizedConfig.name}-service`, {
1359
+ const service = new k8s6.core.v1.Service(`${normalizedConfig.name}-service`, {
1192
1360
  metadata: {
1193
1361
  name: normalizedConfig.name,
1194
1362
  namespace,
@@ -1221,7 +1389,7 @@ var deployK8sServices = (provider, namespace, configs, options) => configs.filte
1221
1389
  results.set(config.name, deployK8sService(provider, namespace, configWithSecret));
1222
1390
  return results;
1223
1391
  }, new Map);
1224
- var createNamespace = (provider, name, labels) => new k8s5.core.v1.Namespace(name, {
1392
+ var createNamespace = (provider, name, labels) => new k8s6.core.v1.Namespace(name, {
1225
1393
  metadata: {
1226
1394
  name,
1227
1395
  labels: {
@@ -1595,8 +1763,11 @@ var generateLocalSetupScript = (services, options = {}) => {
1595
1763
  export {
1596
1764
  validateStreamDefinitionsPure,
1597
1765
  validateStreamDefinitions,
1766
+ resolveStreams,
1598
1767
  registerRuntime,
1599
1768
  ports,
1769
+ msToNatsDuration,
1770
+ materializeStreams,
1600
1771
  hasPublicPort,
1601
1772
  getStreamNames,
1602
1773
  getStreamDefinition,
@@ -1604,6 +1775,7 @@ export {
1604
1775
  getPublicPorts,
1605
1776
  getPrimaryPort,
1606
1777
  getAllPorts,
1778
+ generateSetupScript,
1607
1779
  generateLocalSetupScript,
1608
1780
  generateKubectlApplyCommand,
1609
1781
  generateK3dDeleteCommand,
@@ -1632,6 +1804,7 @@ export {
1632
1804
  createImagePullSecret,
1633
1805
  createDOKSCluster,
1634
1806
  convertToComposeService,
1807
+ computeConfigHash,
1635
1808
  collectStreamDefinitions,
1636
1809
  buildOtelEnv,
1637
1810
  buildNatsUrl,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crossdelta/infrastructure",
3
- "version": "0.5.5",
3
+ "version": "0.6.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -35,7 +35,7 @@
35
35
  }
36
36
  },
37
37
  "dependencies": {
38
- "@crossdelta/cloudevents": "0.6.3"
38
+ "@crossdelta/cloudevents": "0.6.4"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@pulumi/digitalocean": "^4.0.0",