@serve.zone/interfaces 32.58.0 → 32.60.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.
@@ -8,7 +8,7 @@ export interface IReq_GetRegistryById extends plugins.typedrequestInterfaces.imp
8
8
  id: string;
9
9
  };
10
10
  response: {
11
- registry: data.IExternalRegistry;
11
+ registry: data.IExternalRegistryPublic;
12
12
  };
13
13
  }
14
14
  export interface IReq_GetRegistries extends plugins.typedrequestInterfaces.implementsTR<plugins.typedrequestInterfaces.ITypedRequest, IReq_GetRegistries> {
@@ -17,7 +17,7 @@ export interface IReq_GetRegistries extends plugins.typedrequestInterfaces.imple
17
17
  identity: userInterfaces.IIdentity;
18
18
  };
19
19
  response: {
20
- registries: data.IExternalRegistry[];
20
+ registries: data.IExternalRegistryPublic[];
21
21
  };
22
22
  }
23
23
  export interface IReq_CreateRegistry extends plugins.typedrequestInterfaces.implementsTR<plugins.typedrequestInterfaces.ITypedRequest, IReq_CreateRegistry> {
@@ -27,7 +27,7 @@ export interface IReq_CreateRegistry extends plugins.typedrequestInterfaces.impl
27
27
  registryData: data.IExternalRegistry['data'];
28
28
  };
29
29
  response: {
30
- registry: data.IExternalRegistry;
30
+ registry: data.IExternalRegistryPublic;
31
31
  };
32
32
  }
33
33
  export interface IReq_UpdateRegistry extends plugins.typedrequestInterfaces.implementsTR<plugins.typedrequestInterfaces.ITypedRequest, IReq_UpdateRegistry> {
@@ -35,10 +35,14 @@ export interface IReq_UpdateRegistry extends plugins.typedrequestInterfaces.impl
35
35
  request: {
36
36
  identity: userInterfaces.IIdentity;
37
37
  registryId: string;
38
- registryData: data.IExternalRegistry['data'];
38
+ /**
39
+ * The operator fields to change; a field left out keeps its stored value. `password` is
40
+ * write-only: missing or empty keeps the stored credential, and `authType: 'none'` removes it.
41
+ */
42
+ registryData: Partial<data.TExternalRegistryOperatorData>;
39
43
  };
40
44
  response: {
41
- resultRegistry: data.IExternalRegistry;
45
+ resultRegistry: data.IExternalRegistryPublic;
42
46
  };
43
47
  }
44
48
  export interface IReq_DeleteRegistryById extends plugins.typedrequestInterfaces.implementsTR<plugins.typedrequestInterfaces.ITypedRequest, IReq_DeleteRegistryById> {
@@ -58,8 +62,10 @@ export interface IReq_VerifyRegistry extends plugins.typedrequestInterfaces.impl
58
62
  registryId: string;
59
63
  };
60
64
  response: {
65
+ /** True exactly for the `authenticated` and `anonymous` outcomes. */
61
66
  success: boolean;
67
+ outcome: data.TExternalRegistryVerifyOutcome;
62
68
  message?: string;
63
- registry?: data.IExternalRegistry;
69
+ registry: data.IExternalRegistryPublic;
64
70
  };
65
71
  }
@@ -317,7 +317,22 @@ export interface IRequest_Any_Cloudly_UpdateService extends plugins.typedrequest
317
317
  request: {
318
318
  identity: IIdentity;
319
319
  serviceId: string;
320
- serviceData: TServiceUpdateWritableData;
320
+ /**
321
+ * The fields to change, merged over the stored service one key deep: a key the update omits
322
+ * keeps its stored value, and a key it states replaces the stored value whole (a stated
323
+ * `ports`, `mail` or `domains` is the complete new value, not a patch of the old one).
324
+ * `containerArgs: null` removes the stored argument vector; no other field takes `null`.
325
+ * `registryTarget` is the exception to "replaces": Cloudly derives it from the service and
326
+ * its image version, so a stated one only makes Cloudly derive it again and is never stored.
327
+ * An optional field cannot be cleared yet: a key whose value is `undefined` is dropped in
328
+ * transit, so it reads as omitted (explicit `null` removal for optional fields is a follow-up).
329
+ *
330
+ * Cloudly's guards ask which keys an update states, so send only the fields that change: a
331
+ * service with a runtime spec refuses any update stating `placement` or `scaleFactor`, and an
332
+ * immutable service refuses `imageId`, `imageVersion`, `registryTarget`, `scaleFactor` and
333
+ * `placement`, even when the stated value repeats the stored one.
334
+ */
335
+ serviceData: Partial<TServiceUpdateWritableData>;
321
336
  };
