@serve.zone/interfaces 32.15.0 → 32.16.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serve.zone/interfaces",
3
- "version": "32.15.0",
3
+ "version": "32.16.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
@@ -1056,6 +1056,24 @@ sequence, observation time and fixed `freshUntil`; assembling a later census can
1056
1056
  observation. It never transports environment, arbitrary labels, mount paths, volume names,
1057
1057
  registry credentials or secret values.
1058
1058
 
1059
+ `data.computeFleetCutoverDockerServiceSpecDigest` hashes the complete open-schema Docker
1060
+ `Service.Spec`, including response properties unknown to this Interfaces version, under
1061
+ `serve.zone/fleet-cutover-docker-service-spec`. The helper returns only the digest: the full spec,
1062
+ including any environment values or secret references it contains, never belongs in the
1063
+ observation or logs. `data.computeFleetCutoverDockerConstraintSetIdentity` sorts exact constraint
1064
+ strings in UTF-16 code-unit order, preserves duplicates, and binds both the resulting list and its
1065
+ count. `data.snapshotFleetCutoverDockerDesiredTaskCount` admits only the non-negative safe integer
1066
+ from `ServiceStatus.DesiredTasks`; the producer must request service status and must not derive a
1067
+ replacement from task state. These helpers consume only the Docker response fields they name, so
1068
+ additional response properties do not make a newer daemon unreadable.
1069
+
1070
+ The only established historical service-identity ownership label is
1071
+ `data.fleetCutoverDockerLabelNames.serviceId`, whose exact value is `serve.zone.serviceId`.
1072
+ `ownershipLabels.platformOwner: null` means there is no known Docker-label evidence of a platform
1073
+ owner. It does not classify the service as an application, and neither a service name nor an
1074
+ unrecognized label supplies that classification. Cloudly's accepted administrator classification
1075
+ remains the authority for unlabeled legacy platform services.
1076
+
1059
1077
  Mount identity has three explicit canonical domains. `serve.zone/fleet-cutover-mount-source-identity`
1060
1078
  hashes `{ type, source }`, where `source` is the exact UTF-8 `Mount.Source` returned by Docker,
1061
1079
  including an empty string. `serve.zone/fleet-cutover-mount-target-identity` hashes `{ target }`,
@@ -1068,6 +1086,26 @@ ownership also prevents completion rather than omitting the container. Structura
1068
1086
  not establish freshness, authentication, manager consensus, ownership or Docker truth; Cloudly
1069
1087
  establishes those policy facts while dereferencing the accepted observations.
1070
1088
 
1089
+ Docker exposes two different volume sources. A service-spec `Mount.Source` is the logical volume
1090
+ name, while a local container `MountPoint.Source` is its physical host path and `MountPoint.Name`
1091
+ is the logical name. `data.createFleetCutoverDockerServiceMountDescriptor` and
1092
+ `data.createFleetCutoverDockerLocalMountDescriptor` therefore keep their exact raw-source mount
1093
+ identities distinct. They never substitute `Name` for `Source`. The local-only
1094
+ `data.createFleetCutoverDockerPhysicalMountEvidence` adds a value-free correlation from the
1095
+ physical `mountIdentityDigest` to the service-spec identity derived from exact `MountPoint.Name`
1096
+ for volumes, or exact `Source` for bind and tmpfs mounts. Its independently domain-separated
1097
+ correlation digest binds that pair. When a physical container states `mountCorrelations`, the list
1098
+ must be sorted, unique, bounded, and a complete one-to-one mapping of its mounts. The property is
1099
+ optional only so observations created before this evidence existed still parse; absence proves no
1100
+ join, and a cutover consumer requiring a physical/service match must refuse it. Unsupported local
1101
+ mount types and volume mounts without a non-empty `Name` cannot produce correlation evidence.
1102
+
1103
+ The shared exact classifier recognizes only a writable
1104
+ `/var/run/docker.sock` to `/var/run/docker.sock` bind as `docker-socket`, a read-only
1105
+ `/sys/fs/cgroup` to `/host/cgroup` bind as `cgroup`, every volume as `persistent-volume`, other
1106
+ binds as `bind`, and tmpfs as `tmpfs`. Other Docker types remain `unknown`; no path normalization
1107
+ or service-name inference is performed.
1108
+
1071
1109
  `data.IFleetCutoverScope` is the server-owned authoritative service set. It starts `incomplete`;
