@agent-relay/factory 0.1.49 → 0.1.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/README.md +93 -11
  2. package/dist/cli/fleet.d.ts +4 -1
  3. package/dist/cli/fleet.d.ts.map +1 -1
  4. package/dist/cli/fleet.js +87 -42
  5. package/dist/cli/fleet.js.map +1 -1
  6. package/dist/config/schema.d.ts +1671 -10
  7. package/dist/config/schema.d.ts.map +1 -1
  8. package/dist/config/schema.js +83 -0
  9. package/dist/config/schema.js.map +1 -1
  10. package/dist/dispatch/templates.d.ts +6 -0
  11. package/dist/dispatch/templates.d.ts.map +1 -1
  12. package/dist/dispatch/templates.js +16 -0
  13. package/dist/dispatch/templates.js.map +1 -1
  14. package/dist/environments/connection-registry.d.ts +617 -0
  15. package/dist/environments/connection-registry.d.ts.map +1 -0
  16. package/dist/environments/connection-registry.js +174 -0
  17. package/dist/environments/connection-registry.js.map +1 -0
  18. package/dist/environments/index.d.ts +21 -0
  19. package/dist/environments/index.d.ts.map +1 -0
  20. package/dist/environments/index.js +11 -0
  21. package/dist/environments/index.js.map +1 -0
  22. package/dist/environments/k6-job.d.ts +64 -0
  23. package/dist/environments/k6-job.d.ts.map +1 -0
  24. package/dist/environments/k6-job.js +376 -0
  25. package/dist/environments/k6-job.js.map +1 -0
  26. package/dist/environments/kubernetes-client.d.ts +72 -0
  27. package/dist/environments/kubernetes-client.d.ts.map +1 -0
  28. package/dist/environments/kubernetes-client.js +337 -0
  29. package/dist/environments/kubernetes-client.js.map +1 -0
  30. package/dist/environments/kubernetes-command.d.ts +30 -0
  31. package/dist/environments/kubernetes-command.d.ts.map +1 -0
  32. package/dist/environments/kubernetes-command.js +108 -0
  33. package/dist/environments/kubernetes-command.js.map +1 -0
  34. package/dist/environments/kubernetes-environment.d.ts +38 -0
  35. package/dist/environments/kubernetes-environment.d.ts.map +1 -0
  36. package/dist/environments/kubernetes-environment.js +333 -0
  37. package/dist/environments/kubernetes-environment.js.map +1 -0
  38. package/dist/environments/kubernetes-provider.d.ts +83 -0
  39. package/dist/environments/kubernetes-provider.d.ts.map +1 -0
  40. package/dist/environments/kubernetes-provider.js +956 -0
  41. package/dist/environments/kubernetes-provider.js.map +1 -0
  42. package/dist/environments/load-harness.d.ts +199 -0
  43. package/dist/environments/load-harness.d.ts.map +1 -0
  44. package/dist/environments/load-harness.js +259 -0
  45. package/dist/environments/load-harness.js.map +1 -0
  46. package/dist/environments/load-profile.d.ts +294 -0
  47. package/dist/environments/load-profile.d.ts.map +1 -0
  48. package/dist/environments/load-profile.js +125 -0
  49. package/dist/environments/load-profile.js.map +1 -0
  50. package/dist/environments/stack-deployer.d.ts +22 -0
  51. package/dist/environments/stack-deployer.d.ts.map +1 -0
  52. package/dist/environments/stack-deployer.js +27 -0
  53. package/dist/environments/stack-deployer.js.map +1 -0
  54. package/dist/environments/stack-descriptor.d.ts +243 -0
  55. package/dist/environments/stack-descriptor.d.ts.map +1 -0
  56. package/dist/environments/stack-descriptor.js +88 -0
  57. package/dist/environments/stack-descriptor.js.map +1 -0
  58. package/dist/environments/verification-pipeline.d.ts +117 -0
  59. package/dist/environments/verification-pipeline.d.ts.map +1 -0
  60. package/dist/environments/verification-pipeline.js +643 -0
  61. package/dist/environments/verification-pipeline.js.map +1 -0
  62. package/dist/environments/verification-stack-deployer.d.ts +73 -0
  63. package/dist/environments/verification-stack-deployer.d.ts.map +1 -0
  64. package/dist/environments/verification-stack-deployer.js +484 -0
  65. package/dist/environments/verification-stack-deployer.js.map +1 -0
  66. package/dist/environments/verification-stack-descriptor.d.ts +1134 -0
  67. package/dist/environments/verification-stack-descriptor.d.ts.map +1 -0
  68. package/dist/environments/verification-stack-descriptor.js +275 -0
  69. package/dist/environments/verification-stack-descriptor.js.map +1 -0
  70. package/dist/environments/verification-stack.d.ts +29 -0
  71. package/dist/environments/verification-stack.d.ts.map +1 -0
  72. package/dist/environments/verification-stack.js +43 -0
  73. package/dist/environments/verification-stack.js.map +1 -0
  74. package/dist/featuremap/generate.d.ts +72 -0
  75. package/dist/featuremap/generate.d.ts.map +1 -0
  76. package/dist/featuremap/generate.js +854 -0
  77. package/dist/featuremap/generate.js.map +1 -0
  78. package/dist/featuremap/index.d.ts +2 -0
  79. package/dist/featuremap/index.d.ts.map +1 -1
  80. package/dist/featuremap/index.js +1 -0
  81. package/dist/featuremap/index.js.map +1 -1
  82. package/dist/featuremap/validate.d.ts +6 -0
  83. package/dist/featuremap/validate.d.ts.map +1 -1
  84. package/dist/featuremap/validate.js +95 -13
  85. package/dist/featuremap/validate.js.map +1 -1
  86. package/dist/fleet/create-fleet.d.ts +2 -0
  87. package/dist/fleet/create-fleet.d.ts.map +1 -1
  88. package/dist/fleet/create-fleet.js +1 -0
  89. package/dist/fleet/create-fleet.js.map +1 -1
  90. package/dist/fleet/internal-fleet-client.d.ts +8 -1
  91. package/dist/fleet/internal-fleet-client.d.ts.map +1 -1
  92. package/dist/fleet/internal-fleet-client.js +31 -6
  93. package/dist/fleet/internal-fleet-client.js.map +1 -1
  94. package/dist/fleet/relay-fleet-client.d.ts +4 -1
  95. package/dist/fleet/relay-fleet-client.d.ts.map +1 -1
  96. package/dist/fleet/relay-fleet-client.js +142 -1
  97. package/dist/fleet/relay-fleet-client.js.map +1 -1
  98. package/dist/git/agent-worktree.d.ts +4 -1
  99. package/dist/git/agent-worktree.d.ts.map +1 -1
  100. package/dist/git/agent-worktree.js +151 -8
  101. package/dist/git/agent-worktree.js.map +1 -1
  102. package/dist/index.d.ts +32 -8
  103. package/dist/index.d.ts.map +1 -1
  104. package/dist/index.js +15 -3
  105. package/dist/index.js.map +1 -1
  106. package/dist/mount/local-mount-preflight.d.ts.map +1 -1
  107. package/dist/mount/local-mount-preflight.js +6 -2
  108. package/dist/mount/local-mount-preflight.js.map +1 -1
  109. package/dist/mount/relayfile-cloud-mount-client.d.ts +8 -2
  110. package/dist/mount/relayfile-cloud-mount-client.d.ts.map +1 -1
  111. package/dist/mount/relayfile-cloud-mount-client.js +11 -1
  112. package/dist/mount/relayfile-cloud-mount-client.js.map +1 -1
  113. package/dist/node/factory-node.d.ts +2 -0
  114. package/dist/node/factory-node.d.ts.map +1 -1
  115. package/dist/node/factory-node.js +107 -2
  116. package/dist/node/factory-node.js.map +1 -1
  117. package/dist/node/preview-process.d.ts +65 -0
  118. package/dist/node/preview-process.d.ts.map +1 -0
  119. package/dist/node/preview-process.js +431 -0
  120. package/dist/node/preview-process.js.map +1 -0
  121. package/dist/node/tailscale-preview.d.ts +45 -0
  122. package/dist/node/tailscale-preview.d.ts.map +1 -0
  123. package/dist/node/tailscale-preview.js +748 -0
  124. package/dist/node/tailscale-preview.js.map +1 -0
  125. package/dist/observability/events.d.ts +1838 -28
  126. package/dist/observability/events.d.ts.map +1 -1
  127. package/dist/observability/events.js +52 -0
  128. package/dist/observability/events.js.map +1 -1
  129. package/dist/orchestrator/factory.d.ts.map +1 -1
  130. package/dist/orchestrator/factory.js +1416 -78
  131. package/dist/orchestrator/factory.js.map +1 -1
  132. package/dist/orchestrator/index.d.ts +2 -1
  133. package/dist/orchestrator/index.d.ts.map +1 -1
  134. package/dist/orchestrator/index.js +1 -1
  135. package/dist/orchestrator/index.js.map +1 -1
  136. package/dist/orchestrator/reaper.d.ts +24 -0
  137. package/dist/orchestrator/reaper.d.ts.map +1 -1
  138. package/dist/orchestrator/reaper.js +117 -0
  139. package/dist/orchestrator/reaper.js.map +1 -1
  140. package/dist/ports/environment.d.ts +83 -0
  141. package/dist/ports/environment.d.ts.map +1 -0
  142. package/dist/ports/environment.js +2 -0
  143. package/dist/ports/environment.js.map +1 -0
  144. package/dist/ports/fleet.d.ts +68 -1
  145. package/dist/ports/fleet.d.ts.map +1 -1
  146. package/dist/ports/index.d.ts +3 -2
  147. package/dist/ports/index.d.ts.map +1 -1
  148. package/dist/ports/mount.d.ts +9 -0
  149. package/dist/ports/mount.d.ts.map +1 -1
  150. package/dist/ports/state.d.ts +17 -1
  151. package/dist/ports/state.d.ts.map +1 -1
  152. package/dist/ports/worktree.d.ts +14 -0
  153. package/dist/ports/worktree.d.ts.map +1 -1
  154. package/dist/state/file-state-store.js +28 -1
  155. package/dist/state/file-state-store.js.map +1 -1
  156. package/dist/subscriptions/index.d.ts +2 -0
  157. package/dist/subscriptions/index.d.ts.map +1 -1
  158. package/dist/subscriptions/index.js +1 -0
  159. package/dist/subscriptions/index.js.map +1 -1
  160. package/dist/subscriptions/resource-subscriptions.d.ts +77 -0
  161. package/dist/subscriptions/resource-subscriptions.d.ts.map +1 -0
  162. package/dist/subscriptions/resource-subscriptions.js +91 -0
  163. package/dist/subscriptions/resource-subscriptions.js.map +1 -0
  164. package/dist/testing/fakes.d.ts +9 -1
  165. package/dist/testing/fakes.d.ts.map +1 -1
  166. package/dist/testing/fakes.js +40 -0
  167. package/dist/testing/fakes.js.map +1 -1
  168. package/dist/triage/schema.d.ts +32 -32
  169. package/dist/types.d.ts +4 -1
  170. package/dist/types.d.ts.map +1 -1
  171. package/package.json +18 -4
  172. package/schemas/kubernetes-environment-stack.schema.json +86 -0
  173. package/schemas/verification-stack.schema.json +292 -0
