@serve.zone/interfaces 32.61.0 → 32.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/changelog.md +25 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/data/clusteringress.d.ts +7 -1
  4. package/dist_ts/data/clusteringress.js +15 -1
  5. package/dist_ts/data/coremail.d.ts +37 -1
  6. package/dist_ts/data/coremail.js +23 -1
  7. package/dist_ts/data/coremail.runtime.d.ts +10 -1
  8. package/dist_ts/data/coremail.runtime.js +23 -1
  9. package/dist_ts/data/index.d.ts +2 -0
  10. package/dist_ts/data/index.js +3 -1
  11. package/dist_ts/data/mail.d.ts +36 -0
  12. package/dist_ts/data/mail.js +45 -2
  13. package/dist_ts/data/runtimeassignment.d.ts +28 -1
  14. package/dist_ts/data/runtimeassignment.js +22 -3
  15. package/dist_ts/data/runtimesessionpush.d.ts +24 -0
  16. package/dist_ts/data/runtimesessionpush.js +25 -0
  17. package/dist_ts/data/serviceruntime.d.ts +14 -1
  18. package/dist_ts/data/serviceruntime.js +2 -2
  19. package/dist_ts/data/settings.d.ts +21 -0
  20. package/dist_ts/data/settings.js +38 -1
  21. package/dist_ts/data/workloadreplicaidentity.d.ts +101 -0
  22. package/dist_ts/data/workloadreplicaidentity.js +159 -0
  23. package/dist_ts/requests/coremail.d.ts +6 -1
  24. package/package.json +1 -1
  25. package/readme.md +182 -10
  26. package/ts/00_commitinfo_data.ts +1 -1
  27. package/ts/data/clusteringress.ts +14 -0
  28. package/ts/data/coremail.runtime.ts +26 -1
  29. package/ts/data/coremail.ts +42 -1
  30. package/ts/data/index.ts +2 -0
  31. package/ts/data/mail.ts +48 -0
  32. package/ts/data/runtimeassignment.ts +37 -2
  33. package/ts/data/runtimesessionpush.ts +24 -0
  34. package/ts/data/serviceruntime.ts +14 -1
  35. package/ts/data/settings.ts +46 -0
  36. package/ts/data/workloadreplicaidentity.ts +219 -0
  37. package/ts/requests/coremail.ts +6 -1