322
337
  response: {
323
338
  service: IService;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serve.zone/interfaces",
3
- "version": "32.58.0",
3
+ "version": "32.60.0",
4
4
  "private": false,
5
5
  "description": "Shared TypeScript interfaces and TypedRequest contracts for the serve.zone ecosystem.",
6
6
  "exports": {
package/readme.md CHANGED
@@ -2103,15 +2103,17 @@ evidence has not arrived). `refused` carries a `TServiceRuntimeRefusal`:
2103
2103
  `node-authority-unsupported`, `network-policy-missing`, `network-policy-mismatch`,
2104
2104
  `network-workload-pool-missing`, `network-workload-pool-exhausted`, `volume-driver-unsupported`,
2105
2105
  `volume-options-unsupported`, `volume-shared-unsupported`, `volume-placement-unsupported`,
2106
- `volume-initialization-unknown`, `volume-ownership-unknown`, `objectstorage-endpoint-missing`,
2107
- `objectstorage-binding-invalid`, `objectstorage-environment-conflict`,
2106
+ `volume-initialization-unknown`, `volume-ownership-unknown`, `launcher-secret-ownership-unknown`,
2107
+ `objectstorage-endpoint-missing`, `objectstorage-binding-invalid`, `objectstorage-environment-conflict`,
2108
2108
  `objectstorage-endpoint-unselected`, `database-endpoint-missing`, `database-endpoint-unselected`,
2109
2109
  `cluster-relay-missing` or `cluster-ingress-environment-conflict`.
2110
2110
  The first four volume
2111
2111
  refusals name what a local storage claim cannot express: a volume driver other than a node-local
2112
2112
  directory, driver or mount options, one volume mounted by more than one replica or service, and a
2113
2113
  placement that does not pin each volume to one node. The other two name what Cloudly will not guess,
2114
- see [Service Volume Initialization](#service-volume-initialization). Of the four object-storage
2114
+ see [Service Volume Initialization](#service-volume-initialization).
2115
+ `launcher-secret-ownership-unknown` is a launcher-environment secret file owner Cloudly cannot know,
2116
+ see [Launcher Secret Ownership](#launcher-secret-ownership). Of the four object-storage
2115
2117
  refusals, `objectstorage-endpoint-missing`, `objectstorage-binding-invalid` and
2116
2118
  `objectstorage-environment-conflict` name why a binding's environment cannot be composed, see
2117
2119
  [Object-Storage Binding Environment](#object-storage-binding-environment), and
@@ -2194,6 +2196,48 @@ Cloudly knows. The rule decides the first generation of a claim only: every late
2194
2196
  that claim's own initialization and ownership, whatever `runAs` says by then, since the node refuses
2195
2197
  a claim whose initial ownership changed.
2196
2198
 
2199
+ ### Launcher Secret Ownership
2200
+
2201
+ WorkloadInit reads a workload's launcher-environment secret files as the workload's own process
2202
+ identity, so their owner comes from that identity and never from a fixed `0:0`.
2203
+ `deriveLauncherSecretDeliveryOwner({ runAs, imageUsers, launcherDeliveries })` is the one rule, and
2204
+ answers `TLauncherSecretDeliveryOwner`: `derived` with `{ uid, gid }`, `not-required` for a workload
2205
+ without launcher deliveries, or `refused` with `reason: 'launcher-secret-ownership-unknown'`,
2206
+ `variableName` (the group-readable delivery that caused it, else `null`) and a `message`.
2207
+
2208
+ `runAs` is the runtime spec's `profile.runAs`. `imageUsers` holds the image user of the pinned
2209
+ digest's image config in Cloudly's canonical form (the registry manager's `parseCanonicalImageUser`
2210
+ answer, `null` for an image without a user or with an empty `User`, never the raw registry value):
2211
+ one entry for a single-platform manifest, one per platform of a multi-platform index.
2212
+ `launcherDeliveries` are the workload's launcher deliveries as their callers state them
2213
+ (`variableName` and `mode`); group readability is computed from them, never passed in.
2214
+
2215
+ | `runAs` | Image user | Owner |
2216
+ | --- | --- | --- |
2217
+ | `{ user, group }` | any | `user:group` |
2218
+ | `null` | none | `0:0`, the runtime config's root default |
2219
+ | `null` | numeric `u:g` (`root` is `0`) | `u:g` |
2220
+ | `null` | numeric `u`, or `u:<name>` | `u:0` while every delivery is `0o400`; refused, naming the first `0o440` delivery by variable name, otherwise |
2221
+ | `null` | a named user other than `root` | refused |
2222
+ | `null` | a numeric part above `2147483647` | refused |
2223
+
2224
+ An owner-readable (`0o400`) file grants its group nothing, so an unstated group is `0`; a `0o440`
2225
+ file would grant group `0` instead of the image's own group, so it refuses. Every platform of a
2226
+ multi-platform index must derive the same owner, or the workload is refused: it gets one delivery,
2227
+ never one per platform. Without launcher deliveries nothing about the image refuses. Only a
2228
+ structurally malformed input throws.
2229
+
2230
+ The owner travels in the resolved manifest, not in what a caller states. `TSecretDelivery`
2231
+ (`ISecretLauncherEnvironmentDelivery | ISecretFileDelivery`) is a caller's delivery: a Secret's
2232
+ `defaultDelivery`, a SecretSet attachment override, a create request, an App Store declaration. Its
2233
+ launcher member's `uid` and `gid` are optional and deprecated: the resolver ignores them, and
2234
+ `validateSecretDelivery` prefers neither and tolerates both (bounded) until the next major removes
2235
+ them. `TSecretResolvedDelivery` (`ISecretResolvedLauncherEnvironmentDelivery | ISecretFileDelivery`)
2236
+ is `IResolvedSecretManifestEntry.delivery`: its launcher member requires `uid` and `gid` under the
2237
+ same keys, so the manifest's wire format and digest are unchanged for every node.
2238
+ `createResolvedSecretDelivery(delivery, owner)` builds it from a caller's delivery and the derived
2239
+ owner, and `validateSecretResolvedDelivery` checks it. File deliveries keep their stated owner.
2240
+
2197
2241
  ### Runtime Assignment Views
2198
2242
 
2199
2243
  `data.IServiceRuntimeAssignmentView` is one replica slot's current assignment as an administrator
@@ -2998,8 +3042,17 @@ Common data contracts include:
2998
3042
  `proposeClusterNodeName`) and the named `clusterNodeHostnameRefusals` of
2999
3043
  `setClusterNodeHostname` and `resetClusterNodeHostname`.
3000
3044
  - `IService`, `IDeployment`, `IImage`, `IRegistryTarget`, and `IExternalRegistry` for workload delivery.
3045
+ - External registries: `IExternalRegistry` is the stored registry, credential included, and is
3046
+ never answered. Every `requests.externalRegistry` response carries `IExternalRegistryPublic`: the
3047
+ registry without `data.password` and with `passwordConfigured`. `data.password` (a password, access
3048
+ token or API key) is write-only: create stores it; an update (`registryData`, a `Partial` of
3049
+ `TExternalRegistryOperatorData`, never status, errors or timestamps) keeps the stored one when it
3050
+ is missing or empty, and `authType: 'none'` removes it. `verifyExternalRegistry` answers a
3051
+ `TExternalRegistryVerifyOutcome`: only `authenticated` proves the credentials;
3052
+ `credentials-not-exercised` means the registry answered anonymously although credentials are
3053
+ configured.
3001
3054
  - Service port contracts including `IServiceTargetPort`, `IServiceDomainRoute`, and `IServicePublicPortMapping` for canonical backend targets, domain target references, and edge/Coretraffic TCP/UDP public exposure.
3002
- - `IDomain`, `IDnsEntry`, and traffic contracts for routing and DNS management.
3055
+ - `IDomain`, `IDnsEntry`, and traffic contracts for routing and DNS management. `IDomain.data.gatewayListedAt` records when the dcrouter gateway last listed the domain to this Cloudly and is absent while the gateway does not list it; it is separate from the operator's `activationState`.
3003
3056
  - Traffic and gateway route contracts including `ICoretrafficPortRouteConfig`, routing `portRoutes`, and `IGatewayClientRoute` client-owned route views. Gateway route intent supports optional match `domains`, `transport`, and `remoteIngress`, plus explicit route `priority` and `managedRouteKind`. Ownership can combine `hostname` with `routeRef` so a normal route and a path-specific managed route for the same hostname reconcile independently.
3004
3057
  - Value-free `ISecretMetadata`, `ISecretVersionMetadata`, and `ISecretSetMetadata`
3005
3058
  contracts for operator views, plus exact-version
@@ -4612,12 +4665,20 @@ to a workload environment and refuses, naming every key, a name that is already
4612
4665
  service itself or by a second object-storage binding — instead of letting one value replace
4613
4666
  another.
4614
4667
 
4615
- **Reporting.** `composeObjectStorageServiceEnvironment(serviceEnvironment, sources)` composes a
4616
- whole service and answers in the service runtime vocabulary, so a producer never classifies the
4617
- errors above. Each `IObjectStorageBindingEnvironmentSource { binding, endpoint }` is an enabled
4618
- object-storage binding and the `corestoreObjectStorage` of the node it is placed on, `null` when
4619
- that node states none; a binding placed on no node is not a source, and its service waits as
4620
- `bindings-pending`. The answer is a `TObjectStorageServiceEnvironmentComposition`:
4668
+ **Reporting.** `composeObjectStorageServiceEnvironment(serviceEnvironment, sources,
4669
+ secretEnvironmentNames?)` composes a whole service and answers in the service runtime vocabulary,
4670
+ so a producer never classifies the errors above. Each
4671
+ `IObjectStorageBindingEnvironmentSource { binding, endpoint }` is an enabled object-storage binding
4672
+ and the `corestoreObjectStorage` of the node it is placed on, `null` when that node states none; a
4673
+ binding placed on no node is not a source, and its service waits as `bindings-pending`.
4674
+ `secretEnvironmentNames` names, without values, the variables the workload receives as
4675
+ launcher-environment secrets (the `variableName` of each `launcher-environment` delivery in its
4676
+ desired secret manifest; none when omitted). The launcher sets a secret over a plain variable of
4677
+ the same name, so a secret named like a binding's name would silently replace the binding's
4678
+ endpoint: it claims the name exactly as the service's own environment does. Secrets by any other
4679
+ name, including the binding's own credentials (`corestoreObjectStorageCredentialSecretKeys`, never
4680
+ plain names), do not concern the composition. `secretEnvironmentNames` becomes required in the
4681
+ coordinated major. The answer is a `TObjectStorageServiceEnvironmentComposition`:
4621
4682
 
4622
4683
  | `status` | `reason` | When |
4623
4684
  | --- | --- | --- |
@@ -4625,7 +4686,7 @@ that node states none; a binding placed on no node is not a source, and its serv
4625
4686
  | `waiting` | `objectstorage-retention-pending` | A binding declares compliance retention whose evidence has not arrived. |
4626
4687
  | `refused` | `objectstorage-endpoint-missing` | The binding's node states no valid `corestoreObjectStorage`. |
4627
4688
  | `refused` | `objectstorage-binding-invalid` | `createObjectStorageBindingEnvironment()` refuses the binding: not `objectstorage`, no canonical bucket, no canonical retention intent, or evidence that does not prove this binding. |
4628
- | `refused` | `objectstorage-environment-conflict` | The service's own environment or another binding already sets one of the binding's names. |
4689
+ | `refused` | `objectstorage-environment-conflict` | The service's own environment, one of its launcher-environment secrets or another binding already sets one of the binding's names. |
4629
4690
 
4630
4691
  `waiting` and `refused` carry the `bindingId` and the contract's `message`. Bindings are taken in
4631
4692
  id order, so the verdict does not depend on the order of the sources, and a refusal outranks a
@@ -4682,7 +4743,10 @@ object-storage endpoint. What they are addressed to is a fact about the node, ow
4682
4743
  ### Launcher Environment Delivery
4683
4744
 
4684
4745
  Launcher environment delivery uses stable `szsv-<base32-sha256>` Docker
4685
- resource names and `/run/serve.zone/secrets/<resource>` source paths. The
4746
+ resource names and `/run/serve.zone/secrets/<resource>` source paths. Each
4747
+ source file is owned by the workload identity the resolved manifest states,
4748
+ derived by `deriveLauncherSecretDeliveryOwner` (see
4749
+ [Launcher Secret Ownership](#launcher-secret-ownership)). The
4686
4750
  nonsecret map is written to `/run/serve.zone/workloadinit-map.json` with mode
4687
4751
  0444. Runtime assets under `/opt/serve.zone/runtime-assets` are read-only, the
4688
4752
  wrapper itself lives at
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/interfaces',
6
- version: '32.58.0',
6
+ version: '32.60.0',
7
7
  description: 'Shared TypeScript interfaces and TypedRequest contracts for the serve.zone ecosystem.'
8
8
  }
package/ts/data/domain.ts CHANGED
@@ -105,7 +105,14 @@ export interface IDomain {
105
105
  */
106
106
  syncSource?: 'cloudflare' | 'manual' | null;
107
107
  lastSyncAt?: number;
108
-
108
+
109
+ /**
110
+ * When the dcrouter gateway last listed this domain to this Cloudly (timestamp).
111
+ * Absent while the gateway does not list the domain. The gateway sync owns this
112
+ * member; it is independent of the operator's `activationState`.
113
+ */
114
+ gatewayListedAt?: number;
115
+
109
116
  /**
110
117
  * Whether domain is managed externally
111
118
  */
@@ -1,5 +1,9 @@
1
1
  import * as plugins from '../plugins.js';
2
2
 
3
+ /**
4
+ * An external registry as Cloudly stores it, credential included. Never answered: every external
5
+ * registry response carries `IExternalRegistryPublic`.
6
+ */
3
7
  export interface IExternalRegistry {
4
8
  id: string;
5
9
  data: {
@@ -24,7 +28,10 @@ export interface IExternalRegistry {
24
28
  username?: string;
25
29
 
26
30
  /**
27
- * Password, access token, or API key for authentication (optional for public registries)
31
+ * Password, access token or API key for authentication (optional for public registries).
32
+ *
33
+ * Write-only: Cloudly stores it and uses it server-side, and never answers it. Reads carry
34
+ * `IExternalRegistryPublic.passwordConfigured` instead.
28
35
  */
29
36
  password?: string;
30
37
 
@@ -107,4 +114,55 @@ export interface IExternalRegistry {
107
114
  */
108
115
  updatedAt?: number;
109
116
  };
110
- }
117
+ }
118
+
119
+ /**
120
+ * An external registry as Cloudly answers it: the stored registry without its credential, and
121
+ * whether one is stored.
122
+ */
123
+ export interface IExternalRegistryPublic {
124
+ id: string;
125
+ data: Omit<IExternalRegistry['data'], 'password'>;
126
+ /** Whether a password, access token or API key is stored for the registry. */
127
+ passwordConfigured: boolean;
128
+ }
129
+
130
+ /** The registry fields an operator writes; Cloudly owns status, errors and timestamps. */
131
+ export type TExternalRegistryOperatorField =
132
+ | 'type'
133
+ | 'name'
134
+ | 'url'
135
+ | 'username'
136
+ | 'password'
137
+ | 'description'
138
+ | 'isDefault'
139
+ | 'authType'
140
+ | 'insecure'
141
+ | 'namespace'
142
+ | 'proxy'
143
+ | 'config';
144
+
145
+ export type TExternalRegistryOperatorData = Pick<IExternalRegistry['data'], TExternalRegistryOperatorField>;
146
+
147
+ /**
148
+ * What a registry verification proved.
149
+ *
150
+ * - `authenticated`: the registry admitted the stored credentials (Basic, or a token its Bearer
151
+ * realm issued for them).
152
+ * - `anonymous`: the registry answers without authentication and none is configured.
153
+ * - `credentials-not-exercised`: credentials are configured, but the registry answers without
154
+ * authentication, so they were never checked.
155
+ * - `authentication-failed`: the registry, or its token realm, refused the credentials, or it
156
+ * requires authentication and none is configured.
157
+ * - `unreachable`: no usable answer (connection, TLS, timeout or an unexpected HTTP status).
158
+ * - `unsupported`: the registry or its configuration cannot be verified (an npm registry, OAuth2,
159
+ * an unknown or missing authentication challenge, a redirect, or credentials over plain HTTP
160
+ * without `insecure`).
161
+ */
162
+ export type TExternalRegistryVerifyOutcome =
163
+ | 'authenticated'
164
+ | 'anonymous'
165
+ | 'credentials-not-exercised'
166
+ | 'authentication-failed'
167
+ | 'unreachable'
168
+ | 'unsupported';
package/ts/data/index.ts CHANGED
@@ -82,6 +82,7 @@ export * from './servicecredential.js';
82
82
  export * from './serviceplatformownership.js';
83
83
  export * from './serviceports.js';
84
84
  export * from './serviceruntime.js';
85
+ export * from './launchersecretowner.js';
85
86
  export * from './servicevolumeinitialization.js';
86
87
  export * from './status.js';
87
88
  export * from './taskexecution.js';
@@ -0,0 +1,145 @@
1
+ import type { ISecretLauncherEnvironmentDelivery } from './secret.js';
2
+ import type { IServiceRuntimeProfile, TServiceRuntimeRefusal } from './serviceruntime.js';
3
+ import { hasExactKeys, isPlainRecord } from '../private/dataproperties.js';
4
+ import { isRuntimeIdentityNumber, runtimePolicyLimits } from '../private/runtimepolicy.js';
5
+
6
+ /** What the owner of a workload's launcher-environment secret files derives from. */
7
+ export interface ILauncherSecretDeliveryOwnerInput {
8
+ /** The runtime spec's `profile.runAs`; null runs the image's own OCI user. */
9
+ runAs: IServiceRuntimeProfile['runAs'];
10
+ /**
11
+ * The unresolved OCI `config.User` of the pinned digest's image config in Cloudly's canonical
12
+ * form (what its registry manager's `parseCanonicalImageUser` answers, never the raw registry
13
+ * value): `user` or `user:group`, each part a numeric identity or a name, and null for an image
14
+ * that states none or an empty `User`. One entry for a single-platform manifest, one per
15
+ * platform of a multi-platform index; never empty.
16
+ */
17
+ imageUsers: (string | null)[];
18
+ /**
19
+ * Every launcher-environment delivery the workload receives, as its caller states it. A `0o440`
20
+ * delivery grants the derived group, so that group has to be one the image states numerically.
21
+ */
22
+ launcherDeliveries: Pick<ISecretLauncherEnvironmentDelivery, 'variableName' | 'mode'>[];
23
+ }
24
+
25
+ /**
26
+ * The numeric owner of a workload's launcher-environment secret files, or why it derives none.
27
+ *
28
+ * `not-required` is a workload without launcher-environment deliveries: no file needs an owner, so
29
+ * nothing about the image refuses it.
30
+ *
31
+ * `launcher-secret-ownership-unknown` is a runtime spec that runs the image's own user
32
+ * (`runAs: null`) while that user's numeric owner is not known: a named user other than `root`, a
33
+ * numeric part above the runtime identity bound, a group-readable (`0o440`) delivery whose group
34
+ * the image does not state numerically, or platforms of a multi-platform index that derive
35
+ * different owners. `variableName` names the group-readable delivery that caused it (the first by
36
+ * name), null for the others; `message` states the cause.
37
+ */
38
+ export type TLauncherSecretDeliveryOwner =
39
+ | { status: 'derived'; uid: number; gid: number }
40
+ | { status: 'not-required' }
41
+ | {
42
+ status: 'refused';
43
+ reason: Extract<TServiceRuntimeRefusal, 'launcher-secret-ownership-unknown'>;
44
+ variableName: string | null;
45
+ message: string;
46
+ };
47
+
48
+ const imageUserPartPattern = /^[^:\u0000-\u001f\u007f]+$/;
49
+
50
+ /**
51
+ * One part of an image user: a numeric identity (`root` is `0`), a numeric part above the runtime
52
+ * identity bound, or a name only the image's root filesystem resolves.
53
+ */
54
+ type TImageUserPart = number | 'unbounded' | 'named';
55
+
56
+ const imageUserPart = (partArg: string): TImageUserPart => {
57
+ if (partArg === 'root') return 0;
58
+ if (!/^[0-9]+$/.test(partArg)) return 'named';
59
+ const identity = Number(partArg);
60
+ return isRuntimeIdentityNumber(identity) ? identity : 'unbounded';
61
+ };
62
+
63
+ /** One platform's user and group; an absent group is `undefined`. A malformed image user throws. */
64
+ const imageUserParts = (imageUserArg: unknown): { user: TImageUserPart; group: TImageUserPart | undefined } => {
65
+ if (imageUserArg === null) return { user: 0, group: 0 };
66
+ const parts = typeof imageUserArg === 'string' ? imageUserArg.split(':') : [];
67
+ if (parts.length === 0 || parts.length > 2 || !parts.every((partArg) => imageUserPartPattern.test(partArg))) {
68
+ throw new Error('image user must be null, user or user:group');
69
+ }
70
+ return { user: imageUserPart(parts[0]), group: parts[1] === undefined ? undefined : imageUserPart(parts[1]) };
71
+ };
72
+
73
+ /** The names of the group-readable (`0o440`) deliveries, sorted; a malformed delivery throws. */
74
+ const groupReadableVariableNames = (deliveriesArg: unknown): string[] => {
75
+ if (!Array.isArray(deliveriesArg)) throw new Error('launcherDeliveries must list the launcher deliveries');
76
+ const names: string[] = [];
77
+ for (const delivery of deliveriesArg) {
78
+ if (!isPlainRecord(delivery) || typeof delivery.variableName !== 'string' || delivery.variableName === ''
79
+ || (delivery.mode !== 0o400 && delivery.mode !== 0o440)) {
80
+ throw new Error('a launcher delivery must state its variableName and a 0400 or 0440 mode');
81
+ }
82
+ if (delivery.mode === 0o440) names.push(delivery.variableName);
83
+ }
84
+ return names.sort();
85
+ };
86
+
87
+ const refusal = (variableNameArg: string | null, messageArg: string): TLauncherSecretDeliveryOwner => ({
88
+ status: 'refused', reason: 'launcher-secret-ownership-unknown', variableName: variableNameArg, message: messageArg,
89
+ });
90
+
91
+ /**
92
+ * The owner Cloudly gives every launcher-environment secret file of a workload, which WorkloadInit
93
+ * reads as the workload's own process identity before it starts the image.
94
+ *
95
+ * Without launcher deliveries it is `not-required`. A `runAs` pair wins over the image. Under
96
+ * `runAs: null` an image without a user runs as root (`0:0`, the runtime config's own default), a
97
+ * numeric `user:group` is taken as stated and `root` stands for `0` in either part. A numeric user
98
+ * without a group, or with a named group, is `user:0` while every delivery is owner-readable
99
+ * (`0o400`), which grants the group nothing; a `0o440` delivery would grant it, so it refuses and
100
+ * names that delivery. A named user, or a numeric part above the runtime identity bound, refuses.
101
+ * Every platform of a multi-platform index must derive
102
+ * the same owner, or it refuses: a workload gets one delivery, never one per platform. Nothing is
103
+ * guessed. A structurally malformed input throws, whatever it would answer.
104
+ */
105
+ export const deriveLauncherSecretDeliveryOwner = (
106
+ inputArg: ILauncherSecretDeliveryOwnerInput,
107
+ ): TLauncherSecretDeliveryOwner => {
108
+ const { runAs, imageUsers, launcherDeliveries } = inputArg;
109
+ if (runAs !== null && !(isPlainRecord(runAs) && hasExactKeys(runAs, ['user', 'group'])
110
+ && isRuntimeIdentityNumber(runAs.user) && isRuntimeIdentityNumber(runAs.group))) {
111
+ throw new Error('runAs must be null or an exact user and group');
112
+ }
113
+ if (!Array.isArray(imageUsers) || imageUsers.length === 0) {
114
+ throw new Error('imageUsers must name the user of every platform');
115
+ }
116
+ const platforms = imageUsers.map(imageUserParts);
117
+ const groupReadable = groupReadableVariableNames(launcherDeliveries);
118
+ if (launcherDeliveries.length === 0) return { status: 'not-required' };
119
+ if (runAs !== null) return { status: 'derived', uid: runAs.user, gid: runAs.group };
120
+ const userOf = (predicateArg: (platformArg: typeof platforms[number]) => boolean): string | null | undefined => (
121
+ imageUsers.find((_imageUserArg, indexArg) => predicateArg(platforms[indexArg])));
122
+ const unbounded = userOf((platformArg) => platformArg.user === 'unbounded' || platformArg.group === 'unbounded');
123
+ if (unbounded !== undefined) {
124
+ return refusal(null, `image user '${unbounded}' states an identity above `
125
+ + `${runtimePolicyLimits.maximumIdentityNumber}`);
126
+ }
127
+ const namedUser = userOf((platformArg) => platformArg.user === 'named');
128
+ if (namedUser !== undefined) {
129
+ return refusal(null, `image user '${namedUser}' names a user only the image's root filesystem resolves`);
130
+ }
131
+ const ungrouped = userOf((platformArg) => typeof platformArg.group !== 'number');
132
+ if (ungrouped !== undefined && groupReadable.length > 0) {
133
+ return refusal(groupReadable[0], `launcher-environment secret ${groupReadable.join(', ')} is group-readable `
134
+ + `(0440), but image user '${ungrouped}' states no numeric group`);
135
+ }
136
+ // Every part is now a bounded number, or a group that an owner-readable file does not grant.
137
+ const [first, ...rest] = platforms.map((platformArg) => ({
138
+ uid: platformArg.user as number, gid: typeof platformArg.group === 'number' ? platformArg.group : 0,
139
+ }));
140
+ if (rest.some((ownerArg) => ownerArg.uid !== first.uid || ownerArg.gid !== first.gid)) {
141
+ const users = imageUsers.map((userArg) => userArg === null ? 'none' : `'${userArg}'`).join(', ');
142
+ return refusal(null, `the platforms' image users ${users} derive different owners`);
143
+ }
144
+ return { status: 'derived', uid: first.uid, gid: first.gid };
145
+ };
package/ts/data/secret.ts CHANGED
@@ -228,7 +228,33 @@ export interface ISecretSetConsumerRolloutStatus {
228
228
  updatedAt: number;
229
229
  }
230
230
 
231
+ /**
232
+ * A launcher-environment delivery as a caller states it: on a Secret's `defaultDelivery`, a
233
+ * SecretSet attachment override, a create request or an App Store declaration. The owner of the
234
+ * file WorkloadInit reads is not the caller's to state; the resolver derives it.
235
+ */
231
236
  export interface ISecretLauncherEnvironmentDelivery {
237
+ type: 'launcher-environment';
238
+ variableName: string;
239
+ /**
240
+ * @deprecated Derived by the resolver from the workload identity
241
+ * (`deriveLauncherSecretDeliveryOwner`); ignored; removed in the next major.
242
+ */
243
+ uid?: number;
244
+ /**
245
+ * @deprecated Derived by the resolver from the workload identity
246
+ * (`deriveLauncherSecretDeliveryOwner`); ignored; removed in the next major.
247
+ */
248
+ gid?: number;
249
+ mode: 0o400 | 0o440;
250
+ }
251
+
252
+ /**
253
+ * A launcher-environment delivery of a resolved secret manifest. `uid` and `gid` are the owner the
254
+ * resolver derived from the workload identity (`deriveLauncherSecretDeliveryOwner`), written by
255
+ * `createResolvedSecretDelivery`; the node mounts the file with them.
256
+ */
257
+ export interface ISecretResolvedLauncherEnvironmentDelivery {
232
258
  type: 'launcher-environment';
233
259
  variableName: string;
234
260
  uid: number;
@@ -244,8 +270,12 @@ export interface ISecretFileDelivery {
244
270
  mode: 0o400 | 0o440;
245
271
  }
246
272
 
273
+ /** A delivery as a caller states it. */
247
274
  export type TSecretDelivery = ISecretLauncherEnvironmentDelivery | ISecretFileDelivery;
248
275
 
276
+ /** A delivery of a resolved secret manifest: every owner is stated. */
277
+ export type TSecretResolvedDelivery = ISecretResolvedLauncherEnvironmentDelivery | ISecretFileDelivery;
278
+
249
279
  export const secretValueLimits = Object.freeze({
250
280
  maximumBytes: 500 * 1024,
251
281
  minimumGeneratedBytes: 16,
@@ -299,7 +329,7 @@ export interface IResolvedSecretManifestEntry {
299
329
  key: string;
300
330
  environment: TSecretEnvironment;
301
331
  owner: TWorkloadSecretOwner;
302
- delivery: TSecretDelivery;
332
+ delivery: TSecretResolvedDelivery;
303
333
  }
304
334
 
305
335
  /** Immutable, value-free runtime snapshot. */
@@ -709,14 +739,16 @@ export const validateSecretManagementScope = (
709
739
  : ['management scope prefix must match management source'];
710
740
  };
711
741
 
712
- export const validateSecretDelivery = (deliveryArg: unknown): string[] => {
742
+ const validateDelivery = (deliveryArg: unknown, launcherOwnerArg: 'tolerated' | 'required'): string[] => {
713
743
  if (!isRecord(deliveryArg)) return ['delivery must be an object'];
714
744
  const isLauncher = deliveryArg.type === 'launcher-environment';
715
745
  if (!isLauncher && deliveryArg.type !== 'file') {
716
746
  return ['delivery type must be launcher-environment or file'];
717
747
  }
748
+ const statesOwner = !isLauncher || launcherOwnerArg === 'required'
749
+ || deliveryArg.uid !== undefined || deliveryArg.gid !== undefined;
718
750
  const expectedKeys = isLauncher
719
- ? ['type', 'variableName', 'uid', 'gid', 'mode']
751
+ ? ['type', 'variableName', ...(statesOwner ? ['uid', 'gid'] : []), 'mode']
720
752
  : ['type', 'targetPath', 'uid', 'gid', 'mode'];
721
753
  if (!hasExactKeys(deliveryArg, expectedKeys)) {
722
754
  return ['delivery must use its exact schema'];
@@ -730,7 +762,7 @@ export const validateSecretDelivery = (deliveryArg: unknown): string[] => {
730
762
  || !/^\/run\/secrets\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(deliveryArg.targetPath))) {
731
763
  errors.push('file delivery targetPath must be a canonical file below /run/secrets');
732
764
  }
733
- for (const field of ['uid', 'gid'] as const) {
765
+ for (const field of statesOwner ? ['uid', 'gid'] as const : []) {
734
766
  const value = deliveryArg[field];
735
767
  if (!Number.isSafeInteger(value)
736
768
  || (value as number) < 0
@@ -744,6 +776,30 @@ export const validateSecretDelivery = (deliveryArg: unknown): string[] => {
744
776
  return errors;
745
777
  };
746
778
 
779
+ /**
780
+ * Validates a delivery as a caller states it. A launcher delivery should state no owner; one that
781
+ * states the deprecated `uid` and `gid` (both, bounded) is tolerated until the next major.
782
+ */
783
+ export const validateSecretDelivery = (deliveryArg: unknown): string[] => validateDelivery(deliveryArg, 'tolerated');
784
+
785
+ /** Validates a resolved manifest's delivery: a launcher delivery states its derived owner. */
786
+ export const validateSecretResolvedDelivery = (deliveryArg: unknown): string[] => validateDelivery(
787
+ deliveryArg, 'required');
788
+
789
+ /**
790
+ * The resolved manifest delivery of a caller's delivery: a launcher delivery takes the owner the
791
+ * resolver derived (`deriveLauncherSecretDeliveryOwner`) and drops any deprecated owner the caller
792
+ * stated; a file delivery keeps its own.
793
+ */
794
+ export const createResolvedSecretDelivery = (
795
+ deliveryArg: TSecretDelivery,
796
+ ownerArg: { uid: number; gid: number },
797
+ ): TSecretResolvedDelivery => (deliveryArg.type === 'launcher-environment'
798
+ ? { type: 'launcher-environment', variableName: deliveryArg.variableName, uid: ownerArg.uid, gid: ownerArg.gid,
799
+ mode: deliveryArg.mode }
800
+ : { type: 'file', targetPath: deliveryArg.targetPath, uid: deliveryArg.uid, gid: deliveryArg.gid,
801
+ mode: deliveryArg.mode });
802
+
747
803
  export const validateSecretMetadata = (secretArg: unknown): string[] => {
748
804
  if (!isRecord(secretArg)) return ['secret metadata must be an object'];
749
805
  const expectedKeys = [
@@ -1269,7 +1325,7 @@ export const validateResolvedSecretManifest = (
1269
1325
  `secret manifest entries[${index}].owner serviceId must match the manifest serviceId`,
1270
1326
  );
1271
1327
  }
1272
- errors.push(...validateSecretDelivery(candidate.delivery).map(
1328
+ errors.push(...validateSecretResolvedDelivery(candidate.delivery).map(
1273
1329
  (errorArg) => `secret manifest entries[${index}].${errorArg}`,
1274
1330
  ));
1275
1331
  if (isRecord(candidate.delivery)) {
@@ -1281,7 +1337,7 @@ export const validateResolvedSecretManifest = (
1281
1337
  } else if (
1282
1338
  candidate.delivery.type === 'file'
1283
1339
  && typeof candidate.delivery.targetPath === 'string'
1284
- && validateSecretDelivery(candidate.delivery).length === 0
1340
+ && validateSecretResolvedDelivery(candidate.delivery).length === 0
1285
1341
  ) {
1286
1342
  fileDeliveryTargets.push(candidate.delivery.targetPath);
1287
1343
  }
@@ -1584,7 +1640,7 @@ export const createWorkloadInitEnvironmentMap = async (
1584
1640
  .map(async (entryArg) => {
1585
1641
  const resource = await createDockerSecretResourceName(entryArg.secretVersionId);
1586
1642
  return {
1587
- variableName: (entryArg.delivery as ISecretLauncherEnvironmentDelivery).variableName,
1643
+ variableName: (entryArg.delivery as ISecretResolvedLauncherEnvironmentDelivery).variableName,
1588
1644
  resource,
1589
1645
  source: createLauncherSecretSourcePath(resource),
1590
1646
  };
@@ -1818,6 +1874,8 @@ const canonicalSecretOwner = (ownerArg: TSecretOwner): Record<string, string> =>
1818
1874
  }
1819
1875
  };
1820
1876
 
1877
+ // A launcher delivery that still states the deprecated owner keeps the context it was sealed
1878
+ // under; JSON leaves an absent owner out.
1821
1879
  const canonicalSecretDelivery = (deliveryArg: TSecretDelivery): Record<string, unknown> => (
1822
1880
  deliveryArg.type === 'launcher-environment'
1823
1881
  ? {