@serve.zone/interfaces 32.49.0 → 32.51.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 +53 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/data/deploymentlegacyimport.d.ts +120 -6
- package/dist_ts/data/deploymentlegacyimport.js +289 -5
- package/dist_ts/data/index.d.ts +1 -0
- package/dist_ts/data/index.js +2 -1
- package/dist_ts/data/networkapplication.d.ts +84 -0
- package/dist_ts/data/networkapplication.js +128 -0
- package/dist_ts/data/networkprojection.d.ts +5 -3
- package/dist_ts/data/networkprojection.js +6 -4
- package/dist_ts/requests/runtimesession.d.ts +11 -0
- package/dist_ts/requests/service.d.ts +9 -5
- package/package.json +2 -2
- package/readme.md +71 -5
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/data/deploymentlegacyimport.ts +413 -13
- package/ts/data/index.ts +1 -0
- package/ts/data/networkapplication.ts +188 -0
- package/ts/data/networkprojection.ts +5 -3
- package/ts/requests/runtimesession.ts +14 -0
- package/ts/requests/service.ts +12 -5
package/readme.md
CHANGED
|
@@ -711,9 +711,17 @@ projection and returns detached `IRuntimeNetworkWorkloadIngressPacketGrant`s
|
|
|
711
711
|
(`{ source, destination: { lease, protocol, port } }`). Their withdrawals follow
|
|
712
712
|
the host rules: an omitted grant needs its exact withdrawal, unknown or current
|
|
713
713
|
denials reject, and up to 4,096 retained withdrawals carry forward until the
|
|
714
|
-
exact joined application of the preceding projection is supplied
|
|
715
|
-
|
|
716
|
-
|
|
714
|
+
exact joined application of the preceding projection is supplied
|
|
715
|
+
([Projection Application Receipts](#projection-application-receipts)).
|
|
716
|
+
|
|
717
|
+
A signed grant is not yet a delivered flow. The receiving node enforces a grant
|
|
718
|
+
only between two workloads attached behind its own router: the packet engine
|
|
719
|
+
has no one-way grant across the inter-node tunnel, so a grant whose ingress
|
|
720
|
+
lease and target lease run on different nodes is signed into both nodes'
|
|
721
|
+
projections and admitted by both, yet neither builds a packet path for it and
|
|
722
|
+
the flow stays denied. Until cross-node one-way grants exist, a controller may
|
|
723
|
+
route a cluster ingress route to the target lease address and port only when the
|
|
724
|
+
grant exists and both leases are placed on the same node.
|
|
717
725
|
|
|
718
726
|
The optional `IRuntimeNetworkProjection.hostPlatformEndpointIds` lists the ids of
|
|
719
727
|
the protected authority's platform endpoints whose address the projection's
|
|
@@ -784,7 +792,8 @@ rejects changed same-generation content, regressed membership/readiness evidence
|
|
|
784
792
|
changed immutable address/DNS keys, and omitted packet grants without explicit
|
|
785
793
|
withdrawal. Pending withdrawals/tombstones must carry forward until the caller
|
|
786
794
|
supplies the exact previous projection reference from its own durable joined
|
|
787
|
-
application receipt
|
|
795
|
+
application receipt ([Projection Application Receipts](#projection-application-receipts)).
|
|
796
|
+
That reference is never taken from an incoming ACK. Keep
|
|
788
797
|
the accepted history and apply journal until native effects and DNS/allocation
|
|
789
798
|
quarantine are settled; a complete new snapshot does not erase that history.
|
|
790
799
|
|
|
@@ -873,6 +882,63 @@ Noise implementation owns cryptographic validation and authentication. Credentia
|
|
|
873
882
|
delivery does not prove a connected VPN, TUN ownership, packet enforcement or
|
|
874
883
|
workload readiness, and does not replace explicit packet withdrawals.
|
|
875
884
|
|
|
885
|
+
#### Projection Application Receipts
|
|
886
|
+
|
|
887
|
+
`IRuntimeNetworkProjectionApplicationReceipt` is a node's durable statement that
|
|
888
|
+
its packet authority is bounded by one admitted projection. Its claim is a
|
|
889
|
+
ceiling: every packet table the node holds was composed from `projection`, or
|
|
890
|
+
the node holds no packet table at all, so the flows its kernel admits are a
|
|
891
|
+
subset of that projection's grants. Only the node's joined native packet owner
|
|
892
|
+
can supply it: the journaled application of the projection's policies on every
|
|
893
|
+
role, or the release of every table (`nativeJournal: null`). An admission ACK,
|
|
894
|
+
a single table inspection or a success flag is never such evidence
|
|
895
|
+
(`runtimeNetworkProjectionApplicationContract`). A crash, a reboot or a restored
|
|
896
|
+
guard only removes authority, so a receipt stays true until the node starts
|
|
897
|
+
applying a successor; from then on only the successor's receipt counts, because
|
|
898
|
+
only the exact predecessor of an admitted projection is ever selected.
|
|
899
|
+
|
|
900
|
+
A node's receipts form one monotonic chain bound to its node scope and reporter
|
|
901
|
+
session; `bootId` records the boot that minted a receipt and is not compared.
|
|
902
|
+
`admitRuntimeNetworkProjectionApplicationReceipt(receipt,
|
|
903
|
+
previousReceipt, scope, head)` is the receiver's gate: the first receipt is
|
|
904
|
+
generation 1, each successor names its exact predecessor, an identical receipt
|
|
905
|
+
is a replay, and the named projection never goes back. The receipt must name
|
|
906
|
+
the head's projection chain at a generation the head has reached: the head
|
|
907
|
+
exactly, or an older generation. An older one was minted before the node
|
|
908
|
+
admitted the head; it advances the chain and is never usable.
|
|
909
|
+
|
|
910
|
+
`selectRuntimeNetworkProjectionAppliedPrevious(receipt, previous)` returns the
|
|
911
|
+
`appliedPrevious` argument of `admitRuntimeNetworkProjection` for the successor
|
|
912
|
+
of `previous`: the receipt's projection reference when it is exactly `previous`,
|
|
913
|
+
otherwise null. A controller passes the latest receipt it admitted for the node;
|
|
914
|
+
the node passes its own durable receipt to its own admission. Only the exact
|
|
915
|
+
predecessor counts. A receipt for an older projection proves nothing about the
|
|
916
|
+
grants issued since, so it releases nothing.
|
|
917
|
+
|
|
918
|
+
This is why withdrawals now drop. A projection carries its predecessor's
|
|
919
|
+
withdrawals, and may not grant a withdrawn flow again, until `appliedPrevious`
|
|
920
|
+
names that predecessor. Once a receipt proves the node applied it, the node's
|
|
921
|
+
packet authority is within the predecessor's grants, so the successor states
|
|
922
|
+
only the withdrawals of grants the predecessor held and it drops. A flow
|
|
923
|
+
withdrawn earlier can then be granted again. Without receipts, withdrawals only
|
|
924
|
+
accumulate up to their bound, and a withdrawn flow stays withdrawn until a lease
|
|
925
|
+
changes.
|
|
926
|
+
|
|
927
|
+
`reportRuntimeNetworkProjectionApplication`
|
|
928
|
+
(`IReq_Pallet_Controller_ReportRuntimeNetworkProjectionApplication`) carries a
|
|
929
|
+
receipt on the current authenticated physical session.
|
|
930
|
+
`bindReportRuntimeNetworkProjectionApplicationRequest` binds it to the current
|
|
931
|
+
session and the receiver-owned reporter history, as protection reports are
|
|
932
|
+
bound: a receipt minted under an earlier session of the same node still drains
|
|
933
|
+
from its outbox as `historical`. The response must match the exact sent receipt
|
|
934
|
+
(`bindReportRuntimeNetworkProjectionApplicationResponse`). Trust rests on the
|
|
935
|
+
authenticated session; the contract has no node signing key. A node's receipt
|
|
936
|
+
only loosens that node's own enforcement, so a node that misreports it gains no
|
|
937
|
+
authority over any other node. Each node's receipt chain is independent: a flow
|
|
938
|
+
granted in the projections of two nodes is released on each by that node's own
|
|
939
|
+
receipt. A receipt is not an admission acknowledgement, and an acknowledgement
|
|
940
|
+
is never a receipt.
|
|
941
|
+
|
|
876
942
|
### Egress Owner Retirement
|
|
877
943
|
|
|
878
944
|
An owner that is offline, revoked or gone can never acknowledge a successor, so
|
|
@@ -3574,7 +3640,7 @@ The immutable deployment workflow is:
|
|
|
3574
3640
|
|
|
3575
3641
|
A legacy `deployOnPush` service without deployable source moves to a greenfield successor by a legacy import, which replaces step 2:
|
|
3576
3642
|
|
|
3577
|
-
- While the legacy replicas still run — before its runtime is frozen — an administrator
|
|
3643
|
+
- While the legacy replicas still run — before its runtime is frozen — an administrator reads the legacy Swarm service and every container of its tasks on the hosts that run them, and submits that as `hostObservation` (`data.ILegacyServiceHostObservation`: Swarm service name, spec image and mode with its desired replicas; per replica the node hostname, Swarm task id, container id, the engine's image store, the container's image id, the image's `RepoDigests`, the container state and its health, `none` when no healthcheck applies) to `captureLegacyServiceRuntimeAttestation` (`requests.service.IRequest_Any_Cloudly_CaptureLegacyServiceRuntimeAttestation`). `data.validateLegacyServiceHostObservation` judges its shape, `data.selectLegacyServiceHostObservationRootDigest` names the one root every replica runs from the service's own repository, and `data.judgeLegacyServiceHostObservation` judges it against that root as Cloudly reads it from its own registry: exactly the desired replicas of a replicated service, its spec image naming the repository by the source tag or the root digest, every replica `running` and `healthy` or `none`, each container's image id the root digest on the containerd image store or one of the root's platform image configuration digests on the classic store. The source tag need not still name the root. The stored `data.ILegacyServiceRuntimeAttestation` records that root, its platform manifests, the replicas, the evidence source `administrator-host-observation` and the observation itself; `data.validateLegacyServiceRuntimeAttestation` states its rules. Incomplete evidence is refused, never stored, and there is no substitute once the runtime is stopped. `getLegacyServiceRuntimeAttestations` lists what Cloudly holds for a legacy service; both requests are for administrators only.
|
|
3578
3644
|
- The greenfield reservation, and every preflight of it, names `legacyImport` (`data.ILegacyImageImportReference`: legacy service, attestation, asserted source root digest) instead of `sourceRevision`, `sourceDirty` and release evidence. Refusals are the blockers `LEGACY_IMPORT_ATTESTATION_MISSING`, `LEGACY_IMPORT_SOURCE_MISMATCH` and `LEGACY_IMPORT_PLATFORM_MISMATCH`.
|
|
3579
3645
|
- Instead of the registry push, the deployer calls `importServiceDeploymentLegacyImage`: Cloudly imports the attested image from its own registry into the reserved tag — an index root as it is, a single platform manifest under a one-descriptor index of the manifest's own family — and records release evidence of source `cloudly-legacy-import` naming the legacy source. Steps 3 to 5 follow unchanged; `selectTrustedImageReleaseEvidence` accepts that evidence only with `evidenceSource: 'cloudly-legacy-import'`.
|
|
3580
3646
|
- The reservation and the import need the capability `deployment:import-legacy`, which only an organization-service-slot grant can carry.
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -4,15 +4,22 @@ import type {
|
|
|
4
4
|
TSha256Digest,
|
|
5
5
|
} from './immutableimage.js';
|
|
6
6
|
import { normalizeSha256Digest } from './immutableimage.js';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
hasExactKeys,
|
|
9
|
+
isPlainRecord,
|
|
10
|
+
readDataElements,
|
|
11
|
+
readDataProperties,
|
|
12
|
+
} from '../private/dataproperties.js';
|
|
8
13
|
|
|
9
14
|
/**
|
|
10
15
|
* Legacy image import: a greenfield deployment whose image is the one a legacy `deployOnPush`
|
|
11
16
|
* service already runs, instead of one built from a source revision.
|
|
12
17
|
*
|
|
13
18
|
* A legacy service without deployable source still has an image in Cloudly's registry and, until
|
|
14
|
-
* its runtime is frozen,
|
|
15
|
-
*
|
|
19
|
+
* its runtime is frozen, replicas running it. An administrator observes those replicas on the
|
|
20
|
+
* hosts that run them ({@link ILegacyServiceHostObservation}) while they still run, and Cloudly
|
|
21
|
+
* captures that observation once as a {@link ILegacyServiceRuntimeAttestation} after it verified
|
|
22
|
+
* the observed root against its own registry. A greenfield successor
|
|
16
23
|
* reservation names the attestation in `legacyImport` instead of a `sourceRevision`, and Cloudly
|
|
17
24
|
* then imports the attested image into the successor's own repository and release tag itself:
|
|
18
25
|
* the bytes never leave the registry and the release records that it was imported, not built.
|
|
@@ -60,19 +67,98 @@ export interface ILegacyServiceImageSource {
|
|
|
60
67
|
platforms: ILegacyImagePlatformManifest[];
|
|
61
68
|
}
|
|
62
69
|
|
|
63
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* One replica of the legacy service, observed running the attested digest in its own stated
|
|
72
|
+
* health: healthy, or running with no healthcheck that could state one.
|
|
73
|
+
*/
|
|
64
74
|
export interface ILegacyServiceRuntimeTaskAttestation {
|
|
75
|
+
/** The replica's runtime identity: the Swarm task it was observed as. */
|
|
65
76
|
deploymentId: string;
|
|
77
|
+
/** The hostname of the node the replica was observed on. */
|
|
66
78
|
nodeName: string;
|
|
67
79
|
observedDigest: TSha256Digest;
|
|
68
80
|
}
|
|
69
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Where the runtime part of an attestation comes from. `administrator-host-observation`: an
|
|
84
|
+
* administrator read the legacy Swarm service and its containers on the hosts that run them, and
|
|
85
|
+
* Cloudly judged that observation against its own registry.
|
|
86
|
+
*/
|
|
87
|
+
export type TLegacyServiceRuntimeEvidenceSource = 'administrator-host-observation';
|
|
88
|
+
|
|
89
|
+
/** A container's state as the Docker engine reports it (`State.Status`). */
|
|
90
|
+
export type TLegacyHostContainerState =
|
|
91
|
+
| 'created'
|
|
92
|
+
| 'running'
|
|
93
|
+
| 'paused'
|
|
94
|
+
| 'restarting'
|
|
95
|
+
| 'removing'
|
|
96
|
+
| 'exited'
|
|
97
|
+
| 'dead';
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A container's health as the Docker engine reports it (`State.Health.Status`). `none`: no
|
|
101
|
+
* healthcheck applies to the container — the image declares none and the service adds none — so
|
|
102
|
+
* the engine reports no health state at all.
|
|
103
|
+
*/
|
|
104
|
+
export type TLegacyHostContainerHealth = 'healthy' | 'unhealthy' | 'starting' | 'none';
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The image store of the Docker engine that runs a container. On the `containerd` store a
|
|
108
|
+
* container's image id is the digest of the root it was pulled as; on the `classic` store it is
|
|
109
|
+
* the image configuration digest of the platform manifest the engine pulled.
|
|
110
|
+
*/
|
|
111
|
+
export type TLegacyHostImageStore = 'containerd' | 'classic';
|
|
112
|
+
|
|
113
|
+
/** A Swarm service's mode (`Spec.Mode`): a replicated service states its desired replicas. */
|
|
114
|
+
export type TLegacyHostSwarmServiceMode =
|
|
115
|
+
| { kind: 'replicated'; desiredReplicas: number }
|
|
116
|
+
| { kind: 'global' };
|
|
117
|
+
|
|
118
|
+
/** One replica of a legacy Swarm service, as an administrator read it on the node that runs it. */
|
|
119
|
+
export interface ILegacyHostReplicaObservation {
|
|
120
|
+
/** The hostname of the node the task runs on. */
|
|
121
|
+
nodeHostname: string;
|
|
122
|
+
/** The Swarm task id (25 lowercase base-36 characters). */
|
|
123
|
+
swarmTaskId: string;
|
|
124
|
+
/** The full container id of the task (64 lowercase hex characters). */
|
|
125
|
+
containerId: string;
|
|
126
|
+
imageStore: TLegacyHostImageStore;
|
|
127
|
+
/** The container's image id (`Image`), a sha256 digest. */
|
|
128
|
+
containerImageId: TSha256Digest;
|
|
129
|
+
/** The `RepoDigests` of the container's image, exactly as the engine lists them. */
|
|
130
|
+
imageRepoDigests: string[];
|
|
131
|
+
state: TLegacyHostContainerState;
|
|
132
|
+
health: TLegacyHostContainerHealth;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* An administrator's observation of the legacy Swarm service of one legacy service and of every
|
|
137
|
+
* container its tasks run, read on the hosts while the legacy runtime still runs.
|
|
138
|
+
*
|
|
139
|
+
* It states facts, not conclusions: Cloudly judges it against its own registry
|
|
140
|
+
* ({@link judgeLegacyServiceHostObservation}) before it attests anything.
|
|
141
|
+
*/
|
|
142
|
+
export interface ILegacyServiceHostObservation {
|
|
143
|
+
/** The Swarm service name. */
|
|
144
|
+
swarmServiceName: string;
|
|
145
|
+
/** The image reference of the Swarm service spec (`Spec.TaskTemplate.ContainerSpec.Image`). */
|
|
146
|
+
specImage: string;
|
|
147
|
+
mode: TLegacyHostSwarmServiceMode;
|
|
148
|
+
/** When the administrator read the hosts, in milliseconds since the epoch. */
|
|
149
|
+
observedAt: number;
|
|
150
|
+
/** Every running task's replica, sorted by `swarmTaskId`. */
|
|
151
|
+
replicas: ILegacyHostReplicaObservation[];
|
|
152
|
+
}
|
|
153
|
+
|
|
70
154
|
/**
|
|
71
155
|
* Durable evidence, captured by an administrator while the legacy runtime still ran, that every
|
|
72
|
-
*
|
|
156
|
+
* desired replica of a legacy service ran, in its own stated health, exactly the registry root it
|
|
157
|
+
* names.
|
|
73
158
|
*
|
|
74
159
|
* Cloudly writes one and never changes it. A capture only succeeds on complete evidence, so a
|
|
75
|
-
* stored attestation is by construction one whose runtime digest is its source root digest
|
|
160
|
+
* stored attestation is by construction one whose runtime digest is its source root digest and
|
|
161
|
+
* whose runtime part is exactly the host observation it records.
|
|
76
162
|
*/
|
|
77
163
|
export interface ILegacyServiceRuntimeAttestation {
|
|
78
164
|
id: string;
|
|
@@ -81,11 +167,16 @@ export interface ILegacyServiceRuntimeAttestation {
|
|
|
81
167
|
organizationId: string;
|
|
82
168
|
source: ILegacyServiceImageSource;
|
|
83
169
|
runtime: {
|
|
170
|
+
/** The desired replicas of the observed replicated Swarm service. */
|
|
84
171
|
requiredReplicaCount: number;
|
|
172
|
+
/** Replicas observed running healthy, or running with no healthcheck (`health: 'none'`). */
|
|
85
173
|
healthyReplicaCount: number;
|
|
86
174
|
runtimeDigest: TSha256Digest;
|
|
87
|
-
/** Sorted by deploymentId, one per required replica. */
|
|
175
|
+
/** Sorted by deploymentId, one per required replica: the observed replicas. */
|
|
88
176
|
tasks: ILegacyServiceRuntimeTaskAttestation[];
|
|
177
|
+
evidenceSource: TLegacyServiceRuntimeEvidenceSource;
|
|
178
|
+
/** The observation the runtime part was built from, as the administrator submitted it. */
|
|
179
|
+
hostObservation: ILegacyServiceHostObservation;
|
|
89
180
|
};
|
|
90
181
|
capturedByUserId: string;
|
|
91
182
|
capturedAt: number;
|
|
@@ -120,6 +211,31 @@ const legacyImageImportIndexMediaTypes: readonly TLegacyImageImportIndexMediaTyp
|
|
|
120
211
|
'application/vnd.docker.distribution.manifest.list.v2+json',
|
|
121
212
|
];
|
|
122
213
|
const legacyImagePlatforms: readonly TImmutableContainerPlatform[] = ['linux/amd64', 'linux/arm64'];
|
|
214
|
+
const legacyHostContainerStates: readonly TLegacyHostContainerState[] = [
|
|
215
|
+
'created',
|
|
216
|
+
'running',
|
|
217
|
+
'paused',
|
|
218
|
+
'restarting',
|
|
219
|
+
'removing',
|
|
220
|
+
'exited',
|
|
221
|
+
'dead',
|
|
222
|
+
];
|
|
223
|
+
const legacyHostContainerHealths: readonly TLegacyHostContainerHealth[] = [
|
|
224
|
+
'healthy',
|
|
225
|
+
'unhealthy',
|
|
226
|
+
'starting',
|
|
227
|
+
'none',
|
|
228
|
+
];
|
|
229
|
+
/** The health states a replica may be attested in: healthy, or no healthcheck at all. */
|
|
230
|
+
const attestableLegacyHostContainerHealths: readonly TLegacyHostContainerHealth[] = ['healthy', 'none'];
|
|
231
|
+
const legacyHostImageStores: readonly TLegacyHostImageStore[] = ['containerd', 'classic'];
|
|
232
|
+
const swarmTaskIdRegex = /^[a-z0-9]{25}$/;
|
|
233
|
+
const containerIdRegex = /^[0-9a-f]{64}$/;
|
|
234
|
+
const swarmServiceNameRegex = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$/;
|
|
235
|
+
const nodeHostnameRegex = /^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/;
|
|
236
|
+
const imageReferenceRegex = /^\S{1,512}$/;
|
|
237
|
+
const repoDigestRegex = /^[^\s@]{1,440}@sha256:[0-9a-f]{64}$/;
|
|
238
|
+
const maximumRepoDigestCount = 32;
|
|
123
239
|
|
|
124
240
|
const isIdentifier = (valueArg: unknown): valueArg is string => (
|
|
125
241
|
typeof valueArg === 'string' && identifierRegex.test(valueArg)
|
|
@@ -168,6 +284,256 @@ export const validateLegacyImageImportReference = (referenceArg: unknown): strin
|
|
|
168
284
|
return errors;
|
|
169
285
|
};
|
|
170
286
|
|
|
287
|
+
const validateHostReplicaObservation = (
|
|
288
|
+
replicaArg: unknown,
|
|
289
|
+
labelArg: string,
|
|
290
|
+
): string[] => {
|
|
291
|
+
const replica = readDataProperties(replicaArg);
|
|
292
|
+
if (replica === null) return [`${labelArg} must be a plain data object`];
|
|
293
|
+
const errors: string[] = [];
|
|
294
|
+
if (!hasExactKeys(replica, [
|
|
295
|
+
'nodeHostname',
|
|
296
|
+
'swarmTaskId',
|
|
297
|
+
'containerId',
|
|
298
|
+
'imageStore',
|
|
299
|
+
'containerImageId',
|
|
300
|
+
'imageRepoDigests',
|
|
301
|
+
'state',
|
|
302
|
+
'health',
|
|
303
|
+
])) {
|
|
304
|
+
errors.push(`${labelArg} must use its exact schema`);
|
|
305
|
+
}
|
|
306
|
+
if (typeof replica.nodeHostname !== 'string' || !nodeHostnameRegex.test(replica.nodeHostname)) {
|
|
307
|
+
errors.push(`${labelArg}.nodeHostname must be a host name`);
|
|
308
|
+
}
|
|
309
|
+
if (typeof replica.swarmTaskId !== 'string' || !swarmTaskIdRegex.test(replica.swarmTaskId)) {
|
|
310
|
+
errors.push(`${labelArg}.swarmTaskId must be a Swarm task id`);
|
|
311
|
+
}
|
|
312
|
+
if (typeof replica.containerId !== 'string' || !containerIdRegex.test(replica.containerId)) {
|
|
313
|
+
errors.push(`${labelArg}.containerId must be a full container id`);
|
|
314
|
+
}
|
|
315
|
+
if (!legacyHostImageStores.some((storeArg) => storeArg === replica.imageStore)) {
|
|
316
|
+
errors.push(`${labelArg}.imageStore must be containerd or classic`);
|
|
317
|
+
}
|
|
318
|
+
if (!isCanonicalDigest(replica.containerImageId)) {
|
|
319
|
+
errors.push(`${labelArg}.containerImageId must be a canonical sha256 digest`);
|
|
320
|
+
}
|
|
321
|
+
const repoDigests = readDataElements(replica.imageRepoDigests, maximumRepoDigestCount);
|
|
322
|
+
if (repoDigests === null
|
|
323
|
+
|| repoDigests.some((entryArg) => typeof entryArg !== 'string' || !repoDigestRegex.test(entryArg))
|
|
324
|
+
|| new Set(repoDigests).size !== repoDigests.length) {
|
|
325
|
+
errors.push(`${labelArg}.imageRepoDigests must be unique digested image references`);
|
|
326
|
+
}
|
|
327
|
+
if (!legacyHostContainerStates.some((stateArg) => stateArg === replica.state)) {
|
|
328
|
+
errors.push(`${labelArg}.state must be a Docker container state`);
|
|
329
|
+
}
|
|
330
|
+
if (!legacyHostContainerHealths.some((healthArg) => healthArg === replica.health)) {
|
|
331
|
+
errors.push(`${labelArg}.health must be healthy, unhealthy, starting or none`);
|
|
332
|
+
}
|
|
333
|
+
return errors;
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const validateHostSwarmServiceMode = (modeArg: unknown, labelArg: string): string[] => {
|
|
337
|
+
const mode = readDataProperties(modeArg);
|
|
338
|
+
if (mode === null) return [`${labelArg} must be a plain data object`];
|
|
339
|
+
if (mode.kind === 'global') {
|
|
340
|
+
return hasExactKeys(mode, ['kind']) ? [] : [`${labelArg} must use its exact schema`];
|
|
341
|
+
}
|
|
342
|
+
if (mode.kind !== 'replicated') return [`${labelArg}.kind must be replicated or global`];
|
|
343
|
+
const errors: string[] = [];
|
|
344
|
+
if (!hasExactKeys(mode, ['kind', 'desiredReplicas'])) {
|
|
345
|
+
errors.push(`${labelArg} must use its exact schema`);
|
|
346
|
+
}
|
|
347
|
+
if (typeof mode.desiredReplicas !== 'number'
|
|
348
|
+
|| !Number.isSafeInteger(mode.desiredReplicas)
|
|
349
|
+
|| mode.desiredReplicas < 0
|
|
350
|
+
|| mode.desiredReplicas > maximumReplicaCount) {
|
|
351
|
+
errors.push(`${labelArg}.desiredReplicas must be a bounded replica count`);
|
|
352
|
+
}
|
|
353
|
+
return errors;
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The shape of a host observation: exact keys at every level, Docker's own value sets, at most 64
|
|
358
|
+
* replicas, unique and sorted by Swarm task id, each container observed once. It judges nothing
|
|
359
|
+
* against a registry; {@link judgeLegacyServiceHostObservation} does.
|
|
360
|
+
*/
|
|
361
|
+
export const validateLegacyServiceHostObservation = (
|
|
362
|
+
observationArg: unknown,
|
|
363
|
+
labelArg = 'hostObservation',
|
|
364
|
+
): string[] => {
|
|
365
|
+
const observation = readDataProperties(observationArg);
|
|
366
|
+
if (observation === null) return [`${labelArg} must be a plain data object`];
|
|
367
|
+
const errors: string[] = [];
|
|
368
|
+
if (!hasExactKeys(observation, ['swarmServiceName', 'specImage', 'mode', 'observedAt', 'replicas'])) {
|
|
369
|
+
errors.push(`${labelArg} must use its exact schema`);
|
|
370
|
+
}
|
|
371
|
+
if (typeof observation.swarmServiceName !== 'string'
|
|
372
|
+
|| !swarmServiceNameRegex.test(observation.swarmServiceName)) {
|
|
373
|
+
errors.push(`${labelArg}.swarmServiceName must be a Swarm service name`);
|
|
374
|
+
}
|
|
375
|
+
if (typeof observation.specImage !== 'string' || !imageReferenceRegex.test(observation.specImage)) {
|
|
376
|
+
errors.push(`${labelArg}.specImage must be a bounded image reference`);
|
|
377
|
+
}
|
|
378
|
+
errors.push(...validateHostSwarmServiceMode(observation.mode, `${labelArg}.mode`));
|
|
379
|
+
if (!isPositiveSafeInteger(observation.observedAt, Number.MAX_SAFE_INTEGER)) {
|
|
380
|
+
errors.push(`${labelArg}.observedAt must be a positive timestamp`);
|
|
381
|
+
}
|
|
382
|
+
const replicas = readDataElements(observation.replicas, maximumReplicaCount);
|
|
383
|
+
if (replicas === null) {
|
|
384
|
+
errors.push(`${labelArg}.replicas must be a list of at most ${maximumReplicaCount} replicas`);
|
|
385
|
+
return errors;
|
|
386
|
+
}
|
|
387
|
+
replicas.forEach((replicaArg, indexArg) => {
|
|
388
|
+
errors.push(...validateHostReplicaObservation(replicaArg, `${labelArg}.replicas[${indexArg}]`));
|
|
389
|
+
});
|
|
390
|
+
const readReplicaKey = (replicaArg: unknown, keyArg: string): unknown => (
|
|
391
|
+
readDataProperties(replicaArg)?.[keyArg]
|
|
392
|
+
);
|
|
393
|
+
const taskIds = replicas.map((replicaArg) => readReplicaKey(replicaArg, 'swarmTaskId'));
|
|
394
|
+
const sortedTaskIds = taskIds
|
|
395
|
+
.filter((idArg): idArg is string => typeof idArg === 'string')
|
|
396
|
+
.sort();
|
|
397
|
+
if (new Set(taskIds).size !== taskIds.length
|
|
398
|
+
|| JSON.stringify(taskIds) !== JSON.stringify(sortedTaskIds)) {
|
|
399
|
+
errors.push(`${labelArg}.replicas must be unique and sorted by swarmTaskId`);
|
|
400
|
+
}
|
|
401
|
+
const containerIds = replicas.map((replicaArg) => readReplicaKey(replicaArg, 'containerId'));
|
|
402
|
+
if (new Set(containerIds).size !== containerIds.length) {
|
|
403
|
+
errors.push(`${labelArg}.replicas must observe each container once`);
|
|
404
|
+
}
|
|
405
|
+
return errors;
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
/** True when a value is a well-formed host observation. */
|
|
409
|
+
export const isLegacyServiceHostObservation = (
|
|
410
|
+
valueArg: unknown,
|
|
411
|
+
): valueArg is ILegacyServiceHostObservation => (
|
|
412
|
+
validateLegacyServiceHostObservation(valueArg).length === 0
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* The root digest one replica was pulled as from `registryHost/repository`.
|
|
417
|
+
*
|
|
418
|
+
* On the containerd store that is the container's image id, which the image's `RepoDigests` must
|
|
419
|
+
* list for the repository. On the classic store it is the one digest `RepoDigests` lists for the
|
|
420
|
+
* repository; none, or more than one, leaves the root unknown.
|
|
421
|
+
*/
|
|
422
|
+
const selectReplicaRootDigest = (
|
|
423
|
+
replicaArg: ILegacyHostReplicaObservation,
|
|
424
|
+
repositoryReferenceArg: string,
|
|
425
|
+
): TSha256Digest | undefined => {
|
|
426
|
+
const prefix = `${repositoryReferenceArg}@`;
|
|
427
|
+
const repositoryDigests = replicaArg.imageRepoDigests
|
|
428
|
+
.filter((entryArg) => entryArg.startsWith(prefix))
|
|
429
|
+
.map((entryArg) => normalizeSha256Digest(entryArg.slice(prefix.length)))
|
|
430
|
+
.filter((digestArg): digestArg is TSha256Digest => digestArg !== undefined);
|
|
431
|
+
if (replicaArg.imageStore === 'containerd') {
|
|
432
|
+
return repositoryDigests.includes(replicaArg.containerImageId)
|
|
433
|
+
? replicaArg.containerImageId
|
|
434
|
+
: undefined;
|
|
435
|
+
}
|
|
436
|
+
return repositoryDigests.length === 1 ? repositoryDigests[0] : undefined;
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* The one root digest every observed replica runs from the legacy service's own repository, which
|
|
441
|
+
* is the digest Cloudly then inspects in its registry. Refused when a replica's root from that
|
|
442
|
+
* repository is unknown, when the replicas run more than one root, or when nothing was observed.
|
|
443
|
+
*/
|
|
444
|
+
export const selectLegacyServiceHostObservationRootDigest = (argsArg: {
|
|
445
|
+
observation: ILegacyServiceHostObservation;
|
|
446
|
+
registryHost: string;
|
|
447
|
+
repository: string;
|
|
448
|
+
}): { rootDigest?: TSha256Digest; errors: string[] } => {
|
|
449
|
+
const repositoryReference = `${argsArg.registryHost}/${argsArg.repository}`;
|
|
450
|
+
const errors: string[] = [];
|
|
451
|
+
const roots = new Set<TSha256Digest>();
|
|
452
|
+
argsArg.observation.replicas.forEach((replicaArg, indexArg) => {
|
|
453
|
+
const root = selectReplicaRootDigest(replicaArg, repositoryReference);
|
|
454
|
+
if (root === undefined) {
|
|
455
|
+
errors.push(replicaArg.imageStore === 'containerd'
|
|
456
|
+
? `hostObservation.replicas[${indexArg}] image is not listed as ${repositoryReference}@<its image id>`
|
|
457
|
+
: `hostObservation.replicas[${indexArg}] image lists not exactly one digest of ${repositoryReference}`);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
roots.add(root);
|
|
461
|
+
});
|
|
462
|
+
if (argsArg.observation.replicas.length === 0) {
|
|
463
|
+
errors.push('hostObservation must observe at least one replica');
|
|
464
|
+
}
|
|
465
|
+
if (roots.size > 1) {
|
|
466
|
+
errors.push('hostObservation replicas run more than one root digest');
|
|
467
|
+
}
|
|
468
|
+
const [rootDigest] = [...roots];
|
|
469
|
+
return errors.length === 0 && rootDigest !== undefined ? { rootDigest, errors } : { errors };
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Judges a well-formed host observation against the registry source Cloudly inspected for it.
|
|
474
|
+
* Every rule an attestation's runtime part must meet:
|
|
475
|
+
*
|
|
476
|
+
* - the Swarm service is replicated, with at least one desired replica, and exactly its desired
|
|
477
|
+
* replicas were observed;
|
|
478
|
+
* - its spec image names the source repository by the source tag, by the root digest, or by both;
|
|
479
|
+
* - every replica is `running`, and `healthy` or without a healthcheck (`none`) — `unhealthy` and
|
|
480
|
+
* `starting` are refused;
|
|
481
|
+
* - every replica runs the source root from the source repository: its image lists
|
|
482
|
+
* `registryHost/repository@rootDigest`, and its image id is the root digest on the containerd
|
|
483
|
+
* store or one of the root's platform image configuration digests on the classic store.
|
|
484
|
+
*/
|
|
485
|
+
export const judgeLegacyServiceHostObservation = (argsArg: {
|
|
486
|
+
observation: ILegacyServiceHostObservation;
|
|
487
|
+
source: ILegacyServiceImageSource;
|
|
488
|
+
label?: string;
|
|
489
|
+
}): string[] => {
|
|
490
|
+
const label = argsArg.label ?? 'hostObservation';
|
|
491
|
+
const { observation, source } = argsArg;
|
|
492
|
+
const errors: string[] = [];
|
|
493
|
+
if (observation.mode.kind !== 'replicated') {
|
|
494
|
+
errors.push(`${label}.mode must be replicated: a legacy service runs as a replicated Swarm service`);
|
|
495
|
+
} else if (observation.mode.desiredReplicas < 1) {
|
|
496
|
+
errors.push(`${label}.mode must desire at least one replica`);
|
|
497
|
+
} else if (observation.replicas.length !== observation.mode.desiredReplicas) {
|
|
498
|
+
errors.push(`${label} must observe exactly the ${observation.mode.desiredReplicas} desired replicas`);
|
|
499
|
+
}
|
|
500
|
+
const repositoryReference = `${source.registryHost}/${source.repository}`;
|
|
501
|
+
const specImages = [
|
|
502
|
+
`${repositoryReference}:${source.tag}`,
|
|
503
|
+
`${repositoryReference}@${source.rootDigest}`,
|
|
504
|
+
`${repositoryReference}:${source.tag}@${source.rootDigest}`,
|
|
505
|
+
];
|
|
506
|
+
if (!specImages.includes(observation.specImage)) {
|
|
507
|
+
errors.push(`${label}.specImage must name ${repositoryReference} by its source tag or root digest`);
|
|
508
|
+
}
|
|
509
|
+
const imageConfigDigests = source.platforms.map((platformArg) => platformArg.imageConfigDigest);
|
|
510
|
+
observation.replicas.forEach((replicaArg, indexArg) => {
|
|
511
|
+
const replicaLabel = `${label}.replicas[${indexArg}]`;
|
|
512
|
+
if (replicaArg.state !== 'running') {
|
|
513
|
+
errors.push(`${replicaLabel}.state must be running`);
|
|
514
|
+
}
|
|
515
|
+
if (!attestableLegacyHostContainerHealths.includes(replicaArg.health)) {
|
|
516
|
+
errors.push(`${replicaLabel}.health must be healthy or none`);
|
|
517
|
+
}
|
|
518
|
+
if (!replicaArg.imageRepoDigests.includes(`${repositoryReference}@${source.rootDigest}`)) {
|
|
519
|
+
errors.push(`${replicaLabel} image must be ${repositoryReference}@${source.rootDigest}`);
|
|
520
|
+
}
|
|
521
|
+
if (replicaArg.imageStore === 'containerd') {
|
|
522
|
+
if (replicaArg.containerImageId !== source.rootDigest) {
|
|
523
|
+
errors.push(`${replicaLabel}.containerImageId must be the root digest on the containerd image store`);
|
|
524
|
+
}
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
if (!imageConfigDigests.includes(replicaArg.containerImageId)) {
|
|
528
|
+
errors.push(`${replicaLabel}.containerImageId must be a platform image configuration digest of the root on the classic image store`);
|
|
529
|
+
}
|
|
530
|
+
if (selectReplicaRootDigest(replicaArg, repositoryReference) === undefined) {
|
|
531
|
+
errors.push(`${replicaLabel} image must list exactly one digest of ${repositoryReference} on the classic image store`);
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
return errors;
|
|
535
|
+
};
|
|
536
|
+
|
|
171
537
|
const validatePlatformManifest = (
|
|
172
538
|
manifestArg: unknown,
|
|
173
539
|
indexArg: number,
|
|
@@ -270,17 +636,54 @@ const validateImageSource = (sourceArg: unknown): string[] => {
|
|
|
270
636
|
return errors;
|
|
271
637
|
};
|
|
272
638
|
|
|
273
|
-
const
|
|
639
|
+
const isLegacyServiceImageSource = (valueArg: unknown): valueArg is ILegacyServiceImageSource => (
|
|
640
|
+
validateImageSource(valueArg).length === 0
|
|
641
|
+
);
|
|
642
|
+
|
|
643
|
+
const validateRuntime = (runtimeArg: unknown, sourceArg: unknown): string[] => {
|
|
274
644
|
if (!isPlainRecord(runtimeArg)) return ['runtime must be an object'];
|
|
645
|
+
const rootDigest = isPlainRecord(sourceArg) ? sourceArg.rootDigest : undefined;
|
|
275
646
|
const errors: string[] = [];
|
|
276
647
|
if (!hasExactKeys(runtimeArg, [
|
|
277
648
|
'requiredReplicaCount',
|
|
278
649
|
'healthyReplicaCount',
|
|
279
650
|
'runtimeDigest',
|
|
280
651
|
'tasks',
|
|
652
|
+
'evidenceSource',
|
|
653
|
+
'hostObservation',
|
|
281
654
|
])) {
|
|
282
655
|
errors.push('runtime must use its exact schema');
|
|
283
656
|
}
|
|
657
|
+
if (runtimeArg.evidenceSource !== 'administrator-host-observation') {
|
|
658
|
+
errors.push('runtime.evidenceSource must be administrator-host-observation');
|
|
659
|
+
}
|
|
660
|
+
const hostObservation = runtimeArg.hostObservation;
|
|
661
|
+
const observationErrors = validateLegacyServiceHostObservation(
|
|
662
|
+
hostObservation, 'runtime.hostObservation');
|
|
663
|
+
errors.push(...observationErrors);
|
|
664
|
+
if (observationErrors.length === 0
|
|
665
|
+
&& isLegacyServiceHostObservation(hostObservation)
|
|
666
|
+
&& isLegacyServiceImageSource(sourceArg)) {
|
|
667
|
+
errors.push(...judgeLegacyServiceHostObservation({
|
|
668
|
+
observation: hostObservation,
|
|
669
|
+
source: sourceArg,
|
|
670
|
+
label: 'runtime.hostObservation',
|
|
671
|
+
}));
|
|
672
|
+
if (hostObservation.mode.kind === 'replicated'
|
|
673
|
+
&& runtimeArg.requiredReplicaCount !== hostObservation.mode.desiredReplicas) {
|
|
674
|
+
errors.push('runtime.requiredReplicaCount must be the observed desired replicas');
|
|
675
|
+
}
|
|
676
|
+
const recordedTasks = readDataElements(runtimeArg.tasks, maximumReplicaCount) ?? [];
|
|
677
|
+
if (recordedTasks.length !== hostObservation.replicas.length
|
|
678
|
+
|| hostObservation.replicas.some((replicaArg, indexArg) => {
|
|
679
|
+
const task = recordedTasks[indexArg];
|
|
680
|
+
return !isPlainRecord(task)
|
|
681
|
+
|| task.deploymentId !== replicaArg.swarmTaskId
|
|
682
|
+
|| task.nodeName !== replicaArg.nodeHostname;
|
|
683
|
+
})) {
|
|
684
|
+
errors.push('runtime.tasks must be the observed replicas: each Swarm task on its node');
|
|
685
|
+
}
|
|
686
|
+
}
|
|
284
687
|
if (!isPositiveSafeInteger(runtimeArg.requiredReplicaCount, maximumReplicaCount)) {
|
|
285
688
|
errors.push('runtime.requiredReplicaCount must be a positive bounded replica count');
|
|
286
689
|
}
|
|
@@ -289,7 +692,7 @@ const validateRuntime = (runtimeArg: unknown, rootDigestArg: unknown): string[]
|
|
|
289
692
|
}
|
|
290
693
|
if (!isCanonicalDigest(runtimeArg.runtimeDigest)) {
|
|
291
694
|
errors.push('runtime.runtimeDigest must be a canonical sha256 digest');
|
|
292
|
-
} else if (runtimeArg.runtimeDigest !==
|
|
695
|
+
} else if (runtimeArg.runtimeDigest !== rootDigest) {
|
|
293
696
|
errors.push('runtime.runtimeDigest must be the attested source root digest');
|
|
294
697
|
}
|
|
295
698
|
if (!Array.isArray(runtimeArg.tasks) || runtimeArg.tasks.length !== runtimeArg.requiredReplicaCount) {
|
|
@@ -363,9 +766,6 @@ export const validateLegacyServiceRuntimeAttestation = (attestationArg: unknown)
|
|
|
363
766
|
errors.push('capturedAt must be a positive timestamp');
|
|
364
767
|
}
|
|
365
768
|
errors.push(...validateImageSource(data.source));
|
|
366
|
-
errors.push(...validateRuntime(
|
|
367
|
-
data.runtime,
|
|
368
|
-
isPlainRecord(data.source) ? data.source.rootDigest : undefined,
|
|
369
|
-
));
|
|
769
|
+
errors.push(...validateRuntime(data.runtime, data.source));
|
|
370
770
|
return errors;
|
|
371
771
|
};
|
package/ts/data/index.ts
CHANGED
|
@@ -36,6 +36,7 @@ export * from './nodeenrollment.js';
|
|
|
36
36
|
export * from './noderuntimebinding.js';
|
|
37
37
|
export * from './noderetirement.js';
|
|
38
38
|
export * from './networkaddressplan.js';
|
|
39
|
+
export * from './networkapplication.js';
|
|
39
40
|
export * from './networkauthority.js';
|
|
40
41
|
export * from './networkcredential.js';
|
|
41
42
|
export * from './networkdelivery.js';
|