@@ -0,0 +1,956 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { normalizeLogger } from '../logging.js';
3
+ import { KubernetesGuardrailDefaultsSchema, } from './connection-registry.js';
4
+ import { KubectlKubernetesClient, } from './kubernetes-client.js';
5
+ import { KubernetesStackDescriptorSchema, } from './stack-descriptor.js';
6
+ export const KUBERNETES_MANAGED_BY_LABEL = 'app.kubernetes.io/managed-by';
7
+ export const KUBERNETES_ENVIRONMENT_ID_LABEL = 'factory.agentworkforce.dev/environment-id';
8
+ export const KUBERNETES_OWNER_ID_LABEL = 'factory.agentworkforce.dev/owner-id';
9
+ export const KUBERNETES_EXPIRES_AT_LABEL = 'factory.agentworkforce.dev/expires-at';
10
+ export const KUBERNETES_OWNER_ID_ANNOTATION = 'factory.agentworkforce.dev/owner-id';
11
+ export const KUBERNETES_CUSTOMER_ID_ANNOTATION = 'factory.agentworkforce.dev/customer-id';
12
+ export const KUBERNETES_REPOSITORY_ANNOTATION = 'factory.agentworkforce.dev/repository';
13
+ export const KUBERNETES_CONNECTION_ID_ANNOTATION = 'factory.agentworkforce.dev/connection-id';
14
+ export const KUBERNETES_TARGET_ANNOTATION = 'factory.agentworkforce.dev/target';
15
+ export const KUBERNETES_CREATED_AT_ANNOTATION = 'factory.agentworkforce.dev/created-at';
16
+ export const KUBERNETES_ENDPOINTS_ANNOTATION = 'factory.agentworkforce.dev/endpoints';
17
+ export const KUBERNETES_CLUSTER_RESOURCES_ANNOTATION = 'factory.agentworkforce.dev/cluster-resources';
18
+ const MANAGED_BY_VALUE = 'factory';
19
+ const RESERVED_RESOURCE_PREFIX = 'factory-guardrail-';
20
+ const DEPLOYER_SERVICE_ACCOUNT = `${RESERVED_RESOURCE_PREFIX}deployer`;
21
+ const WORKLOAD_SERVICE_ACCOUNT = `${RESERVED_RESOURCE_PREFIX}workload`;
22
+ export class EnvironmentKubernetesStackSecretResolver {
23
+ #env;
24
+ constructor(env = process.env) {
25
+ this.#env = env;
26
+ }
27
+ async resolve(reference) {
28
+ if (!reference.startsWith('env:')) {
29
+ throw new Error(`Unsupported stack secret reference ${reference}; configure a secret-manager resolver for this scheme`);
30
+ }
31
+ const variable = reference.slice('env:'.length);
32
+ if (!/^[A-Z_][A-Z0-9_]*$/u.test(variable))
33
+ throw new Error(`Invalid environment secret reference ${reference}`);
34
+ const value = this.#env[variable];
35
+ if (value === undefined)
36
+ throw new Error(`Required stack secret reference ${reference} is unavailable`);
37
+ return value;
38
+ }
39
+ }
40
+ /** Namespace-isolated Kubernetes provider for customer EKS and managed fallback clusters. */
41
+ export class KubernetesEnvironmentProvider {
42
+ #registry;
43
+ #client;
44
+ #secrets;
45
+ #defaults;
46
+ #now;
47
+ #randomId;
48
+ #logger;
49
+ #ownerIsAlive;
50
+ #records = new Map();
51
+ constructor(options) {
52
+ this.#registry = options.registry;
53
+ this.#client = options.client ?? new KubectlKubernetesClient();
54
+ this.#secrets = options.secretResolver ?? new EnvironmentKubernetesStackSecretResolver();
55
+ this.#defaults = KubernetesGuardrailDefaultsSchema.parse({
56
+ ...options.registry.config.defaults,
57
+ ...options.defaults,
58
+ limitRange: {
59
+ ...options.registry.config.defaults.limitRange,
60
+ ...options.defaults?.limitRange,
61
+ },
62
+ });
63
+ this.#now = options.now ?? (() => new Date());
64
+ this.#randomId = options.randomId ?? (() => randomUUID().replaceAll('-', '').slice(0, 10));
65
+ this.#logger = options.logger ? normalizeLogger(options.logger) : undefined;
66
+ this.#ownerIsAlive = options.ownerIsAlive;
67
+ }
68
+ async provision(spec) {
69
+ const descriptor = KubernetesStackDescriptorSchema.parse(spec.stack.descriptor);
70
+ const target = spec.target ?? descriptor.target ?? 'byoc';
71
+ if (descriptor.target !== target) {
72
+ throw new Error(`Kubernetes descriptor target ${descriptor.target} does not match requested target ${target}`);
73
+ }
74
+ const ttl = spec.ttl ?? this.#defaults.ttlMs;
75
+ if (!Number.isInteger(ttl) || ttl < 60_000 || ttl > 7 * 24 * 60 * 60_000) {
76
+ throw new Error('Kubernetes environment ttl must be between 60000 and 604800000 milliseconds');
77
+ }
78
+ const connection = await this.#registry.resolve({
79
+ customerId: spec.customerId,
80
+ repository: spec.repository,
81
+ target,
82
+ });
83
+ const createdAt = this.#now();
84
+ const id = generatedNamespaceName(spec.repository, this.#randomId());
85
+ const expiresAtSeconds = Math.ceil((createdAt.getTime() + ttl) / 1_000);
86
+ const rendered = await this.#client.render(descriptor.deployment, id, spec.stack.repoRoot, connection);
87
+ const safeResources = enforceKubernetesResourceSafety(rendered, {
88
+ namespace: id,
89
+ environmentId: id,
90
+ connection,
91
+ descriptorAllowsClusterScope: descriptor.allowClusterScopedResources,
92
+ });
93
+ const clusterResources = safeResources.filter((resource) => isClusterScopedResource(resource, connection));
94
+ const namespacedResources = safeResources.filter((resource) => !isClusterScopedResource(resource, connection));
95
+ const namespace = namespaceResource({
96
+ id,
97
+ spec,
98
+ descriptor,
99
+ connection,
100
+ createdAt,
101
+ expiresAtSeconds,
102
+ clusterResources,
103
+ });
104
+ let namespaceCreated = false;
105
+ const createdClusterResources = [];
106
+ let ambiguousClusterResource;
107
+ try {
108
+ // `create`, not `apply`, is deliberate: a collision can never reuse an
109
+ // existing (and potentially production) namespace.
110
+ await this.#client.createNamespace(namespace, connection);
111
+ namespaceCreated = true;
112
+ await this.#client.apply(createKubernetesGuardrailResources(id, connection, this.#defaults), id, connection);
113
+ const secretResources = await this.#secretResources(descriptor.secrets, id);
114
+ if (secretResources.length > 0)
115
+ await this.#client.apply(secretResources, id, connection);
116
+ for (const resource of clusterResources) {
117
+ // Cluster-scoped resources are always created, never applied, so a
118
+ // customer object with the same name cannot be adopted or overwritten.
119
+ ambiguousClusterResource = resource;
120
+ await this.#client.createClusterResource(resource, connection);
121
+ createdClusterResources.push(resource);
122
+ ambiguousClusterResource = undefined;
123
+ }
124
+ await this.#client.apply(namespacedResources, id, connection);
125
+ await this.#client.waitForReady(id, connection, descriptor.readinessTimeoutMs ?? this.#defaults.readinessTimeoutMs);
126
+ const environment = {
127
+ id,
128
+ provider: 'kubernetes',
129
+ dispatchNamespace: id,
130
+ endpoints: {},
131
+ bindings: {
132
+ 'kubernetes.connection': connection.id,
133
+ 'kubernetes.target': connection.target,
134
+ ...(connection.target === 'managed' && connection.fidelityCaveat
135
+ ? { 'kubernetes.fidelityCaveat': connection.fidelityCaveat }
136
+ : {}),
137
+ },
138
+ status: 'ready',
139
+ createdAt: createdAt.toISOString(),
140
+ ttl,
141
+ };
142
+ const record = {
143
+ environment,
144
+ connection,
145
+ endpoints: descriptor.endpoints,
146
+ clusterResources,
147
+ forwards: new Map(),
148
+ };
149
+ this.#records.set(id, record);
150
+ environment.endpoints = await this.endpoints(id);
151
+ this.#logger?.info?.('[kubernetes-environment] provisioned environment', {
152
+ id,
153
+ connectionId: connection.id,
154
+ target: connection.target,
155
+ expiresAt: new Date(expiresAtSeconds * 1_000).toISOString(),
156
+ });
157
+ return cloneEnvironment(environment);
158
+ }
159
+ catch (error) {
160
+ const partialRecord = this.#records.get(id);
161
+ if (partialRecord) {
162
+ await this.#stopForwards(partialRecord);
163
+ this.#records.delete(id);
164
+ }
165
+ const cleanupErrors = await this.#deleteOwnedClusterResources(createdClusterResources, id, connection);
166
+ cleanupErrors.push(...await this.#deleteOwnedClusterResources(ambiguousClusterResource ? [ambiguousClusterResource] : [], id, connection, { skipUnowned: true }));
167
+ // Keep the namespace (and its persisted cluster-resource identities) if
168
+ // cluster cleanup failed, so a later reaper can retry without guessing.
169
+ if (namespaceCreated && cleanupErrors.length === 0) {
170
+ try {
171
+ const current = await this.#client.getNamespace(id, connection);
172
+ if (current && isOwnedNamespace(current, id, connection.id))
173
+ await this.#client.deleteNamespace(id, connection);
174
+ }
175
+ catch (cleanupError) {
176
+ cleanupErrors.push(cleanupError);
177
+ }
178
+ }
179
+ if (cleanupErrors.length > 0) {
180
+ throw new AggregateError([error, ...cleanupErrors], `Kubernetes provision failed and cleanup also failed for ${id}`);
181
+ }
182
+ throw error;
183
+ }
184
+ }
185
+ async status(id) {
186
+ const record = await this.#findRecord(id);
187
+ if (!record)
188
+ return 'destroyed';
189
+ const namespace = await this.#client.getNamespace(id, record.connection);
190
+ if (!namespace) {
191
+ record.environment.status = 'destroyed';
192
+ await this.#stopForwards(record);
193
+ this.#records.delete(id);
194
+ return 'destroyed';
195
+ }
196
+ assertOwnedNamespace(namespace, id, record.connection.id);
197
+ if (namespace.metadata?.deletionTimestamp || record.environment.status === 'destroying')
198
+ return 'destroying';
199
+ if (record.environment.status === 'failed')
200
+ return 'failed';
201
+ return record.environment.status === 'provisioning' ? 'provisioning' : 'ready';
202
+ }
203
+ async endpoints(id) {
204
+ const record = await this.#findRecord(id);
205
+ if (!record)
206
+ throw new Error(`Kubernetes environment ${id} does not exist`);
207
+ const namespace = await this.#client.getNamespace(id, record.connection);
208
+ if (!namespace)
209
+ throw new Error(`Kubernetes environment ${id} does not exist`);
210
+ assertOwnedNamespace(namespace, id, record.connection.id);
211
+ for (const endpoint of record.endpoints) {
212
+ if (record.environment.endpoints[endpoint.name])
213
+ continue;
214
+ if (endpoint.ingressUrl) {
215
+ record.environment.endpoints[endpoint.name] = endpointUrl(endpoint.ingressUrl, endpoint.path);
216
+ continue;
217
+ }
218
+ const forward = await this.#client.portForwardService(id, endpoint.service, endpoint.port, endpoint.protocol, record.connection);
219
+ record.forwards.set(endpoint.name, forward);
220
+ record.environment.endpoints[endpoint.name] = endpointUrl(forward.url, endpoint.path);
221
+ }
222
+ return { ...record.environment.endpoints };
223
+ }
224
+ async destroy(id) {
225
+ const record = await this.#findRecord(id);
226
+ if (!record)
227
+ return;
228
+ record.environment.status = 'destroying';
229
+ await this.#stopForwards(record);
230
+ const namespace = await this.#client.getNamespace(id, record.connection);
231
+ if (!namespace) {
232
+ const cleanupErrors = await this.#deleteOwnedClusterResources(record.clusterResources, id, record.connection);
233
+ if (cleanupErrors.length > 0) {
234
+ record.environment.status = 'failed';
235
+ throw new AggregateError(cleanupErrors, `Could not safely delete cluster-scoped resources for ${id}`);
236
+ }
237
+ record.environment.status = 'destroyed';
238
+ this.#records.delete(id);
239
+ return;
240
+ }
241
+ assertOwnedNamespace(namespace, id, record.connection.id);
242
+ const cleanupErrors = await this.#deleteOwnedClusterResources(record.clusterResources, id, record.connection);
243
+ if (cleanupErrors.length > 0) {
244
+ record.environment.status = 'failed';
245
+ throw new AggregateError(cleanupErrors, `Could not safely delete cluster-scoped resources for ${id}`);
246
+ }
247
+ await this.#client.deleteNamespace(id, record.connection);
248
+ record.environment.status = 'destroyed';
249
+ this.#records.delete(id);
250
+ this.#logger?.info?.('[kubernetes-environment] destroyed environment', {
251
+ id,
252
+ connectionId: record.connection.id,
253
+ });
254
+ }
255
+ async reap() {
256
+ const report = { reaped: [], skipped: [] };
257
+ const nowMs = this.#now().getTime();
258
+ for (const resolution of await this.#registry.resolveAll()) {
259
+ if (!resolution.connection) {
260
+ report.skipped.push({
261
+ connectionId: resolution.id,
262
+ reason: `credential resolution failed: ${errorMessage(resolution.error)}`,
263
+ });
264
+ continue;
265
+ }
266
+ const connection = resolution.connection;
267
+ let namespaces;
268
+ try {
269
+ namespaces = await this.#client.listNamespaces(connection, `${KUBERNETES_MANAGED_BY_LABEL}=${MANAGED_BY_VALUE}`);
270
+ }
271
+ catch (error) {
272
+ report.skipped.push({ connectionId: connection.id, reason: `list failed: ${errorMessage(error)}` });
273
+ continue;
274
+ }
275
+ for (const namespace of namespaces) {
276
+ const id = namespace.metadata?.name;
277
+ if (!id) {
278
+ report.skipped.push({ connectionId: connection.id, reason: 'owned namespace has no name' });
279
+ continue;
280
+ }
281
+ if (!isOwnedNamespace(namespace, id, connection.id)) {
282
+ report.skipped.push({ id, connectionId: connection.id, reason: 'ownership identity mismatch' });
283
+ continue;
284
+ }
285
+ const expiresAtSeconds = Number(namespace.metadata?.labels?.[KUBERNETES_EXPIRES_AT_LABEL]);
286
+ const ownerId = namespace.metadata?.annotations?.[KUBERNETES_OWNER_ID_ANNOTATION];
287
+ let reason;
288
+ if (Number.isFinite(expiresAtSeconds) && expiresAtSeconds * 1_000 <= nowMs) {
289
+ reason = 'ttl-expired';
290
+ }
291
+ else if (ownerId && this.#ownerIsAlive) {
292
+ try {
293
+ if (await this.#ownerIsAlive(ownerId) === false)
294
+ reason = 'owner-gone';
295
+ }
296
+ catch (error) {
297
+ report.skipped.push({ id, connectionId: connection.id, reason: `owner check failed: ${errorMessage(error)}` });
298
+ continue;
299
+ }
300
+ }
301
+ if (!reason)
302
+ continue;
303
+ const record = this.#records.get(id);
304
+ if (record)
305
+ await this.#stopForwards(record);
306
+ let clusterResources;
307
+ try {
308
+ clusterResources = parsePersistedClusterResources(namespace.metadata?.annotations?.[KUBERNETES_CLUSTER_RESOURCES_ANNOTATION]);
309
+ }
310
+ catch (error) {
311
+ report.skipped.push({
312
+ id,
313
+ connectionId: connection.id,
314
+ reason: `invalid cluster resource identity: ${errorMessage(error)}`,
315
+ });
316
+ continue;
317
+ }
318
+ const cleanupErrors = await this.#deleteOwnedClusterResources(clusterResources, id, connection);
319
+ if (cleanupErrors.length > 0) {
320
+ report.skipped.push({
321
+ id,
322
+ connectionId: connection.id,
323
+ reason: `cluster resource cleanup failed: ${cleanupErrors.map(errorMessage).join('; ')}`,
324
+ });
325
+ continue;
326
+ }
327
+ try {
328
+ await this.#client.deleteNamespace(id, connection);
329
+ }
330
+ catch (error) {
331
+ report.skipped.push({
332
+ id,
333
+ connectionId: connection.id,
334
+ reason: `namespace deletion failed: ${errorMessage(error)}`,
335
+ });
336
+ continue;
337
+ }
338
+ if (record) {
339
+ record.environment.status = 'destroyed';
340
+ this.#records.delete(id);
341
+ }
342
+ report.reaped.push({ id, connectionId: connection.id, reason });
343
+ this.#logger?.warn?.('[kubernetes-environment] reaped environment', {
344
+ id,
345
+ connectionId: connection.id,
346
+ reason,
347
+ });
348
+ }
349
+ }
350
+ return report;
351
+ }
352
+ async #findRecord(id) {
353
+ const known = this.#records.get(id);
354
+ if (known)
355
+ return known;
356
+ const resolutionFailures = [];
357
+ for (const resolution of await this.#registry.resolveAll()) {
358
+ if (!resolution.connection) {
359
+ resolutionFailures.push(new Error(`Kubernetes connection ${resolution.id} is unavailable: ${errorMessage(resolution.error)}`));
360
+ continue;
361
+ }
362
+ const connection = resolution.connection;
363
+ const namespace = await this.#client.getNamespace(id, connection);
364
+ if (!namespace)
365
+ continue;
366
+ assertOwnedNamespace(namespace, id, connection.id);
367
+ const annotations = namespace.metadata?.annotations ?? {};
368
+ const createdAt = annotations[KUBERNETES_CREATED_AT_ANNOTATION];
369
+ const expirySeconds = Number(namespace.metadata?.labels?.[KUBERNETES_EXPIRES_AT_LABEL]);
370
+ const createdAtMs = Date.parse(createdAt ?? '');
371
+ const ttl = Number.isFinite(expirySeconds) && Number.isFinite(createdAtMs)
372
+ ? Math.max(0, expirySeconds * 1_000 - createdAtMs)
373
+ : this.#defaults.ttlMs;
374
+ const endpoints = parsePersistedEndpoints(annotations[KUBERNETES_ENDPOINTS_ANNOTATION]);
375
+ const clusterResources = parsePersistedClusterResources(annotations[KUBERNETES_CLUSTER_RESOURCES_ANNOTATION]);
376
+ const record = {
377
+ environment: {
378
+ id,
379
+ provider: 'kubernetes',
380
+ dispatchNamespace: id,
381
+ endpoints: {},
382
+ bindings: {
383
+ 'kubernetes.connection': connection.id,
384
+ 'kubernetes.target': connection.target,
385
+ ...(connection.target === 'managed' && connection.fidelityCaveat
386
+ ? { 'kubernetes.fidelityCaveat': connection.fidelityCaveat }
387
+ : {}),
388
+ },
389
+ status: namespace.metadata?.deletionTimestamp ? 'destroying' : 'ready',
390
+ createdAt: Number.isFinite(createdAtMs) ? new Date(createdAtMs).toISOString() : this.#now().toISOString(),
391
+ ttl,
392
+ },
393
+ connection,
394
+ endpoints,
395
+ clusterResources,
396
+ forwards: new Map(),
397
+ };
398
+ this.#records.set(id, record);
399
+ return record;
400
+ }
401
+ if (resolutionFailures.length > 0) {
402
+ throw new AggregateError(resolutionFailures, `Could not safely determine whether Kubernetes environment ${id} exists`);
403
+ }
404
+ return undefined;
405
+ }
406
+ async #secretResources(secrets, namespace) {
407
+ const grouped = new Map();
408
+ for (const secret of secrets) {
409
+ const value = await this.#secrets.resolve(secret.secretRef);
410
+ const data = grouped.get(secret.name) ?? {};
411
+ data[secret.key] = Buffer.from(value, 'utf8').toString('base64');
412
+ grouped.set(secret.name, data);
413
+ }
414
+ return [...grouped.entries()].map(([name, data]) => ({
415
+ apiVersion: 'v1',
416
+ kind: 'Secret',
417
+ metadata: { name, namespace },
418
+ type: 'Opaque',
419
+ data,
420
+ }));
421
+ }
422
+ async #stopForwards(record) {
423
+ const forwards = [...record.forwards.values()];
424
+ record.forwards.clear();
425
+ record.environment.endpoints = {};
426
+ await Promise.allSettled(forwards.map(async (forward) => await forward.stop()));
427
+ }
428
+ async #deleteOwnedClusterResources(resources, environmentId, connection, options = {}) {
429
+ const errors = [];
430
+ for (const resource of [...resources].reverse()) {
431
+ try {
432
+ const current = await this.#client.getClusterResource(resource, connection);
433
+ if (!current)
434
+ continue;
435
+ if (current.metadata?.labels?.[KUBERNETES_ENVIRONMENT_ID_LABEL] !== environmentId) {
436
+ if (options.skipUnowned)
437
+ continue;
438
+ throw new Error(`Refusing to delete cluster-scoped ${resource.kind}/${resource.metadata?.name}: ownership identity does not match`);
439
+ }
440
+ await this.#client.deleteClusterResource(resource, connection);
441
+ }
442
+ catch (error) {
443
+ errors.push(error);
444
+ }
445
+ }
446
+ return errors;
447
+ }
448
+ }
449
+ export class KubernetesEnvironmentReaper {
450
+ #provider;
451
+ #intervalMs;
452
+ #timer;
453
+ #running = false;
454
+ #stopped = false;
455
+ constructor(provider, options = {}) {
456
+ this.#provider = provider;
457
+ this.#intervalMs = options.intervalMs ?? 60_000;
458
+ }
459
+ start() {
460
+ if (this.#timer || this.#stopped)
461
+ return;
462
+ this.#schedule(0);
463
+ }
464
+ async stop() {
465
+ this.#stopped = true;
466
+ if (this.#timer)
467
+ clearTimeout(this.#timer);
468
+ this.#timer = undefined;
469
+ }
470
+ #schedule(delayMs) {
471
+ this.#timer = setTimeout(() => {
472
+ this.#timer = undefined;
473
+ void this.#tick();
474
+ }, delayMs);
475
+ }
476
+ async #tick() {
477
+ if (this.#stopped || this.#running)
478
+ return;
479
+ this.#running = true;
480
+ try {
481
+ await this.#provider.reap();
482
+ }
483
+ finally {
484
+ this.#running = false;
485
+ if (!this.#stopped)
486
+ this.#schedule(this.#intervalMs);
487
+ }
488
+ }
489
+ }
490
+ const CLUSTER_SCOPED_KINDS = new Set([
491
+ 'APIService',
492
+ 'CertificateSigningRequest',
493
+ 'ClusterRole',
494
+ 'ClusterRoleBinding',
495
+ 'CustomResourceDefinition',
496
+ 'CSIDriver',
497
+ 'CSINode',
498
+ 'IngressClass',
499
+ 'MutatingWebhookConfiguration',
500
+ 'Namespace',
501
+ 'Node',
502
+ 'PersistentVolume',
503
+ 'PodSecurityPolicy',
504
+ 'PriorityClass',
505
+ 'RuntimeClass',
506
+ 'StorageClass',
507
+ 'ValidatingAdmissionPolicy',
508
+ 'ValidatingAdmissionPolicyBinding',
509
+ 'ValidatingWebhookConfiguration',
510
+ 'VolumeAttachment',
511
+ ]);
512
+ export function enforceKubernetesResourceSafety(input, options) {
513
+ const renderedClusterRoles = new Set(input
514
+ .filter((resource) => resource.kind === 'ClusterRole')
515
+ .map((resource) => resource.metadata?.name)
516
+ .filter((name) => Boolean(name)));
517
+ return input.map((raw) => {
518
+ const resource = structuredClone(raw);
519
+ const kind = resource.kind;
520
+ const apiVersion = resource.apiVersion;
521
+ const name = resource.metadata?.name;
522
+ if (!kind || !apiVersion || !name)
523
+ throw new Error('Rendered Kubernetes resources require apiVersion, kind, and metadata.name');
524
+ if (kind === 'Namespace')
525
+ throw new Error('Customer stacks may not create or modify namespaces');
526
+ if (kind === 'Secret') {
527
+ throw new Error(`Customer stack Secret/${name} is denied; declare secretRef entries in the verification descriptor`);
528
+ }
529
+ if (name.startsWith(RESERVED_RESOURCE_PREFIX)) {
530
+ throw new Error(`Customer stack resource ${kind}/${name} uses Factory's reserved guardrail prefix`);
531
+ }
532
+ if (kind === 'NetworkPolicy' && customerPolicyAllowsEgress(resource)) {
533
+ throw new Error(`Customer NetworkPolicy/${name} may not add egress rules that bypass Factory isolation`);
534
+ }
535
+ validateCustomerRbac(resource, options.namespace, renderedClusterRoles);
536
+ const clusterScoped = isClusterScopedResource(resource, options.connection);
537
+ if (clusterScoped) {
538
+ const qualifiedKind = `${apiVersion}/${kind}`;
539
+ const allowedKinds = options.connection.allowedClusterScopedKinds;
540
+ if (!options.descriptorAllowsClusterScope ||
541
+ !options.connection.allowClusterScopedResources ||
542
+ (!allowedKinds.includes(kind) && !allowedKinds.includes(qualifiedKind))) {
543
+ throw new Error(`Cluster-scoped resource ${qualifiedKind}/${name} is denied; both descriptor and customer connection must opt in and allow the kind`);
544
+ }
545
+ if (resource.metadata?.namespace)
546
+ delete resource.metadata.namespace;
547
+ }
548
+ else {
549
+ const declaredNamespace = resource.metadata?.namespace;
550
+ if (declaredNamespace && declaredNamespace !== options.namespace) {
551
+ throw new Error(`Customer stack resource ${kind}/${name} targets namespace ${declaredNamespace}; only generated namespace ${options.namespace} is allowed`);
552
+ }
553
+ resource.metadata = { ...resource.metadata, namespace: options.namespace };
554
+ }
555
+ resource.metadata = {
556
+ ...resource.metadata,
557
+ labels: {
558
+ ...resource.metadata?.labels,
559
+ [KUBERNETES_ENVIRONMENT_ID_LABEL]: options.environmentId,
560
+ },
561
+ };
562
+ applyPlacementAndIdentity(resource, options.connection);
563
+ return resource;
564
+ });
565
+ }
566
+ export function createKubernetesGuardrailResources(namespace, connection, defaultsInput) {
567
+ const defaults = KubernetesGuardrailDefaultsSchema.parse(defaultsInput);
568
+ const protectedNamespaces = [...new Set([...connection.protectedNamespaces, 'kube-system'])];
569
+ const namespaceEgress = protectedNamespaces.length === 0 ? [] : [{
570
+ to: [{
571
+ namespaceSelector: {
572
+ matchExpressions: [{
573
+ key: 'kubernetes.io/metadata.name',
574
+ operator: 'NotIn',
575
+ values: protectedNamespaces,
576
+ }],
577
+ },
578
+ }],
579
+ }];
580
+ const metadata = (name) => ({
581
+ name: `${RESERVED_RESOURCE_PREFIX}${name}`,
582
+ namespace,
583
+ labels: { [KUBERNETES_ENVIRONMENT_ID_LABEL]: namespace },
584
+ });
585
+ return [
586
+ {
587
+ apiVersion: 'v1', kind: 'ServiceAccount',
588
+ metadata: { ...metadata('deployer'), name: DEPLOYER_SERVICE_ACCOUNT },
589
+ automountServiceAccountToken: false,
590
+ },
591
+ {
592
+ apiVersion: 'v1', kind: 'ServiceAccount',
593
+ metadata: { ...metadata('workload'), name: WORKLOAD_SERVICE_ACCOUNT },
594
+ automountServiceAccountToken: false,
595
+ },
596
+ {
597
+ apiVersion: 'rbac.authorization.k8s.io/v1', kind: 'Role',
598
+ metadata: metadata('deployer'),
599
+ rules: [
600
+ {
601
+ apiGroups: [''],
602
+ resources: ['configmaps', 'endpoints', 'persistentvolumeclaims', 'pods', 'pods/log', 'secrets', 'services', 'serviceaccounts'],
603
+ verbs: ['get', 'list', 'watch', 'create', 'update', 'patch', 'delete'],
604
+ },
605
+ {
606
+ apiGroups: ['apps'],
607
+ resources: ['deployments', 'statefulsets', 'daemonsets', 'replicasets'],
608
+ verbs: ['get', 'list', 'watch', 'create', 'update', 'patch', 'delete'],
609
+ },
610
+ {
611
+ apiGroups: ['batch'],
612
+ resources: ['jobs', 'cronjobs'],
613
+ verbs: ['get', 'list', 'watch', 'create', 'update', 'patch', 'delete'],
614
+ },
615
+ {
616
+ apiGroups: ['networking.k8s.io'],
617
+ resources: ['ingresses'],
618
+ verbs: ['get', 'list', 'watch', 'create', 'update', 'patch', 'delete'],
619
+ },
620
+ {
621
+ apiGroups: ['autoscaling'], resources: ['horizontalpodautoscalers'],
622
+ verbs: ['get', 'list', 'watch', 'create', 'update', 'patch', 'delete'],
623
+ },
624
+ {
625
+ apiGroups: ['policy'], resources: ['poddisruptionbudgets'],
626
+ verbs: ['get', 'list', 'watch', 'create', 'update', 'patch', 'delete'],
627
+ },
628
+ ],
629
+ },
630
+ {
631
+ apiVersion: 'rbac.authorization.k8s.io/v1', kind: 'RoleBinding',
632
+ metadata: metadata('deployer'),
633
+ roleRef: { apiGroup: 'rbac.authorization.k8s.io', kind: 'Role', name: `${RESERVED_RESOURCE_PREFIX}deployer` },
634
+ subjects: [{ kind: 'ServiceAccount', name: DEPLOYER_SERVICE_ACCOUNT, namespace }],
635
+ },
636
+ {
637
+ apiVersion: 'v1', kind: 'ResourceQuota',
638
+ metadata: metadata('quota'),
639
+ spec: { hard: defaults.resourceQuota },
640
+ },
641
+ {
642
+ apiVersion: 'v1', kind: 'LimitRange',
643
+ metadata: metadata('limits'),
644
+ spec: {
645
+ limits: [{
646
+ type: 'Container',
647
+ default: defaults.limitRange.default,
648
+ defaultRequest: defaults.limitRange.defaultRequest,
649
+ max: defaults.limitRange.max,
650
+ }],
651
+ },
652
+ },
653
+ {
654
+ apiVersion: 'networking.k8s.io/v1', kind: 'NetworkPolicy',
655
+ metadata: metadata('default-deny'),
656
+ spec: { podSelector: {}, policyTypes: ['Ingress', 'Egress'] },
657
+ },
658
+ {
659
+ apiVersion: 'networking.k8s.io/v1', kind: 'NetworkPolicy',
660
+ metadata: metadata('same-namespace'),
661
+ spec: {
662
+ podSelector: {},
663
+ policyTypes: ['Ingress', 'Egress'],
664
+ ingress: [{ from: [{ podSelector: {} }] }],
665
+ egress: [{ to: [{ podSelector: {} }] }],
666
+ },
667
+ },
668
+ {
669
+ apiVersion: 'networking.k8s.io/v1', kind: 'NetworkPolicy',
670
+ metadata: metadata('dns'),
671
+ spec: {
672
+ podSelector: {},
673
+ policyTypes: ['Egress'],
674
+ egress: [{
675
+ to: [{ namespaceSelector: { matchLabels: { 'kubernetes.io/metadata.name': 'kube-system' } } }],
676
+ ports: [{ protocol: 'UDP', port: 53 }, { protocol: 'TCP', port: 53 }],
677
+ }],
678
+ },
679
+ },
680
+ {
681
+ apiVersion: 'networking.k8s.io/v1', kind: 'NetworkPolicy',
682
+ metadata: metadata('approved-egress'),
683
+ spec: {
684
+ podSelector: {},
685
+ policyTypes: ['Egress'],
686
+ egress: [
687
+ ...namespaceEgress,
688
+ {
689
+ to: [{
690
+ ipBlock: {
691
+ cidr: '0.0.0.0/0',
692
+ except: ['10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8', '169.254.0.0/16', '172.16.0.0/12', '192.168.0.0/16'],
693
+ },
694
+ }],
695
+ },
696
+ {
697
+ to: [{ ipBlock: { cidr: '::/0', except: ['fc00::/7', 'fe80::/10'] } }],
698
+ },
699
+ ],
700
+ },
701
+ },
702
+ ];
703
+ }
704
+ function namespaceResource(input) {
705
+ return {
706
+ apiVersion: 'v1',
707
+ kind: 'Namespace',
708
+ metadata: {
709
+ name: input.id,
710
+ labels: {
711
+ [KUBERNETES_MANAGED_BY_LABEL]: MANAGED_BY_VALUE,
712
+ [KUBERNETES_ENVIRONMENT_ID_LABEL]: input.id,
713
+ [KUBERNETES_OWNER_ID_LABEL]: labelValue(input.spec.ownerId),
714
+ [KUBERNETES_EXPIRES_AT_LABEL]: String(input.expiresAtSeconds),
715
+ 'pod-security.kubernetes.io/enforce': 'restricted',
716
+ 'pod-security.kubernetes.io/enforce-version': 'latest',
717
+ 'pod-security.kubernetes.io/audit': 'restricted',
718
+ 'pod-security.kubernetes.io/warn': 'restricted',
719
+ },
720
+ annotations: {
721
+ [KUBERNETES_OWNER_ID_ANNOTATION]: input.spec.ownerId,
722
+ [KUBERNETES_CUSTOMER_ID_ANNOTATION]: input.spec.customerId,
723
+ [KUBERNETES_REPOSITORY_ANNOTATION]: input.spec.repository,
724
+ [KUBERNETES_CONNECTION_ID_ANNOTATION]: input.connection.id,
725
+ [KUBERNETES_TARGET_ANNOTATION]: input.connection.target,
726
+ [KUBERNETES_CREATED_AT_ANNOTATION]: input.createdAt.toISOString(),
727
+ [KUBERNETES_ENDPOINTS_ANNOTATION]: JSON.stringify(input.descriptor.endpoints),
728
+ [KUBERNETES_CLUSTER_RESOURCES_ANNOTATION]: JSON.stringify(input.clusterResources.map(clusterResourceIdentity)),
729
+ },
730
+ },
731
+ };
732
+ }
733
+ function applyPlacementAndIdentity(resource, connection) {
734
+ const podSpec = podSpecFor(resource);
735
+ if (!podSpec)
736
+ return;
737
+ hardenPodSpec(resource, podSpec);
738
+ if (podSpec.serviceAccountName === DEPLOYER_SERVICE_ACCOUNT) {
739
+ throw new Error(`${resource.kind}/${resource.metadata?.name} may not run as Factory's deployer service account`);
740
+ }
741
+ const currentNodeSelector = objectRecord(podSpec.nodeSelector);
742
+ podSpec.nodeSelector = { ...currentNodeSelector, ...connection.nodeSelector };
743
+ const currentTolerations = Array.isArray(podSpec.tolerations) ? podSpec.tolerations : [];
744
+ podSpec.tolerations = [...currentTolerations, ...connection.tolerations];
745
+ if (podSpec.serviceAccountName === undefined || podSpec.serviceAccountName === 'default') {
746
+ podSpec.serviceAccountName = WORKLOAD_SERVICE_ACCOUNT;
747
+ }
748
+ if (podSpec.automountServiceAccountToken === undefined)
749
+ podSpec.automountServiceAccountToken = false;
750
+ }
751
+ function hardenPodSpec(resource, podSpec) {
752
+ const name = resource.metadata?.name ?? '<unnamed>';
753
+ for (const field of ['hostNetwork', 'hostPID', 'hostIPC']) {
754
+ if (podSpec[field] === true)
755
+ throw new Error(`${resource.kind}/${name} requests ${field}, which bypasses namespace isolation`);
756
+ }
757
+ const volumes = Array.isArray(podSpec.volumes) ? podSpec.volumes : [];
758
+ if (volumes.some((volume) => volume.hostPath !== undefined)) {
759
+ throw new Error(`${resource.kind}/${name} requests a hostPath volume, which bypasses namespace isolation`);
760
+ }
761
+ const podSecurityContext = objectRecord(podSpec.securityContext);
762
+ if (podSecurityContext.runAsNonRoot === false || podSecurityContext.runAsUser === 0) {
763
+ throw new Error(`${resource.kind}/${name} explicitly requests a root workload`);
764
+ }
765
+ podSpec.securityContext = {
766
+ ...podSecurityContext,
767
+ runAsNonRoot: podSecurityContext.runAsNonRoot ?? true,
768
+ seccompProfile: podSecurityContext.seccompProfile ?? { type: 'RuntimeDefault' },
769
+ };
770
+ for (const field of ['initContainers', 'containers', 'ephemeralContainers']) {
771
+ const containers = Array.isArray(podSpec[field]) ? podSpec[field] : [];
772
+ for (const container of containers) {
773
+ const containerName = typeof container.name === 'string' ? container.name : '<unnamed>';
774
+ const securityContext = objectRecord(container.securityContext);
775
+ if (securityContext.privileged === true || securityContext.allowPrivilegeEscalation === true) {
776
+ throw new Error(`${resource.kind}/${name} container ${containerName} requests privileged execution`);
777
+ }
778
+ if (securityContext.runAsNonRoot === false || securityContext.runAsUser === 0) {
779
+ throw new Error(`${resource.kind}/${name} container ${containerName} explicitly requests root execution`);
780
+ }
781
+ const capabilities = objectRecord(securityContext.capabilities);
782
+ const addedCapabilities = Array.isArray(capabilities.add) ? capabilities.add : [];
783
+ if (addedCapabilities.some((capability) => capability !== 'NET_BIND_SERVICE')) {
784
+ throw new Error(`${resource.kind}/${name} container ${containerName} requests unsafe Linux capabilities`);
785
+ }
786
+ const ports = Array.isArray(container.ports) ? container.ports : [];
787
+ if (ports.some((port) => port.hostPort !== undefined && port.hostPort !== 0)) {
788
+ throw new Error(`${resource.kind}/${name} container ${containerName} requests a host port`);
789
+ }
790
+ container.securityContext = {
791
+ ...securityContext,
792
+ allowPrivilegeEscalation: false,
793
+ capabilities: {
794
+ ...capabilities,
795
+ drop: [...new Set([...(Array.isArray(capabilities.drop) ? capabilities.drop : []), 'ALL'])],
796
+ },
797
+ };
798
+ }
799
+ }
800
+ }
801
+ function validateCustomerRbac(resource, namespace, renderedClusterRoles) {
802
+ const name = resource.metadata?.name ?? '<unnamed>';
803
+ if (resource.kind === 'Role' || resource.kind === 'ClusterRole') {
804
+ if (resource.kind === 'ClusterRole' && Object.prototype.hasOwnProperty.call(resource, 'aggregationRule')) {
805
+ throw new Error(`ClusterRole/${name} may not aggregate unreviewed cluster permissions`);
806
+ }
807
+ const rules = Array.isArray(resource.rules)
808
+ ? resource.rules
809
+ : [];
810
+ const protectedResources = new Set([
811
+ '*', 'networkpolicies', 'resourcequotas', 'limitranges', 'roles', 'rolebindings',
812
+ 'serviceaccounts/token',
813
+ ]);
814
+ const protectedClusterResources = new Set([
815
+ ...protectedResources,
816
+ 'clusterroles', 'clusterrolebindings', 'namespaces', 'nodes', 'persistentvolumes',
817
+ 'secrets', 'serviceaccounts', 'serviceaccounts/token', 'subjectaccessreviews', 'tokenreviews',
818
+ ]);
819
+ for (const rule of rules) {
820
+ const resources = Array.isArray(rule.resources) ? rule.resources : [];
821
+ const verbs = Array.isArray(rule.verbs) ? rule.verbs : [];
822
+ const apiGroups = Array.isArray(rule.apiGroups) ? rule.apiGroups : [];
823
+ const nonResourceURLs = Array.isArray(rule.nonResourceURLs) ? rule.nonResourceURLs : [];
824
+ const protectedSet = resource.kind === 'ClusterRole' ? protectedClusterResources : protectedResources;
825
+ if (resources.some((entry) => protectedSet.has(String(entry))) ||
826
+ verbs.some((entry) => entry === '*' || entry === 'impersonate' || entry === 'escalate' || entry === 'bind') ||
827
+ apiGroups.includes('*') ||
828
+ nonResourceURLs.length > 0) {
829
+ throw new Error(`${resource.kind}/${name} can mutate Factory guardrails or escalate privileges`);
830
+ }
831
+ }
832
+ }
833
+ if (resource.kind === 'RoleBinding') {
834
+ const record = resource;
835
+ const roleRef = objectRecord(record.roleRef);
836
+ const subjects = Array.isArray(record.subjects) ? record.subjects : [];
837
+ if (roleRef.kind !== 'Role' || roleRef.name === `${RESERVED_RESOURCE_PREFIX}deployer`) {
838
+ throw new Error(`RoleBinding/${name} may bind only a customer Role in the verification namespace`);
839
+ }
840
+ if (subjects.length === 0 ||
841
+ subjects.some((subject) => subject.kind !== 'ServiceAccount' ||
842
+ subject.namespace !== namespace ||
843
+ subject.name === DEPLOYER_SERVICE_ACCOUNT)) {
844
+ throw new Error(`RoleBinding/${name} may bind only customer service accounts in generated namespace ${namespace}`);
845
+ }
846
+ }
847
+ if (resource.kind === 'ClusterRoleBinding') {
848
+ const record = resource;
849
+ const roleRef = objectRecord(record.roleRef);
850
+ const subjects = Array.isArray(record.subjects) ? record.subjects : [];
851
+ if (roleRef.kind !== 'ClusterRole' ||
852
+ typeof roleRef.name !== 'string' ||
853
+ !renderedClusterRoles.has(roleRef.name)) {
854
+ throw new Error(`ClusterRoleBinding/${name} must bind a ClusterRole created by the same stack`);
855
+ }
856
+ if (subjects.length === 0 ||
857
+ subjects.some((subject) => subject.kind !== 'ServiceAccount' ||
858
+ subject.namespace !== namespace ||
859
+ subject.name === DEPLOYER_SERVICE_ACCOUNT)) {
860
+ throw new Error(`ClusterRoleBinding/${name} may bind only service accounts in generated namespace ${namespace}`);
861
+ }
862
+ }
863
+ }
864
+ function podSpecFor(resource) {
865
+ const record = resource;
866
+ const spec = objectRecord(record.spec);
867
+ if (resource.kind === 'Pod')
868
+ return spec;
869
+ if (['Deployment', 'StatefulSet', 'DaemonSet', 'ReplicaSet', 'Job'].includes(resource.kind ?? '')) {
870
+ return objectRecord(objectRecord(spec.template).spec);
871
+ }
872
+ if (resource.kind === 'CronJob') {
873
+ return objectRecord(objectRecord(objectRecord(objectRecord(spec.jobTemplate).spec).template).spec);
874
+ }
875
+ return undefined;
876
+ }
877
+ function customerPolicyAllowsEgress(resource) {
878
+ const spec = objectRecord(resource.spec);
879
+ const policyTypes = Array.isArray(spec.policyTypes) ? spec.policyTypes : [];
880
+ return Object.prototype.hasOwnProperty.call(spec, 'egress') || policyTypes.includes('Egress');
881
+ }
882
+ function assertOwnedNamespace(namespace, expectedId, connectionId) {
883
+ if (!isOwnedNamespace(namespace, expectedId, connectionId)) {
884
+ throw new Error(`Refusing to operate on namespace ${expectedId}: Factory ownership identity does not match`);
885
+ }
886
+ }
887
+ function isOwnedNamespace(namespace, expectedId, connectionId) {
888
+ return namespace.kind === 'Namespace' &&
889
+ namespace.metadata?.name === expectedId &&
890
+ expectedId.startsWith('factory-') &&
891
+ namespace.metadata?.labels?.[KUBERNETES_MANAGED_BY_LABEL] === MANAGED_BY_VALUE &&
892
+ namespace.metadata?.labels?.[KUBERNETES_ENVIRONMENT_ID_LABEL] === expectedId &&
893
+ namespace.metadata?.annotations?.[KUBERNETES_CONNECTION_ID_ANNOTATION] === connectionId;
894
+ }
895
+ function generatedNamespaceName(repository, suffix) {
896
+ const repoName = repository.split('/').at(-1) ?? 'verification';
897
+ const slug = repoName.toLowerCase().replace(/[^a-z0-9]+/gu, '-').replace(/^-+|-+$/gu, '') || 'verification';
898
+ const safeSuffix = suffix.toLowerCase().replace(/[^a-z0-9]+/gu, '').slice(0, 12) || randomUUID().replaceAll('-', '').slice(0, 10);
899
+ return `factory-${slug.slice(0, 42)}-${safeSuffix}`.slice(0, 63).replace(/-$/u, '');
900
+ }
901
+ function labelValue(value) {
902
+ const normalized = value.toLowerCase().replace(/[^a-z0-9_.-]+/gu, '-').replace(/^[^a-z0-9]+|[^a-z0-9]+$/gu, '');
903
+ return (normalized || 'unknown').slice(0, 63).replace(/[^a-z0-9]$/u, '0');
904
+ }
905
+ function endpointUrl(base, path) {
906
+ const url = new URL(base);
907
+ url.pathname = path;
908
+ return url.toString();
909
+ }
910
+ function parsePersistedEndpoints(value) {
911
+ if (!value)
912
+ return [];
913
+ try {
914
+ const parsed = JSON.parse(value);
915
+ const result = KubernetesStackDescriptorSchema.shape.endpoints.safeParse(parsed);
916
+ return result.success ? result.data : [];
917
+ }
918
+ catch {
919
+ return [];
920
+ }
921
+ }
922
+ function parsePersistedClusterResources(value) {
923
+ if (!value)
924
+ return [];
925
+ const parsed = JSON.parse(value);
926
+ if (!Array.isArray(parsed))
927
+ throw new Error('cluster resource annotation must be an array');
928
+ return parsed.map((resource) => clusterResourceIdentity(resource));
929
+ }
930
+ function clusterResourceIdentity(resource) {
931
+ if (!resource.apiVersion || !resource.kind || !resource.metadata?.name) {
932
+ throw new Error('Cluster-scoped resource identity requires apiVersion, kind, and metadata.name');
933
+ }
934
+ return {
935
+ apiVersion: resource.apiVersion,
936
+ kind: resource.kind,
937
+ metadata: { name: resource.metadata.name },
938
+ };
939
+ }
940
+ function isClusterScopedResource(resource, connection) {
941
+ const kind = resource.kind ?? '';
942
+ const qualifiedKind = `${resource.apiVersion ?? ''}/${kind}`;
943
+ return CLUSTER_SCOPED_KINDS.has(kind) ||
944
+ connection.allowedClusterScopedKinds.includes(kind) ||
945
+ connection.allowedClusterScopedKinds.includes(qualifiedKind);
946
+ }
947
+ function objectRecord(value) {
948
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
949
+ }
950
+ const cloneEnvironment = (environment) => ({
951
+ ...environment,
952
+ endpoints: { ...environment.endpoints },
953
+ bindings: { ...environment.bindings },
954
+ });
955
+ const errorMessage = (error) => error instanceof Error ? error.message : String(error);
956
+ //# sourceMappingURL=kubernetes-provider.js.map