package/ts/data/mail.ts CHANGED
@@ -338,6 +338,54 @@ export interface IServiceMailConfig {
338
338
  /** Caller-writable mail configuration. CoreMail binding metadata is excluded. */
339
339
  export type TServiceMailWritableConfig = Omit<IServiceMailConfig, 'coreMail'>;
340
340
 
341
+ /**
342
+ * The environment names a service's default sender is delivered under, one spelling for the
343
+ * provisioners (Cloudly, Onebox) and the reader (`@serve.zone/platformclient`).
344
+ *
345
+ * Three groups, each delivered whole: the typed dcrouter-direct binding (`from`, `typedUrl`,
346
+ * `apiCredentialId`, `apiCredentialSecret`; `typedUrl` is `ICloudlySettings.dcrouterMailEndpointUrl`),
347
+ * its SMTP compatibility surface (`smtp*`), and a CoreMail binding (`coreMail*`, with `from`).
348
+ * `apiCredentialSecret`, `smtpPassword` and `coreMailCredentialSecret` are secrets and are
349
+ * delivered as secrets. Every further sender address uses the same names scoped to its address
350
+ * token (`createServiceMailAddressEnvironmentNames`).
351
+ */
352
+ export const serviceMailEnvironmentNames = Object.freeze({
353
+ from: 'MAIL_FROM',
354
+ typedUrl: 'MAIL_TYPED_URL',
355
+ apiCredentialId: 'MAIL_API_CREDENTIAL_ID',
356
+ apiCredentialSecret: 'MAIL_API_CREDENTIAL_SECRET',
357
+ smtpHost: 'SMTP_HOST',
358
+ smtpPort: 'SMTP_PORT',
359
+ smtpTlsMode: 'SMTP_TLS_MODE',
360
+ smtpUsername: 'SMTP_USERNAME',
361
+ smtpPassword: 'SMTP_PASSWORD',
362
+ coreMailUrl: 'MAIL_COREMAIL_URL',
363
+ coreMailBindingId: 'MAIL_COREMAIL_BINDING_ID',
364
+ coreMailCredentialId: 'MAIL_COREMAIL_CREDENTIAL_ID',
365
+ coreMailCredentialVersionNumber: 'MAIL_COREMAIL_CREDENTIAL_VERSION',
366
+ coreMailCredentialSecret: 'MAIL_COREMAIL_CREDENTIAL_SECRET',
367
+ } as const);
368
+
369
+ export type TServiceMailEnvironmentNames = Readonly<Record<keyof typeof serviceMailEnvironmentNames, string>>;
370
+
371
+ /**
372
+ * The names one further sender address is delivered under: each of `serviceMailEnvironmentNames`
373
+ * with `MAIL_<token>_` in place of its `MAIL_` prefix, or before it where it has none
374
+ * (`MAIL_<token>_FROM`, `MAIL_<token>_TYPED_URL`, `MAIL_<token>_SMTP_HOST`,
375
+ * `MAIL_<token>_COREMAIL_URL`). The token is the address's upper-case token
376
+ * (`^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$`); any other token is the caller's error and is thrown.
377
+ */
378
+ export const createServiceMailAddressEnvironmentNames = (addressTokenArg: string): TServiceMailEnvironmentNames => {
379
+ if (typeof addressTokenArg !== 'string' || !/^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$/.test(addressTokenArg)) {
380
+ throw new Error('service mail address token must be an upper-case address token');
381
+ }
382
+ const scoped: Record<string, string> = {};
383
+ for (const [key, name] of Object.entries(serviceMailEnvironmentNames)) {
384
+ scoped[key] = `MAIL_${addressTokenArg}_${name.startsWith('MAIL_') ? name.slice('MAIL_'.length) : name}`;
385
+ }
386
+ return Object.freeze(scoped) as TServiceMailEnvironmentNames;
387
+ };
388
+
341
389
  export interface IServiceMailTargetAddressSummary {
342
390
  address: string;
343
391
  inbound: {
@@ -39,6 +39,19 @@ export type TRuntimeAssignmentDisposition =
39
39
  | 'stop-preserve'
40
40
  | 'remove-runtime-preserve-storage';
41
41
 
42
+ /**
43
+ * What a cluster designates the service of an attempt as, stated by the controller so a node can
44
+ * order its work by the service's role instead of by the order the deliveries arrived in.
45
+ *
46
+ * `cluster-ingress`: the service is its cluster's designated ingress, the service Cloudly lists as
47
+ * designated (`IClusterIngressDesignation` for a cluster born on Pallet,
48
+ * `IFleetCutoverIngressDesignation` after a Swarm cutover) and delivers first, because a cluster
49
+ * serves nothing until its ingress runs. A node begins such a run before every other run it holds.
50
+ */
51
+ export const runtimeAssignmentDesignations = ['cluster-ingress'] as const;
52
+
53
+ export type TRuntimeAssignmentDesignation = (typeof runtimeAssignmentDesignations)[number];
54
+
42
55
  export interface IRuntimeAssignment extends IRuntimeAssignmentScope {
43
56
  assignmentId: string;
44
57
  serviceId: string;
@@ -55,6 +68,19 @@ export interface IRuntimeAssignment extends IRuntimeAssignmentScope {
55
68
  disposition: TRuntimeAssignmentDisposition;
56
69
  /** No automatic relocation or expiry-based claim of a stopped writer. */
57
70
  offlinePolicy: 'continue-node-bound';
71
+ /**
72
+ * The cluster's designation of this attempt's service (`runtimeAssignmentDesignations`); absent
73
+ * for a service the cluster designates nothing. It is part of the attempt, as every member but
74
+ * `generation`, `previous`, `disposition` and `digest` is: every later revision of the attempt
75
+ * states the same value, so a designation that moves to another service reaches the nodes with
76
+ * the services' next attempts. It orders a node's work and grants nothing: no authority,
77
+ * placement, credential or network access follows from it.
78
+ *
79
+ * A reader built before this member refuses an assignment that states it (exact keys), so a
80
+ * controller states it only on a session whose offer speaks the release that added it or a later
81
+ * one, and states nothing on any other.
82
+ */
83
+ designation?: TRuntimeAssignmentDesignation;
58
84
  workload: {
59
85
  /**
60
86
  * The registry that published this image, which is the publication identity the image release
@@ -168,7 +194,10 @@ export type TRuntimeImagePullRefusalReason = typeof runtimeImagePullRefusalReaso
168
194
  * Why a node refused to start a run before any runtime effect of it, as the node names it:
169
195
  * `unsupported-readiness` a readiness probe the run's network cannot carry (a TCP or HTTP probe of
170
196
  * a run without network authority), `unresolved-authority` a workload authority the node cannot
171
- * resolve, and `invalid` an assignment or configuration the node cannot read.
197
+ * resolve, and `invalid` an assignment or configuration the node cannot read or cannot run as stated,
198
+ * such as a config environment that states a name the runtime injects
199
+ * (`composeWorkloadReplicaEnvironment` answering `workload-identity-environment-conflict`, a
200
+ * service-status reason only Cloudly states).
172
201
  */
173
202
  export const runtimeAssignmentRefusalReasons = ['unsupported-readiness', 'unresolved-authority', 'invalid'] as const;
174
203
  export type TRuntimeAssignmentRefusalReason = typeof runtimeAssignmentRefusalReasons[number];
@@ -317,15 +346,21 @@ const assignmentKeys = [
317
346
  'assignmentId', 'serviceId', 'replicaId', 'attemptId', 'workerGroupId', 'siteId',
318
347
  'generation', 'previous', 'disposition', 'offlinePolicy', 'workload', 'digest',
319
348
  ] as const;
349
+ /** Stated only for a service its cluster designates (`runtimeAssignmentDesignations`). */
350
+ const optionalAssignmentKeys = ['designation'] as const;
320
351
 
321
352
  const assertAssignment = (valueArg: unknown): IRuntimeAssignment => {
322
- if (!isExactRecord(valueArg, assignmentKeys)
353
+ if (!isExactRecord(valueArg, assignmentKeys, optionalAssignmentKeys)
323
354
  || !isController(valueArg.controller)
324
355
  || !isPositiveInteger(valueArg.generation) || !isDigest(valueArg.digest)
325
356
  || valueArg.offlinePolicy !== 'continue-node-bound'
326
357
  || !['run', 'stop-preserve', 'remove-runtime-preserve-storage'].includes(
327
358
  valueArg.disposition as string,
328
359
  )) fail('runtime assignment must use its exact schema');
360
+ if (Object.hasOwn(valueArg, 'designation')
361
+ && !runtimeAssignmentDesignations.some((designationArg) => designationArg === valueArg.designation)) {
362
+ fail('runtime assignment designation must be a stated designation');
363
+ }
329
364
  for (const key of ['organizationId', 'runtimeNamespace', 'assignmentId', 'serviceId', 'attemptId']) {
330
365
  if (!isIdentifier(valueArg[key])) fail(`runtime assignment ${key} must be a canonical identifier`);
331
366
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * How many requests a controller holds outstanding on one node's runtime session at once.
3
+ *
4
+ * Counted per node, never per lane, method or carrier: every request a controller sends a node over
5
+ * its runtime session counts — `applyRuntimeAssignment`, `applyRuntimeLocalStorageClaim`,
6
+ * `applyRuntimeNetworkSigningAuthority`, `applyRuntimeNetworkProjection`,
7
+ * `applyRuntimeNetworkDnsLeaseRenewal` and `readRuntimeAssignmentStats` together, whatever pass or
8
+ * lane of the controller sends it — from the moment it is sent until its answer or its failure
9
+ * arrived. One relay peer carries a whole cluster, so the count is each node's own, not the peer's.
10
+ * A relay adds no request of its own while the controller's session is up (its DNS lease escrow
11
+ * offers one renewal per node, and only while that session is down), so it stays within the same
12
+ * count.
13
+ *
14
+ * A node holds this many at once beside the answers to its own requests and the cancellation each
15
+ * of them may draw, and never closes its session over them; it answers each within the relay's
16
+ * forward timeout for a push to a node (coreflow's `nodeForwardMs`, 8 s; not
17
+ * `runtimeSessionRequestBounds.relaySessionForwardMs`, which bounds the node's own requests to the
18
+ * controller), so a request the controller stopped waiting for is no longer held on the node. A
19
+ * controller that sends more than this may lose the node's session: the node's transport bounds
20
+ * what it queues and closes the connection past that bound.
21
+ */
22
+ export const runtimeSessionPushBounds = Object.freeze({
23
+ maximumInFlightPerNode: 8,
24
+ } as const);
@@ -93,7 +93,7 @@ const serviceRuntimeRefusals = [
93
93
  'objectstorage-endpoint-missing', 'objectstorage-binding-invalid', 'objectstorage-environment-conflict',
94
94
  'objectstorage-endpoint-unselected', 'database-endpoint-missing', 'database-endpoint-unselected',
95
95
  'published-port-reserved', 'published-port-symmetric-without-egress', 'readiness-network-unsupported',
96
- 'cluster-relay-missing', 'cluster-ingress-environment-conflict',
96
+ 'cluster-relay-missing', 'cluster-ingress-environment-conflict', 'workload-identity-environment-conflict',
97
97
  ] as const;
98
98
 
99
99
  /**
@@ -183,6 +183,19 @@ export type TServiceRuntimeWaitingReason = typeof serviceRuntimeWaitingReasons[n
183
183
  * `cluster-ingress-environment-conflict` is an ingress service whose own environment already sets
184
184
  * one of `clusterIngressEnvironmentNames`: Cloudly owns those names, and a service value would
185
185
  * otherwise silently replace Cloudly's or be replaced by it.
186
+ *
187
+ * `workload-identity-environment-conflict` is a service whose effective environment, or one of
188
+ * whose launcher-environment secrets, states one of `workloadReplicaIdentityEnvironmentNames`
189
+ * (`findWorkloadReplicaIdentityEnvironmentConflicts`): every runtime injects those names into every
190
+ * workload (`composeWorkloadReplicaEnvironment`), so a service value would silently replace the
191
+ * replica's identity or be replaced by it. Cloudly alone states it, and only in this status, which
192
+ * it stores and answers on `getServiceRuntimeSpec`, `getServiceRuntimeSpecForCluster` and
193
+ * `getServiceRuntimeSpecs`. Those requests carry no protocol offer, so the reason cannot be gated
194
+ * on a peer's version, and no other refusal names this check. A reader built before the release
195
+ * that added it refuses such a status whole, so every reader of statuses moves to that release
196
+ * before Cloudly does, and a Cloudly that has stored one is not rolled back below it. A node never
197
+ * states it: a Pallet whose composition refuses reports the run as the existing
198
+ * `TRuntimeAssignmentFailureCause` `refused` with `invalid`.
186
199
  */
187
200
  export type TServiceRuntimeRefusal = typeof serviceRuntimeRefusals[number];
188
201
 
@@ -52,6 +52,15 @@ export interface ICloudlySettings {
52
52
  dcrouterMailSubmissionHost?: string;
53
53
  dcrouterMailSubmissionPort?: number;
54
54
  dcrouterMailSubmissionTlsMode?: 'plain' | 'starttls' | 'implicitTls';
55
+ /**
56
+ * The canonical HTTPS URL of dcrouter's tenant mail listener, the endpoint a service's typed
57
+ * dcrouter-direct mail binding connects to. Cloudly writes it, and only it, into the binding's
58
+ * `MAIL_TYPED_URL` and `MAIL_<TOKEN>_TYPED_URL` (`serviceMailEnvironmentNames`); never
59
+ * `dcrouterGatewayUrl` and never a CoreMail endpoint. Absent: Cloudly refuses to provision a
60
+ * typed dcrouter-direct mail binding and names this key, with no fallback. A value is accepted
61
+ * only when `validateDcrouterMailEndpointUrl` answers no error.
62
+ */
63
+ dcrouterMailEndpointUrl?: string;
55
64
  dcrouterMailForwardTargetHost?: string;
56
65
  dcrouterMailForwardTargetHostsByNode?: Record<string, string>;
57
66
  /** Whether this Cloudly reconciles a CoreMail workload at all. */
@@ -81,6 +90,43 @@ export interface ICloudlySettings {
81
90
  appStoreCatalogRegistries?: string[];
82
91
  }
83
92
 
93
+ /** The longest `dcrouterMailEndpointUrl` Cloudly accepts, in characters. */
94
+ export const dcrouterMailEndpointUrlMaximumLength = 2_048;
95
+
96
+ /**
97
+ * Every reason a value is not a `dcrouterMailEndpointUrl`; empty when it is one.
98
+ *
99
+ * An `https:` URL without credentials, query or fragment, at most
100
+ * `dcrouterMailEndpointUrlMaximumLength` characters, written exactly as `new URL(value).href`
101
+ * spells it (`https://mail.example.com/`), so two spellings of one endpoint never compare unequal.
102
+ * Plain `http:` and `wss:` are refused: the binding's credential travels to this endpoint. No
103
+ * reason quotes the value.
104
+ */
105
+ export const validateDcrouterMailEndpointUrl = (valueArg: unknown): string[] => {
106
+ if (typeof valueArg !== 'string' || valueArg.length === 0
107
+ || valueArg.length > dcrouterMailEndpointUrlMaximumLength) {
108
+ return [`dcrouterMailEndpointUrl must be a URL of 1 to ${dcrouterMailEndpointUrlMaximumLength} characters`];
109
+ }
110
+ let url: URL;
111
+ try {
112
+ url = new URL(valueArg);
113
+ } catch {
114
+ return ['dcrouterMailEndpointUrl must be an absolute URL'];
115
+ }
116
+ const errors: string[] = [];
117
+ if (url.protocol !== 'https:') errors.push('dcrouterMailEndpointUrl must use https');
118
+ if (url.username !== '' || url.password !== '') {
119
+ errors.push('dcrouterMailEndpointUrl must not carry credentials');
120
+ }
121
+ if (valueArg.includes('?') || valueArg.includes('#')) {
122
+ errors.push('dcrouterMailEndpointUrl must not carry a query or fragment');
123
+ }
124
+ if (errors.length === 0 && url.href !== valueArg) {
125
+ errors.push('dcrouterMailEndpointUrl must be written in its canonical URL form');
126
+ }
127
+ return errors;
128
+ };
129
+
84
130
  export const cloudlySystemSecretOwner = Object.freeze({
85
131
  kind: 'system',
86
132
  systemId: 'cloudly-settings',
@@ -0,0 +1,219 @@
1
+ import type { TSha256Digest } from './immutableimage.js';
2
+ import { isSha256Digest } from './immutableimage.js';
3
+ import type { IRuntimeAssignment } from './runtimeassignment.js';
4
+ import { isRuntimeReplicaId, snapshotRuntimeAssignment } from './runtimeassignment.js';
5
+ import { runtimeConfigContract } from './runtimeconfig.js';
6
+ import type { TServiceRuntimeRefusal } from './serviceruntime.js';
7
+ import { hasExactKeys, readDataProperties } from '../private/dataproperties.js';
8
+
9
+ /**
10
+ * Which replica of which workload a process is, as its runtime states it.
11
+ *
12
+ * Every member is the runtime assignment's own (`buildWorkloadReplicaIdentity`), so a workload, its
13
+ * runtime and its controller name a replica with the same four values. `attemptId` is the one
14
+ * execution attempt: a rollout, a replacement or a restart onto another attempt is a new attempt,
15
+ * so the attempt is what a workload reports to correlate itself with the controller's roster.
16
+ */
17
+ export interface IWorkloadReplicaIdentity {
18
+ /** The service the replica runs (`IRuntimeAssignment.serviceId`). */
19
+ serviceId: string;
20
+ /** The stable logical replica (`IRuntimeAssignment.replicaId`), a `isRuntimeReplicaId` name. */
21
+ replicaId: string;
22
+ /** The one execution attempt (`IRuntimeAssignment.attemptId`). */
23
+ attemptId: string;
24
+ /** The root descriptor digest the replica runs (`IRuntimeAssignment.workload.imageDigest`). */
25
+ imageDigest: TSha256Digest;
26
+ }
27
+
28
+ /**
29
+ * The environment variable names every runtime injects into every workload it runs, one per
30
+ * identity member. The one spelling Pallet, Onebox, Cloudly's composition refusal and every
31
+ * workload that reads its identity share.
32
+ */
33
+ export const workloadReplicaIdentityEnvironmentNames = Object.freeze({
34
+ serviceId: 'SERVEZONE_WORKLOAD_SERVICE_ID',
35
+ replicaId: 'SERVEZONE_WORKLOAD_REPLICA_ID',
36
+ attemptId: 'SERVEZONE_WORKLOAD_ATTEMPT_ID',
37
+ imageDigest: 'SERVEZONE_WORKLOAD_IMAGE_DIGEST',
38
+ } as const satisfies Readonly<Record<keyof IWorkloadReplicaIdentity, string>>);
39
+
40
+ export type TWorkloadReplicaIdentityEnvironmentName =
41
+ typeof workloadReplicaIdentityEnvironmentNames[keyof IWorkloadReplicaIdentity];
42
+
43
+ const identityKeys = Object.freeze(
44
+ Object.keys(workloadReplicaIdentityEnvironmentNames) as (keyof IWorkloadReplicaIdentity)[],
45
+ );
46
+ const identityEnvironmentNames: readonly string[] = Object.freeze(
47
+ Object.values(workloadReplicaIdentityEnvironmentNames),
48
+ );
49
+
50
+ /**
51
+ * The bounds the injection adds to a runtime config's own.
52
+ *
53
+ * `IRuntimeConfig.environment` keeps its own bound (`runtimeConfigContract.maximumEnvironmentEntries`),
54
+ * so a service states at most that many entries. A runtime's executor admits the config's entries
55
+ * plus the injected identity, `maximumExecutorEnvironmentEntries`, computed from both, never a
56
+ * literal of its own.
57
+ */
58
+ export const workloadReplicaIdentityContract = Object.freeze({
59
+ injectedEnvironmentEntries: identityKeys.length,
60
+ maximumExecutorEnvironmentEntries: runtimeConfigContract.maximumEnvironmentEntries + identityKeys.length,
61
+ });
62
+
63
+ const isIdentifier = (valueArg: unknown): valueArg is string =>
64
+ typeof valueArg === 'string' && /^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/.test(valueArg);
65
+
66
+ /** What each member must hold, in the runtime assignment's own vocabulary. */
67
+ const memberRules: Readonly<Record<keyof IWorkloadReplicaIdentity, {
68
+ accepts: (valueArg: unknown) => boolean;
69
+ requirement: string;
70
+ }>> = Object.freeze({
71
+ serviceId: { accepts: isIdentifier, requirement: 'a canonical identifier' },
72
+ replicaId: { accepts: isRuntimeReplicaId, requirement: 'a replica id' },
73
+ attemptId: { accepts: isIdentifier, requirement: 'a canonical identifier' },
74
+ imageDigest: {
75
+ accepts: (valueArg: unknown) => typeof valueArg === 'string' && isSha256Digest(valueArg),
76
+ requirement: 'a sha256 digest',
77
+ },
78
+ });
79
+
80
+ const judgeIdentity = (valueArg: unknown): { record: Record<string, unknown> | null; errors: string[] } => {
81
+ const record = readDataProperties(valueArg);
82
+ if (record === null) {
83
+ return { record, errors: ['workload replica identity must be a plain record of data properties'] };
84
+ }
85
+ const errors: string[] = [];
86
+ if (!hasExactKeys(record, identityKeys)) {
87
+ errors.push(`workload replica identity must state exactly ${identityKeys.join(', ')}`);
88
+ }
89
+ for (const key of identityKeys) {
90
+ if (Object.hasOwn(record, key) && !memberRules[key].accepts(record[key])) {
91
+ errors.push(`${key} must be ${memberRules[key].requirement}`);
92
+ }
93
+ }
94
+ return { record, errors };
95
+ };
96
+
97
+ /** Every reason a value is not exactly a workload replica identity; empty when it is one. */
98
+ export const validateWorkloadReplicaIdentity = (valueArg: unknown): string[] => judgeIdentity(valueArg).errors;
99
+
100
+ /** A detached, frozen copy of exactly a workload replica identity; throws naming every violation. */
101
+ export const snapshotWorkloadReplicaIdentity = (valueArg: unknown): IWorkloadReplicaIdentity => {
102
+ const { record, errors } = judgeIdentity(valueArg);
103
+ if (record === null || errors.length > 0) {
104
+ throw new Error(`workload replica identity refused: ${errors.join('; ')}`);
105
+ }
106
+ return Object.freeze({
107
+ serviceId: record.serviceId as string,
108
+ replicaId: record.replicaId as string,
109
+ attemptId: record.attemptId as string,
110
+ imageDigest: record.imageDigest as TSha256Digest,
111
+ });
112
+ };
113
+
114
+ /**
115
+ * The identity of the replica one runtime assignment runs.
116
+ *
117
+ * Read from the assignment the runtime admitted, through its exact schema, so the answer always
118
+ * satisfies `validateWorkloadReplicaIdentity`. It proves nothing about the assignment's digest or
119
+ * admission; the runtime has judged those before it runs anything.
120
+ */
121
+ export const buildWorkloadReplicaIdentity = (assignmentArg: IRuntimeAssignment): IWorkloadReplicaIdentity => {
122
+ const assignment = snapshotRuntimeAssignment(assignmentArg);
123
+ return Object.freeze({
124
+ serviceId: assignment.serviceId,
125
+ replicaId: assignment.replicaId,
126
+ attemptId: assignment.attemptId,
127
+ imageDigest: assignment.workload.imageDigest,
128
+ });
129
+ };
130
+
131
+ /**
132
+ * The injected names among `namesArg`, sorted and unique: empty when none collides.
133
+ *
134
+ * Cloudly's composition refusal passes the keys of a replica's effective environment together with
135
+ * the variable names of its launcher-environment secrets, because WorkloadInit sets a secret over a
136
+ * plain variable of the same name and so would replace the injected identity just as silently.
137
+ */
138
+ export const findWorkloadReplicaIdentityEnvironmentConflicts = (
139
+ namesArg: readonly string[],
140
+ ): TWorkloadReplicaIdentityEnvironmentName[] => identityEnvironmentNames
141
+ .filter((nameArg) => namesArg.includes(nameArg))
142
+ .sort() as TWorkloadReplicaIdentityEnvironmentName[];
143
+
144
+ /** The one reason a workload environment cannot carry its replica identity. */
145
+ export type TWorkloadReplicaEnvironmentRefusal = Extract<
146
+ TServiceRuntimeRefusal,
147
+ 'workload-identity-environment-conflict'
148
+ >;
149
+
150
+ /** What a replica runs with, or the one reason its identity cannot be injected. */
151
+ export type TWorkloadReplicaEnvironmentComposition =
152
+ | { status: 'composed'; environment: Record<string, string> }
153
+ | { status: 'refused'; reason: TWorkloadReplicaEnvironmentRefusal; message: string };
154
+
155
+ /**
156
+ * The environment one replica runs with: its runtime config's environment with the replica's
157
+ * identity added under `workloadReplicaIdentityEnvironmentNames`.
158
+ *
159
+ * Every runtime injects the identity into every workload, whether the workload reads it or not. A
160
+ * config environment that already states one of the names is refused
161
+ * `workload-identity-environment-conflict`, naming every such key and none of their values, rather
162
+ * than having one value silently replace the other. The reason stays inside the runtime: only
163
+ * Cloudly states it on the wire, in a service's status. A Pallet reports the refused run as the
164
+ * existing `TRuntimeAssignmentFailureCause` `{ kind: 'refused', reason: 'invalid' }`, which every
165
+ * reader since 32.45.0 reads and a controller never re-places with an identical configuration. An
166
+ * identity that is not exactly one is the caller's error and is thrown. The inputs are not
167
+ * changed. A config within
168
+ * `runtimeConfigContract.maximumEnvironmentEntries` composes to at most
169
+ * `workloadReplicaIdentityContract.maximumExecutorEnvironmentEntries` entries.
170
+ */
171
+ export const composeWorkloadReplicaEnvironment = (
172
+ environmentArg: Readonly<Record<string, string>>,
173
+ identityArg: IWorkloadReplicaIdentity,
174
+ ): TWorkloadReplicaEnvironmentComposition => {
175
+ const identity = snapshotWorkloadReplicaIdentity(identityArg);
176
+ const conflicts = findWorkloadReplicaIdentityEnvironmentConflicts(Object.keys(environmentArg));
177
+ if (conflicts.length > 0) {
178
+ return { status: 'refused', reason: 'workload-identity-environment-conflict',
179
+ message: `workload environment already states ${conflicts.join(', ')}, which the runtime injects` };
180
+ }
181
+ const injected: Record<string, string> = {};
182
+ for (const key of identityKeys) {
183
+ injected[workloadReplicaIdentityEnvironmentNames[key]] = identity[key];
184
+ }
185
+ return { status: 'composed', environment: { ...environmentArg, ...injected } };
186
+ };
187
+
188
+ /**
189
+ * The replica identity a workload reads from its own environment (`process.env`).
190
+ *
191
+ * Reads the four injected names only, as own data properties, and nothing else of the environment.
192
+ * Throws naming every name that is missing and every name whose value is not a valid member, never
193
+ * a value, so a workload refuses to start with the exact variable its runtime did not inject.
194
+ */
195
+ export const readWorkloadReplicaIdentityEnvironment = (
196
+ environmentArg: Readonly<Record<string, string | undefined>>,
197
+ ): IWorkloadReplicaIdentity => {
198
+ const missing: string[] = [];
199
+ const invalid: string[] = [];
200
+ const identity: Record<string, string> = {};
201
+ for (const key of identityKeys) {
202
+ const name = workloadReplicaIdentityEnvironmentNames[key];
203
+ const descriptor = Object.getOwnPropertyDescriptor(environmentArg, name);
204
+ if (descriptor === undefined || !Object.hasOwn(descriptor, 'value') || typeof descriptor.value !== 'string') {
205
+ missing.push(name);
206
+ } else if (!memberRules[key].accepts(descriptor.value)) {
207
+ invalid.push(name);
208
+ } else {
209
+ identity[key] = descriptor.value;
210
+ }
211
+ }
212
+ if (missing.length > 0 || invalid.length > 0) {
213
+ throw new Error(`workload replica identity environment ${[
214
+ ...missing.length > 0 ? [`lacks ${missing.join(', ')}`] : [],
215
+ ...invalid.length > 0 ? [`states an invalid ${invalid.join(', ')}`] : [],
216
+ ].join('; ')}`);
217
+ }
218
+ return snapshotWorkloadReplicaIdentity(identity);
219
+ };
@@ -18,6 +18,7 @@ import type {
18
18
  TCoreMailWorkloadOperation,
19
19
  } from '../data/coremail.js';
20
20
  import type { IMailConnectionInfo } from '../data/mail.js';
21
+ import type { IWorkloadReplicaIdentity } from '../data/workloadreplicaidentity.js';
21
22
 
22
23
  export interface IReq_CoreMailAuthenticateWorkload extends plugins.typedrequestInterfaces.implementsTR<
23
24
  plugins.typedrequestInterfaces.ITypedRequest,
@@ -186,7 +187,11 @@ export interface IReq_CoreMailAuthenticateControl extends plugins.typedrequestIn
186
187
  authenticated: true;
187
188
  credentialId: string;
188
189
  credentialVersion: number;
189
- replica: ICoreMailReplicaIdentity;
190
+ /**
191
+ * The authenticated replica: `IWorkloadReplicaIdentity` from CoreMail 34 on, the deprecated
192
+ * `ICoreMailReplicaIdentity` from an earlier CoreMail (`normalizeCoreMailReplicaIdentity`).
193
+ */
194
+ replica: IWorkloadReplicaIdentity | ICoreMailReplicaIdentity;
190
195
  };
191
196
  }
192
197