1072
1110
  every `complete` entry binds an exact Docker service id to the current service authority and an
1073
1111
  accepted classification authority. `unknown` cannot appear in a complete structural snapshot.
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/interfaces',
6
- version: '32.15.0',
6
+ version: '32.16.0',
7
7
  description: 'Shared TypeScript interfaces and TypedRequest contracts for the serve.zone ecosystem.'
8
8
  }
@@ -19,6 +19,7 @@ import { normalizeStorageRequest } from '../platform/storage.js';
19
19
  import type { ISecretVersionReference } from './secret.js';
20
20
  import {
21
21
  canonicalizeStrictJson,
22
+ compareWireTokens,
22
23
  createCanonicalJsonSha256Hex,
23
24
  } from '../private/canonicaljson.js';
24
25
  import {
@@ -82,6 +83,37 @@ export interface IFleetCutoverMountTargetIdentityInput {
82
83
  target: string;
83
84
  }
84
85
 
86
+ export interface IFleetCutoverDockerMountInput {
87
+ /** Raw Docker mount type. Unsupported values produce an explicit unknown descriptor. */
88
+ type: string;
89
+ /** Exact source bytes from the Docker object being described; never substitute another field. */
90
+ source: string;
91
+ /** Exact container target bytes from the Docker object being described. */
92
+ target: string;
93
+ readOnly: boolean;
94
+ }
95
+
96
+ /**
97
+ * Value-free correlation between one local physical mount and its service-spec mount identity.
98
+ * The two identities stay distinct: a Docker volume's local Source is a host path while its
99
+ * service Source and local Name are the logical volume name.
100
+ */
101
+ export interface IFleetCutoverPhysicalMountCorrelation {
102
+ physicalMountIdentityDigest: TSha256Digest;
103
+ serviceMountIdentityDigest: TSha256Digest;
104
+ correlationIdentityDigest: TSha256Digest;
105
+ }
106
+
107
+ export interface IFleetCutoverPhysicalMountEvidence {
108
+ mount: IFleetCutoverMountDescriptor;
109
+ correlation: IFleetCutoverPhysicalMountCorrelation;
110
+ }
111
+
112
+ export interface IFleetCutoverDockerConstraintSetIdentity {
113
+ constraintCount: number;
114
+ constraintSetDigest: TSha256Digest;
115
+ }
116
+
85
117
  export type TFleetCutoverDockerTaskState =
86
118
  | 'new'
87
119
  | 'allocated'
@@ -148,6 +180,11 @@ export interface IFleetCutoverPhysicalContainer {
148
180
  imageId: TSha256Digest;
149
181
  serviceIdLabel: string | null;
150
182
  mounts: IFleetCutoverMountDescriptor[];
183
+ /**
184
+ * Optional only for compatibility with observations predating correlation evidence. When
185
+ * present it is a complete one-to-one mapping of mounts. Absence establishes no mount join.
186
+ */
187
+ mountCorrelations?: IFleetCutoverPhysicalMountCorrelation[];
151
188
  }
152
189
 
153
190
  export interface IFleetCutoverContainerSetIdentity {
@@ -636,6 +673,9 @@ export const fleetCutoverContract = Object.freeze({
636
673
  maximumNodes: 1024,
637
674
  maximumContainersPerNode: 512,
638
675
  maximumMountsPerOwner: 32,
676
+ maximumConstraintsPerService: 256,
677
+ maximumDockerTextBytes: 4096,
678
+ maximumDockerServiceSpecCanonicalBytes: 1_048_576,
639
679
  maximumPreparedVolumeHandoffMutationEntries: 32,
640
680
  /** Every admitted task has one node placement and at most 32 mounts: 4096 * 32. */
641
681
  maximumPreparedVolumeHandoffs: 4096 * 32,
@@ -646,6 +686,9 @@ export const fleetCutoverContract = Object.freeze({
646
686
  mountSourceIdentityDigestDomain: 'serve.zone/fleet-cutover-mount-source-identity',
647
687
  mountTargetIdentityDigestDomain: 'serve.zone/fleet-cutover-mount-target-identity',
648
688
  mountIdentityDigestDomain: 'serve.zone/fleet-cutover-mount-identity',
689
+ mountCorrelationIdentityDigestDomain: 'serve.zone/fleet-cutover-mount-correlation-identity',
690
+ dockerServiceSpecDigestDomain: 'serve.zone/fleet-cutover-docker-service-spec',
691
+ dockerConstraintSetDigestDomain: 'serve.zone/fleet-cutover-docker-constraint-set',
649
692
  containerSetIdentityDigestDomain: 'serve.zone/fleet-cutover-container-set-identity',
650
693
  localNodeSnapshotDigestDomain: 'serve.zone/fleet-cutover-local-node-snapshot',
651
694
  managerSnapshotDigestDomain: 'serve.zone/fleet-cutover-manager-snapshot',
@@ -673,6 +716,11 @@ export const fleetCutoverContract = Object.freeze({
673
716
  } satisfies Record<TClusterRuntimePhase, TClusterRuntimePhase | null>),
674
717
  } as const);
675
718
 
719
+ /** Docker labels with established historical producer semantics. */
720
+ export const fleetCutoverDockerLabelNames = Object.freeze({
721
+ serviceId: 'serve.zone.serviceId',
722
+ } as const);
723
+
676
724
  const fail = (): never => { throw new Error('Fleet cutover contract rejected.'); };
677
725
  const safe = <T>(operationArg: () => T): T => {
678
726
  try { return operationArg(); } catch { return fail(); }
@@ -764,6 +812,28 @@ const sortedMountDescriptors = (valueArg: unknown): IFleetCutoverMountDescriptor
764
812
  networkList(valueArg, fleetCutoverContract.maximumMountsPerOwner, mountDescriptor),
765
813
  itemArg => itemArg.mountIdentityDigest,
766
814
  );
815
+ const physicalMountCorrelation = (
816
+ valueArg: unknown,
817
+ ): IFleetCutoverPhysicalMountCorrelation => {
818
+ const value = networkRecord(valueArg, [
819
+ 'physicalMountIdentityDigest', 'serviceMountIdentityDigest', 'correlationIdentityDigest',
820
+ ]);
821
+ return {
822
+ physicalMountIdentityDigest: networkDigest(value.physicalMountIdentityDigest),
823
+ serviceMountIdentityDigest: networkDigest(value.serviceMountIdentityDigest),
824
+ correlationIdentityDigest: networkDigest(value.correlationIdentityDigest),
825
+ };
826
+ };
827
+ const sortedPhysicalMountCorrelations = (
828
+ valueArg: unknown,
829
+ ): IFleetCutoverPhysicalMountCorrelation[] => networkSorted(
830
+ networkList(
831
+ valueArg,
832
+ fleetCutoverContract.maximumMountsPerOwner,
833
+ physicalMountCorrelation,
834
+ ),
835
+ itemArg => itemArg.physicalMountIdentityDigest,
836
+ );
767
837
  const dockerMode = (valueArg: unknown): TFleetCutoverDockerServiceMode => {
768
838
  requireNetwork(valueArg !== null && typeof valueArg === 'object');
769
839
  const kind = (valueArg as Record<string, unknown>).kind;
@@ -788,6 +858,8 @@ const dockerService = (valueArg: unknown): IFleetCutoverDockerService => {
788
858
  'dockerServiceId', 'dockerServiceName', 'specVersion', 'specDigest', 'imageDigest', 'mode',
789
859
  'desiredTaskCount', 'constraintCount', 'constraintSetDigest', 'mounts', 'ownershipLabels',
790
860
  ]);
861
+ const constraintCount = nonNegative(value.constraintCount);
862
+ requireNetwork(constraintCount <= fleetCutoverContract.maximumConstraintsPerService);
791
863
  return {
792
864
  dockerServiceId: networkId(value.dockerServiceId),
793
865
  dockerServiceName: networkId(value.dockerServiceName),
@@ -796,7 +868,7 @@ const dockerService = (valueArg: unknown): IFleetCutoverDockerService => {
796
868
  imageDigest: networkDigest(value.imageDigest),
797
869
  mode: dockerMode(value.mode),
798
870
  desiredTaskCount: nonNegative(value.desiredTaskCount),
799
- constraintCount: nonNegative(value.constraintCount),
871
+ constraintCount,
800
872
  constraintSetDigest: networkDigest(value.constraintSetDigest),
801
873
  mounts: sortedMountDescriptors(value.mounts),
802
874
  ownershipLabels: ownershipLabels(value.ownershipLabels),
@@ -833,8 +905,8 @@ const dockerTask = (valueArg: unknown): IFleetCutoverDockerTask => {
833
905
  const physicalContainer = (valueArg: unknown): IFleetCutoverPhysicalContainer => {
834
906
  const value = networkRecord(valueArg, [
835
907
  'dockerContainerId', 'dockerServiceId', 'dockerTaskId', 'imageId', 'serviceIdLabel', 'mounts',
836
- ]);
837
- return {
908
+ ], ['mountCorrelations']);
909
+ const result: IFleetCutoverPhysicalContainer = {
838
910
  dockerContainerId: networkId(value.dockerContainerId),
839
911
  dockerServiceId: value.dockerServiceId === null ? null : networkId(value.dockerServiceId),
840
912
  dockerTaskId: value.dockerTaskId === null ? null : networkId(value.dockerTaskId),
@@ -842,6 +914,10 @@ const physicalContainer = (valueArg: unknown): IFleetCutoverPhysicalContainer =>
842
914
  serviceIdLabel: value.serviceIdLabel === null ? null : networkId(value.serviceIdLabel),
843
915
  mounts: sortedMountDescriptors(value.mounts),
844
916
  };
917
+ if (Object.hasOwn(value, 'mountCorrelations')) {
918
+ result.mountCorrelations = sortedPhysicalMountCorrelations(value.mountCorrelations);
919
+ }
920
+ return result;
845
921
  };
846
922
  const inventory = (valueArg: unknown): IFleetCutoverDockerInventory => {
847
923
  const value = networkRecord(valueArg, [
@@ -925,11 +1001,17 @@ const canonicalDigest = async (domainArg: string, valueArg: object): Promise<TSh
925
1001
 
926
1002
  const exactDockerText = (valueArg: unknown, allowEmptyArg: boolean): string => {
927
1003
  requireNetwork(typeof valueArg === 'string'
928
- && new TextEncoder().encode(valueArg).length <= 4096
1004
+ && new TextEncoder().encode(valueArg).length <= fleetCutoverContract.maximumDockerTextBytes
929
1005
  && (allowEmptyArg || valueArg.length > 0));
930
1006
  return valueArg;
931
1007
  };
932
1008
 
1009
+ const openDockerRecord = (valueArg: unknown): Record<string, unknown> => {
1010
+ const value = captureNetwork(valueArg);
1011
+ requireNetwork(value !== null && typeof value === 'object' && !Array.isArray(value));
1012
+ return value as Record<string, unknown>;
1013
+ };
1014
+
933
1015
  const computeExactIdentityDigest = async (
934
1016
  domainArg: string,
935
1017
  inputArg: object,
@@ -938,6 +1020,160 @@ const computeExactIdentityDigest = async (
938
1020
  input: inputArg,
939
1021
  }, fail), fail)}`;
940
1022
 
1023
+ /**
1024
+ * Hash the complete open-schema Docker Service.Spec object. Unknown response properties are
1025
+ * intentionally included: this helper returns only the digest and never a retained projection.
1026
+ */
1027
+ export const computeFleetCutoverDockerServiceSpecDigest = async (
1028
+ valueArg: unknown,
1029
+ ): Promise<TSha256Digest> => {
1030
+ try {
1031
+ const spec = openDockerRecord(valueArg);
1032
+ const canonicalSpec = canonicalizeStrictJson(spec, fail, 'Docker Service.Spec');
1033
+ requireNetwork(new TextEncoder().encode(canonicalSpec).byteLength
1034
+ <= fleetCutoverContract.maximumDockerServiceSpecCanonicalBytes);
1035
+ return await computeExactIdentityDigest(
1036
+ fleetCutoverContract.dockerServiceSpecDigestDomain,
1037
+ { spec },
1038
+ );
1039
+ } catch {
1040
+ return fail();
1041
+ }
1042
+ };
1043
+
1044
+ /** Sort exact constraint strings while preserving duplicate entries, then return value-free identity. */
1045
+ export const computeFleetCutoverDockerConstraintSetIdentity = async (
1046
+ valueArg: unknown,
1047
+ ): Promise<IFleetCutoverDockerConstraintSetIdentity> => {
1048
+ try {
1049
+ const captured = captureNetwork(valueArg);
1050
+ requireNetwork(Array.isArray(captured)
1051
+ && captured.length <= fleetCutoverContract.maximumConstraintsPerService);
1052
+ const constraints = captured.map(itemArg => exactDockerText(itemArg, false))
1053
+ .sort(compareWireTokens);
1054
+ return {
1055
+ constraintCount: constraints.length,
1056
+ constraintSetDigest: await computeExactIdentityDigest(
1057
+ fleetCutoverContract.dockerConstraintSetDigestDomain,
1058
+ { constraints },
1059
+ ),
1060
+ };
1061
+ } catch {
1062
+ return fail();
1063
+ }
1064
+ };
1065
+
1066
+ export const snapshotFleetCutoverDockerDesiredTaskCount = (valueArg: unknown): number =>
1067
+ safe(() => nonNegative(valueArg));
1068
+
1069
+ export const validateFleetCutoverDockerDesiredTaskCount = (valueArg: unknown): string[] => (
1070
+ typeof valueArg === 'number' && Number.isSafeInteger(valueArg) && valueArg >= 0
1071
+ ? []
1072
+ : ['Docker ServiceStatus.DesiredTasks must be a non-negative safe integer']
1073
+ );
1074
+
1075
+ /** Apply only the documented exact cutover mount classifications; no path normalization occurs. */
1076
+ export const classifyFleetCutoverDockerMount = (
1077
+ valueArg: IFleetCutoverDockerMountInput,
1078
+ ): Pick<IFleetCutoverMountDescriptor, 'type' | 'classification'> => safe(() => {
1079
+ const value = networkRecord(valueArg, ['type', 'source', 'target', 'readOnly']);
1080
+ const rawType = exactDockerText(value.type, false);
1081
+ const source = exactDockerText(value.source, true);
1082
+ const target = exactDockerText(value.target, false);
1083
+ requireNetwork(typeof value.readOnly === 'boolean');
1084
+ if (rawType === 'volume') return { type: 'volume', classification: 'persistent-volume' };
1085
+ if (rawType === 'tmpfs') return { type: 'tmpfs', classification: 'tmpfs' };
1086
+ if (rawType !== 'bind') return { type: 'unknown', classification: 'unknown' };
1087
+ if (source === '/var/run/docker.sock'
1088
+ && target === '/var/run/docker.sock'
1089
+ && value.readOnly === false) {
1090
+ return { type: 'bind', classification: 'docker-socket' };
1091
+ }
1092
+ if (source === '/sys/fs/cgroup'
1093
+ && target === '/host/cgroup'
1094
+ && value.readOnly === true) {
1095
+ return { type: 'bind', classification: 'cgroup' };
1096
+ }
1097
+ return { type: 'bind', classification: 'bind' };
1098
+ });
1099
+
1100
+ export const createFleetCutoverMountDescriptor = async (
1101
+ valueArg: IFleetCutoverDockerMountInput,
1102
+ ): Promise<IFleetCutoverMountDescriptor> => {
1103
+ try {
1104
+ const value = networkRecord(valueArg, ['type', 'source', 'target', 'readOnly']);
1105
+ const source = exactDockerText(value.source, true);
1106
+ const target = exactDockerText(value.target, false);
1107
+ requireNetwork(typeof value.readOnly === 'boolean');
1108
+ const shape = classifyFleetCutoverDockerMount({
1109
+ type: exactDockerText(value.type, false),
1110
+ source,
1111
+ target,
1112
+ readOnly: value.readOnly,
1113
+ });
1114
+ requireNetwork(shape.type === 'tmpfs' ? source.length === 0 : source.length > 0);
1115
+ const identityInput: Omit<IFleetCutoverMountDescriptor, 'mountIdentityDigest'> = {
1116
+ ...shape,
1117
+ sourceIdentityDigest: await computeFleetCutoverMountSourceIdentityDigest({
1118
+ type: shape.type,
1119
+ source,
1120
+ }),
1121
+ targetIdentityDigest: await computeFleetCutoverMountTargetIdentityDigest({ target }),
1122
+ readOnly: value.readOnly,
1123
+ };
1124
+ return {
1125
+ ...identityInput,
1126
+ mountIdentityDigest: await computeFleetCutoverMountIdentityDigest(identityInput),
1127
+ };
1128
+ } catch {
1129
+ return fail();
1130
+ }
1131
+ };
1132
+
1133
+ const dockerServiceMountInput = (valueArg: unknown): IFleetCutoverDockerMountInput => {
1134
+ const value = openDockerRecord(valueArg);
1135
+ const type = exactDockerText(value.Type, false);
1136
+ const source = value.Source === undefined ? '' : exactDockerText(value.Source, true);
1137
+ const target = exactDockerText(value.Target, false);
1138
+ const readOnly = value.ReadOnly === undefined ? false : value.ReadOnly;
1139
+ requireNetwork(typeof readOnly === 'boolean');
1140
+ return { type, source, target, readOnly };
1141
+ };
1142
+
1143
+ interface IFleetCutoverDockerLocalMountInput {
1144
+ descriptorInput: IFleetCutoverDockerMountInput;
1145
+ logicalSource: string;
1146
+ }
1147
+
1148
+ const dockerLocalMountInput = (valueArg: unknown): IFleetCutoverDockerLocalMountInput => {
1149
+ const value = openDockerRecord(valueArg);
1150
+ const type = exactDockerText(value.Type, false);
1151
+ const source = exactDockerText(value.Source, true);
1152
+ const target = exactDockerText(value.Destination, false);
1153
+ requireNetwork(typeof value.RW === 'boolean');
1154
+ const logicalSource = type === 'volume'
1155
+ ? exactDockerText(value.Name, false)
1156
+ : source;
1157
+ return {
1158
+ descriptorInput: { type, source, target, readOnly: !value.RW },
1159
+ logicalSource,
1160
+ };
1161
+ };
1162
+
1163
+ /** Read only consumed Service.Spec Mount fields; future Docker response properties are ignored. */
1164
+ export const createFleetCutoverDockerServiceMountDescriptor = async (
1165
+ valueArg: unknown,
1166
+ ): Promise<IFleetCutoverMountDescriptor> => createFleetCutoverMountDescriptor(
1167
+ dockerServiceMountInput(valueArg),
1168
+ );
1169
+
1170
+ /** Read only consumed local MountPoint fields and retain the exact physical Source identity. */
1171
+ export const createFleetCutoverDockerLocalMountDescriptor = async (
1172
+ valueArg: unknown,
1173
+ ): Promise<IFleetCutoverMountDescriptor> => createFleetCutoverMountDescriptor(
1174
+ dockerLocalMountInput(valueArg).descriptorInput,
1175
+ );
1176
+
941
1177
  export const computeFleetCutoverMountSourceIdentityDigest = async (
942
1178
  valueArg: IFleetCutoverMountSourceIdentityInput,
943
1179
  ): Promise<TSha256Digest> => safe(async () => {
@@ -973,6 +1209,54 @@ export const computeFleetCutoverMountIdentityDigest = async (
973
1209
  });
974
1210
  });
975
1211
 
1212
+ export const computeFleetCutoverMountCorrelationIdentityDigest = async (
1213
+ valueArg: Omit<IFleetCutoverPhysicalMountCorrelation, 'correlationIdentityDigest'>,
1214
+ ): Promise<TSha256Digest> => safe(async () => {
1215
+ const value = networkRecord(valueArg, [
1216
+ 'physicalMountIdentityDigest', 'serviceMountIdentityDigest',
1217
+ ]);
1218
+ return computeExactIdentityDigest(fleetCutoverContract.mountCorrelationIdentityDigestDomain, {
1219
+ physicalMountIdentityDigest: networkDigest(value.physicalMountIdentityDigest),
1220
+ serviceMountIdentityDigest: networkDigest(value.serviceMountIdentityDigest),
1221
+ });
1222
+ });
1223
+
1224
+ /**
1225
+ * Produce both the exact local physical descriptor and its logical service-mount correlation.
1226
+ * For a volume only MountPoint.Name supplies the logical source. Unknown types and a missing or
1227
+ * empty volume Name are refused because they cannot establish a truthful service join.
1228
+ */
1229
+ export const createFleetCutoverDockerPhysicalMountEvidence = async (
1230
+ valueArg: unknown,
1231
+ ): Promise<IFleetCutoverPhysicalMountEvidence> => {
1232
+ try {
1233
+ const value = dockerLocalMountInput(valueArg);
1234
+ requireNetwork(['volume', 'bind', 'tmpfs'].includes(value.descriptorInput.type));
1235
+ const mount = await createFleetCutoverMountDescriptor(value.descriptorInput);
1236
+ const serviceMount = await createFleetCutoverMountDescriptor({
1237
+ ...value.descriptorInput,
1238
+ source: value.logicalSource,
1239
+ });
1240
+ const correlationInput: Omit<
1241
+ IFleetCutoverPhysicalMountCorrelation,
1242
+ 'correlationIdentityDigest'
1243
+ > = {
1244
+ physicalMountIdentityDigest: mount.mountIdentityDigest,
1245
+ serviceMountIdentityDigest: serviceMount.mountIdentityDigest,
1246
+ };
1247
+ return {
1248
+ mount,
1249
+ correlation: {
1250
+ ...correlationInput,
1251
+ correlationIdentityDigest:
1252
+ await computeFleetCutoverMountCorrelationIdentityDigest(correlationInput),
1253
+ },
1254
+ };
1255
+ } catch {
1256
+ return fail();
1257
+ }
1258
+ };
1259
+
976
1260
  const validateMountDescriptors = async (
977
1261
  mountsArg: IFleetCutoverMountDescriptor[],
978
1262
  ): Promise<void> => {
@@ -982,6 +1266,28 @@ const validateMountDescriptors = async (
982
1266
  }
983
1267
  };
984
1268
 
1269
+ const validatePhysicalMountCorrelations = async (
1270
+ containerArg: IFleetCutoverPhysicalContainer,
1271
+ ): Promise<void> => {
1272
+ if (containerArg.mountCorrelations === undefined) return;
1273
+ requireNetwork(containerArg.mountCorrelations.length === containerArg.mounts.length);
1274
+ const physicalMountIdentities = new Set(
1275
+ containerArg.mounts.map(itemArg => itemArg.mountIdentityDigest),
1276
+ );
1277
+ const serviceMountIdentities = new Set(
1278
+ containerArg.mountCorrelations.map(itemArg => itemArg.serviceMountIdentityDigest),
1279
+ );
1280
+ requireNetwork(physicalMountIdentities.size === containerArg.mounts.length
1281
+ && serviceMountIdentities.size === containerArg.mountCorrelations.length
1282
+ && containerArg.mountCorrelations.every(itemArg =>
1283
+ physicalMountIdentities.has(itemArg.physicalMountIdentityDigest)));
1284
+ for (const correlation of containerArg.mountCorrelations) {
1285
+ const { correlationIdentityDigest, ...input } = correlation;
1286
+ requireNetwork(correlationIdentityDigest
1287
+ === await computeFleetCutoverMountCorrelationIdentityDigest(input));
1288
+ }
1289
+ };
1290
+
985
1291
  export const computeFleetCutoverContainerSetIdentityDigest = async (
986
1292
  containerIdsArg: string[],
987
1293
  ): Promise<TSha256Digest> => safe(async () => {
@@ -1046,7 +1352,10 @@ export const validateFleetCutoverLocalNodeSnapshot = async (
1046
1352
  );
1047
1353
  requireNetwork(value.before.containerIdsDigest === containerIdsDigest
1048
1354
  && value.after.containerIdsDigest === containerIdsDigest);
1049
- for (const container of value.containers) await validateMountDescriptors(container.mounts);
1355
+ for (const container of value.containers) {
1356
+ await validateMountDescriptors(container.mounts);
1357
+ await validatePhysicalMountCorrelations(container);
1358
+ }
1050
1359
  return value.snapshotDigest === await computeFleetCutoverLocalNodeSnapshotDigest(value)
1051
1360
  ? [] : ['fleet cutover local node snapshot digest does not match'];
1052
1361
  } catch {