@serve.zone/interfaces 32.61.0 → 32.62.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.
- package/changelog.md +17 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/data/coremail.d.ts +37 -1
- package/dist_ts/data/coremail.js +23 -1
- package/dist_ts/data/coremail.runtime.d.ts +10 -1
- package/dist_ts/data/coremail.runtime.js +23 -1
- package/dist_ts/data/index.d.ts +1 -0
- package/dist_ts/data/index.js +2 -1
- package/dist_ts/data/mail.d.ts +36 -0
- package/dist_ts/data/mail.js +45 -2
- package/dist_ts/data/runtimeassignment.d.ts +4 -1
- package/dist_ts/data/runtimeassignment.js +5 -2
- package/dist_ts/data/serviceruntime.d.ts +14 -1
- package/dist_ts/data/serviceruntime.js +2 -2
- package/dist_ts/data/settings.d.ts +21 -0
- package/dist_ts/data/settings.js +38 -1
- package/dist_ts/data/workloadreplicaidentity.d.ts +101 -0
- package/dist_ts/data/workloadreplicaidentity.js +159 -0
- package/dist_ts/requests/coremail.d.ts +6 -1
- package/package.json +1 -1
- package/readme.md +153 -7
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/data/coremail.runtime.ts +26 -1
- package/ts/data/coremail.ts +42 -1
- package/ts/data/index.ts +1 -0
- package/ts/data/mail.ts +48 -0
- package/ts/data/runtimeassignment.ts +4 -1
- package/ts/data/serviceruntime.ts +14 -1
- package/ts/data/settings.ts +46 -0
- package/ts/data/workloadreplicaidentity.ts +219 -0
- package/ts/requests/coremail.ts +6 -1
package/ts/data/settings.ts
CHANGED
|
@@ -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
|
+
};
|
package/ts/requests/coremail.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|