@serve.zone/interfaces 32.1.0 → 32.3.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 +41 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/data/clusternode.d.ts +21 -0
- package/dist_ts/data/clusternode.js +345 -11
- package/dist_ts/platform/storage.d.ts +48 -2
- package/dist_ts/platform/storage.golden.d.ts +31 -0
- package/dist_ts/platform/storage.golden.js +84 -0
- package/dist_ts/platform/storage.js +57 -1
- package/dist_ts/platform/storagemigration.js +11 -287
- package/dist_ts/private/storagecontract.d.ts +40 -0
- package/dist_ts/private/storagecontract.js +399 -0
- package/package.json +1 -1
- package/readme.md +50 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/data/clusternode.ts +457 -10
- package/ts/platform/storage.golden.ts +97 -0
- package/ts/platform/storage.ts +81 -2
- package/ts/platform/storagemigration.ts +31 -440
- package/ts/private/storagecontract.ts +623 -0
package/ts/data/clusternode.ts
CHANGED
|
@@ -6,7 +6,11 @@ import {
|
|
|
6
6
|
canonicalizeStrictJson,
|
|
7
7
|
createCanonicalJsonSha256Hex,
|
|
8
8
|
} from '../private/canonicaljson.js';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
hasExactKeys,
|
|
11
|
+
readDataElements,
|
|
12
|
+
readDataProperties,
|
|
13
|
+
} from '../private/dataproperties.js';
|
|
10
14
|
|
|
11
15
|
export interface IClusterNodeMetrics {
|
|
12
16
|
cpuUsagePercent: number;
|
|
@@ -610,6 +614,29 @@ export const validateSparkSwarmObservation = async (
|
|
|
610
614
|
}
|
|
611
615
|
};
|
|
612
616
|
|
|
617
|
+
/**
|
|
618
|
+
* Which node is reporting, in the two members every Spark route body carries.
|
|
619
|
+
*
|
|
620
|
+
* All four routes authenticate the same reporter from the same pair, so the rule lives here: a
|
|
621
|
+
* copy per route is how one route ends up admitting a bearer another refuses.
|
|
622
|
+
*/
|
|
623
|
+
const validateSparkReporterIdentity = (
|
|
624
|
+
requestArg: Record<string, unknown>,
|
|
625
|
+
labelArg: string,
|
|
626
|
+
): string[] => {
|
|
627
|
+
const errors: string[] = [];
|
|
628
|
+
if (!isRuntimeIdentifier(requestArg.nodeId)) {
|
|
629
|
+
errors.push(`${labelArg} nodeId must be canonical`);
|
|
630
|
+
}
|
|
631
|
+
if (typeof requestArg.nodeToken !== 'string'
|
|
632
|
+
|| requestArg.nodeToken.length < 1
|
|
633
|
+
|| requestArg.nodeToken.length > 512
|
|
634
|
+
|| requestArg.nodeToken.trim() !== requestArg.nodeToken) {
|
|
635
|
+
errors.push(`${labelArg} nodeToken must be a bounded opaque token`);
|
|
636
|
+
}
|
|
637
|
+
return errors;
|
|
638
|
+
};
|
|
639
|
+
|
|
613
640
|
export const validateSparkSwarmObservationRequest = async (
|
|
614
641
|
requestArg: unknown,
|
|
615
642
|
): Promise<string[]> => {
|
|
@@ -632,15 +659,10 @@ export const validateSparkSwarmObservationRequest = async (
|
|
|
632
659
|
'Spark Swarm observation request protocol',
|
|
633
660
|
);
|
|
634
661
|
errors.push(...await validateSparkSwarmObservation(requestArg.observation));
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|| requestArg.nodeToken.length < 1
|
|
640
|
-
|| requestArg.nodeToken.length > 512
|
|
641
|
-
|| requestArg.nodeToken.trim() !== requestArg.nodeToken) {
|
|
642
|
-
errors.push('Spark Swarm observation request nodeToken must be a bounded opaque token');
|
|
643
|
-
}
|
|
662
|
+
errors.push(...validateSparkReporterIdentity(
|
|
663
|
+
requestArg,
|
|
664
|
+
'Spark Swarm observation request',
|
|
665
|
+
));
|
|
644
666
|
return errors;
|
|
645
667
|
} catch {
|
|
646
668
|
return ['Spark Swarm observation request must be safely inspectable'];
|
|
@@ -735,6 +757,431 @@ export const validateSparkSwarmObservationResponse = async (
|
|
|
735
757
|
}
|
|
736
758
|
};
|
|
737
759
|
|
|
760
|
+
/** A runtime description or a service name: bounded, and free of bytes JSON text cannot carry. */
|
|
761
|
+
const maximumSparkRuntimeTextLength = 512;
|
|
762
|
+
/** An operator-facing message, which may hold line breaks but is still bounded. */
|
|
763
|
+
const maximumSparkMessageTextLength = 2048;
|
|
764
|
+
/** `sparkSwarmNodeContracts.actionResult` states what a server keeps of an action's output. */
|
|
765
|
+
const maximumSparkActionResultTextLength = 4000;
|
|
766
|
+
/**
|
|
767
|
+
* The route's own byte bound refuses an oversized runtime description first; this stops an
|
|
768
|
+
* in-process caller from making the validator walk a list no route could have delivered.
|
|
769
|
+
*/
|
|
770
|
+
const maximumSparkRuntimeServices = 64;
|
|
771
|
+
|
|
772
|
+
const sparkCloudlyConnectionStatuses = new Set<TSparkCloudlyConnectionStatus>([
|
|
773
|
+
'not-configured',
|
|
774
|
+
'connecting',
|
|
775
|
+
'connected',
|
|
776
|
+
'failed',
|
|
777
|
+
]);
|
|
778
|
+
const sparkNodeModes = new Set<TSparkNodeMode>(['cloudly', 'coreflow-node']);
|
|
779
|
+
const sparkActionResultStatuses = new Set<TNodeActionStatus>([
|
|
780
|
+
'running',
|
|
781
|
+
'succeeded',
|
|
782
|
+
'failed',
|
|
783
|
+
]);
|
|
784
|
+
|
|
785
|
+
const isBoundedText = (
|
|
786
|
+
valueArg: unknown,
|
|
787
|
+
maximumLengthArg = maximumSparkRuntimeTextLength,
|
|
788
|
+
): valueArg is string => (
|
|
789
|
+
typeof valueArg === 'string'
|
|
790
|
+
&& valueArg.length > 0
|
|
791
|
+
&& valueArg.length <= maximumLengthArg
|
|
792
|
+
&& !/[\u0000-\u001f\u007f]/u.test(valueArg)
|
|
793
|
+
);
|
|
794
|
+
|
|
795
|
+
/** Message text a node reports: line breaks and tabs are its content, other control bytes are not. */
|
|
796
|
+
const isBoundedMessageText = (
|
|
797
|
+
valueArg: unknown,
|
|
798
|
+
maximumLengthArg: number,
|
|
799
|
+
): valueArg is string => (
|
|
800
|
+
typeof valueArg === 'string'
|
|
801
|
+
&& valueArg.length <= maximumLengthArg
|
|
802
|
+
&& !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(valueArg)
|
|
803
|
+
);
|
|
804
|
+
|
|
805
|
+
const isNonNegativeNumber = (
|
|
806
|
+
valueArg: unknown,
|
|
807
|
+
maximumArg = Number.MAX_SAFE_INTEGER,
|
|
808
|
+
): valueArg is number => (
|
|
809
|
+
typeof valueArg === 'number'
|
|
810
|
+
&& Number.isFinite(valueArg)
|
|
811
|
+
&& !Object.is(valueArg, -0)
|
|
812
|
+
&& valueArg >= 0
|
|
813
|
+
&& valueArg <= maximumArg
|
|
814
|
+
);
|
|
815
|
+
|
|
816
|
+
const isNonNegativeSafeInteger = (valueArg: unknown): valueArg is number => (
|
|
817
|
+
Number.isSafeInteger(valueArg) && !Object.is(valueArg, -0) && (valueArg as number) >= 0
|
|
818
|
+
);
|
|
819
|
+
|
|
820
|
+
/** A record a Spark body states, detached from the caller's object, or `null` with the reason. */
|
|
821
|
+
interface ISparkRecordReading {
|
|
822
|
+
record: Record<string, unknown> | null;
|
|
823
|
+
errors: string[];
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* One level of a Spark body: own enumerable data properties only, then the exact key set.
|
|
828
|
+
*
|
|
829
|
+
* The heartbeat, metrics-sample and action-result bodies are judged member by member rather than
|
|
830
|
+
* as canonical bytes, because only the observation is digested and those three legitimately carry
|
|
831
|
+
* fractional CPU, memory and rate observations that the digest's integer encoding would refuse.
|
|
832
|
+
* What the canonical reader also gave — a body that cannot run code or answer differently on a
|
|
833
|
+
* second read — is kept here by copying the data properties away from the caller's object before
|
|
834
|
+
* any member is judged.
|
|
835
|
+
*/
|
|
836
|
+
const readSparkRecord = (
|
|
837
|
+
valueArg: unknown,
|
|
838
|
+
labelArg: string,
|
|
839
|
+
requiredKeysArg: readonly string[],
|
|
840
|
+
optionalKeysArg: readonly string[] = [],
|
|
841
|
+
): ISparkRecordReading => {
|
|
842
|
+
const record = readDataProperties(valueArg);
|
|
843
|
+
if (record === null) {
|
|
844
|
+
return { record: null, errors: [`${labelArg} must contain plain JSON data properties`] };
|
|
845
|
+
}
|
|
846
|
+
if (!hasExactKeys(record, requiredKeysArg, optionalKeysArg)) {
|
|
847
|
+
return { record: null, errors: [`${labelArg} must use its exact schema`] };
|
|
848
|
+
}
|
|
849
|
+
return { record, errors: [] };
|
|
850
|
+
};
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* The envelope a Spark route body states before its payload: the exact key set, the reporter's
|
|
854
|
+
* protocol offer read by name, and the reporter's identity — in that order, because a server
|
|
855
|
+
* answers an unreadable offer before it looks at anything the body claims about a node.
|
|
856
|
+
*/
|
|
857
|
+
const readSparkRouteEnvelope = (
|
|
858
|
+
requestArg: unknown,
|
|
859
|
+
labelArg: string,
|
|
860
|
+
requiredKeysArg: readonly string[],
|
|
861
|
+
optionalKeysArg: readonly string[] = [],
|
|
862
|
+
): ISparkRecordReading => {
|
|
863
|
+
const reading = readSparkRecord(requestArg, labelArg, requiredKeysArg, optionalKeysArg);
|
|
864
|
+
if (reading.record === null) {
|
|
865
|
+
return reading;
|
|
866
|
+
}
|
|
867
|
+
return {
|
|
868
|
+
record: reading.record,
|
|
869
|
+
errors: [
|
|
870
|
+
...validateProtocolOfferOn(reading.record, `${labelArg} protocol`),
|
|
871
|
+
...validateSparkReporterIdentity(reading.record, labelArg),
|
|
872
|
+
],
|
|
873
|
+
};
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* One reason per member, collected rather than thrown.
|
|
878
|
+
*
|
|
879
|
+
* A Spark route answers a refusal before the node is authenticated, so the reporter only ever
|
|
880
|
+
* learns which member of its own body was wrong; naming that member is what lets an operator fix
|
|
881
|
+
* a node without reading the server's source.
|
|
882
|
+
*/
|
|
883
|
+
const createSparkFieldErrors = (
|
|
884
|
+
recordArg: Record<string, unknown>,
|
|
885
|
+
labelArg: string,
|
|
886
|
+
) => {
|
|
887
|
+
const errors: string[] = [];
|
|
888
|
+
return {
|
|
889
|
+
errors,
|
|
890
|
+
number: (keyArg: string, maximumArg?: number): void => {
|
|
891
|
+
if (!isNonNegativeNumber(recordArg[keyArg], maximumArg)) {
|
|
892
|
+
errors.push(`${labelArg} ${keyArg} must be a bounded non-negative number`);
|
|
893
|
+
}
|
|
894
|
+
},
|
|
895
|
+
percentage: (keyArg: string): void => {
|
|
896
|
+
if (!isNonNegativeNumber(recordArg[keyArg], 100)) {
|
|
897
|
+
errors.push(`${labelArg} ${keyArg} must be a percentage between 0 and 100`);
|
|
898
|
+
}
|
|
899
|
+
},
|
|
900
|
+
count: (keyArg: string): void => {
|
|
901
|
+
if (!isNonNegativeSafeInteger(recordArg[keyArg])) {
|
|
902
|
+
errors.push(`${labelArg} ${keyArg} must be a non-negative safe integer`);
|
|
903
|
+
}
|
|
904
|
+
},
|
|
905
|
+
instant: (keyArg: string): void => {
|
|
906
|
+
if (!isPositiveSafeInteger(recordArg[keyArg])) {
|
|
907
|
+
errors.push(`${labelArg} ${keyArg} must be a positive safe integer timestamp`);
|
|
908
|
+
}
|
|
909
|
+
},
|
|
910
|
+
text: (keyArg: string): void => {
|
|
911
|
+
if (!isBoundedText(recordArg[keyArg])) {
|
|
912
|
+
errors.push(`${labelArg} ${keyArg} must be bounded non-empty text`);
|
|
913
|
+
}
|
|
914
|
+
},
|
|
915
|
+
messageText: (keyArg: string, maximumLengthArg: number): void => {
|
|
916
|
+
if (!isBoundedMessageText(recordArg[keyArg], maximumLengthArg)) {
|
|
917
|
+
errors.push(`${labelArg} ${keyArg} must be bounded message text`);
|
|
918
|
+
}
|
|
919
|
+
},
|
|
920
|
+
identifier: (keyArg: string): void => {
|
|
921
|
+
if (!isRuntimeIdentifier(recordArg[keyArg])) {
|
|
922
|
+
errors.push(`${labelArg} ${keyArg} must be canonical`);
|
|
923
|
+
}
|
|
924
|
+
},
|
|
925
|
+
boolean: (keyArg: string): void => {
|
|
926
|
+
if (typeof recordArg[keyArg] !== 'boolean') {
|
|
927
|
+
errors.push(`${labelArg} ${keyArg} must be a boolean`);
|
|
928
|
+
}
|
|
929
|
+
},
|
|
930
|
+
member: <TMember extends string>(keyArg: string, allowedArg: ReadonlySet<TMember>): void => {
|
|
931
|
+
if (!allowedArg.has(recordArg[keyArg] as TMember)) {
|
|
932
|
+
errors.push(`${labelArg} ${keyArg} is outside the supported values`);
|
|
933
|
+
}
|
|
934
|
+
},
|
|
935
|
+
/** Runs `checkArg` only when the optional member is present at all. */
|
|
936
|
+
optional: (keyArg: string, checkArg: (keyArg: string) => void): void => {
|
|
937
|
+
if (recordArg[keyArg] !== undefined) {
|
|
938
|
+
checkArg(keyArg);
|
|
939
|
+
}
|
|
940
|
+
},
|
|
941
|
+
};
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
const validateSparkNodeMetrics = (valueArg: unknown, labelArg: string): string[] => {
|
|
945
|
+
const reading = readSparkRecord(valueArg, labelArg, [
|
|
946
|
+
'cpuUsagePercent',
|
|
947
|
+
'memoryUsedMB',
|
|
948
|
+
'memoryAvailableMB',
|
|
949
|
+
'diskUsedGB',
|
|
950
|
+
'diskAvailableGB',
|
|
951
|
+
'containerCount',
|
|
952
|
+
'timestamp',
|
|
953
|
+
]);
|
|
954
|
+
if (reading.record === null) {
|
|
955
|
+
return reading.errors;
|
|
956
|
+
}
|
|
957
|
+
const field = createSparkFieldErrors(reading.record, labelArg);
|
|
958
|
+
field.percentage('cpuUsagePercent');
|
|
959
|
+
field.number('memoryUsedMB');
|
|
960
|
+
field.number('memoryAvailableMB');
|
|
961
|
+
field.number('diskUsedGB');
|
|
962
|
+
field.number('diskAvailableGB');
|
|
963
|
+
field.count('containerCount');
|
|
964
|
+
field.instant('timestamp');
|
|
965
|
+
return field.errors;
|
|
966
|
+
};
|
|
967
|
+
|
|
968
|
+
const validateNodeMetricsSample = (valueArg: unknown, labelArg: string): string[] => {
|
|
969
|
+
const reading = readSparkRecord(
|
|
970
|
+
valueArg,
|
|
971
|
+
labelArg,
|
|
972
|
+
['timestamp', 'cpuUsagePercent', 'memoryUsedMB', 'memoryAvailableMB'],
|
|
973
|
+
['networkRxBytesPerSec', 'networkTxBytesPerSec'],
|
|
974
|
+
);
|
|
975
|
+
if (reading.record === null) {
|
|
976
|
+
return reading.errors;
|
|
977
|
+
}
|
|
978
|
+
const field = createSparkFieldErrors(reading.record, labelArg);
|
|
979
|
+
field.instant('timestamp');
|
|
980
|
+
field.percentage('cpuUsagePercent');
|
|
981
|
+
field.number('memoryUsedMB');
|
|
982
|
+
field.number('memoryAvailableMB');
|
|
983
|
+
field.optional('networkRxBytesPerSec', field.number);
|
|
984
|
+
field.optional('networkTxBytesPerSec', field.number);
|
|
985
|
+
return field.errors;
|
|
986
|
+
};
|
|
987
|
+
|
|
988
|
+
const validateServeZoneServiceRuntimeInfo = (
|
|
989
|
+
valueArg: unknown,
|
|
990
|
+
labelArg: string,
|
|
991
|
+
): string[] => {
|
|
992
|
+
const reading = readSparkRecord(
|
|
993
|
+
valueArg,
|
|
994
|
+
labelArg,
|
|
995
|
+
['name', 'running', 'checkedAt'],
|
|
996
|
+
[
|
|
997
|
+
'serviceId',
|
|
998
|
+
'image',
|
|
999
|
+
'desiredImage',
|
|
1000
|
+
'imageVersion',
|
|
1001
|
+
'runningImageId',
|
|
1002
|
+
'runningTaskCount',
|
|
1003
|
+
'updatedAt',
|
|
1004
|
+
'error',
|
|
1005
|
+
],
|
|
1006
|
+
);
|
|
1007
|
+
if (reading.record === null) {
|
|
1008
|
+
return reading.errors;
|
|
1009
|
+
}
|
|
1010
|
+
const field = createSparkFieldErrors(reading.record, labelArg);
|
|
1011
|
+
field.text('name');
|
|
1012
|
+
field.boolean('running');
|
|
1013
|
+
field.instant('checkedAt');
|
|
1014
|
+
field.optional('serviceId', field.text);
|
|
1015
|
+
field.optional('image', field.text);
|
|
1016
|
+
field.optional('desiredImage', field.text);
|
|
1017
|
+
field.optional('imageVersion', field.text);
|
|
1018
|
+
field.optional('runningImageId', field.text);
|
|
1019
|
+
field.optional('runningTaskCount', field.count);
|
|
1020
|
+
field.optional('updatedAt', field.instant);
|
|
1021
|
+
field.optional('error', (keyArg) => field.messageText(keyArg, maximumSparkMessageTextLength));
|
|
1022
|
+
return field.errors;
|
|
1023
|
+
};
|
|
1024
|
+
|
|
1025
|
+
const validateSparkNodeRuntimeInfo = (valueArg: unknown, labelArg: string): string[] => {
|
|
1026
|
+
const reading = readSparkRecord(
|
|
1027
|
+
valueArg,
|
|
1028
|
+
labelArg,
|
|
1029
|
+
[
|
|
1030
|
+
'runtime',
|
|
1031
|
+
'nodeId',
|
|
1032
|
+
'platform',
|
|
1033
|
+
'arch',
|
|
1034
|
+
'sparkVersion',
|
|
1035
|
+
'cloudlyConnectionStatus',
|
|
1036
|
+
'dockerAvailable',
|
|
1037
|
+
'checkedAt',
|
|
1038
|
+
],
|
|
1039
|
+
[
|
|
1040
|
+
'mode',
|
|
1041
|
+
'hostname',
|
|
1042
|
+
'osRelease',
|
|
1043
|
+
'cloudlyUrl',
|
|
1044
|
+
'swarmNodeId',
|
|
1045
|
+
'serveZoneServices',
|
|
1046
|
+
'lastError',
|
|
1047
|
+
],
|
|
1048
|
+
);
|
|
1049
|
+
if (reading.record === null) {
|
|
1050
|
+
return reading.errors;
|
|
1051
|
+
}
|
|
1052
|
+
const runtimeInfo = reading.record;
|
|
1053
|
+
const field = createSparkFieldErrors(runtimeInfo, labelArg);
|
|
1054
|
+
if (runtimeInfo.runtime !== 'spark') {
|
|
1055
|
+
field.errors.push(`${labelArg} runtime must be spark`);
|
|
1056
|
+
}
|
|
1057
|
+
field.identifier('nodeId');
|
|
1058
|
+
field.text('platform');
|
|
1059
|
+
field.text('arch');
|
|
1060
|
+
field.text('sparkVersion');
|
|
1061
|
+
field.member('cloudlyConnectionStatus', sparkCloudlyConnectionStatuses);
|
|
1062
|
+
field.boolean('dockerAvailable');
|
|
1063
|
+
field.instant('checkedAt');
|
|
1064
|
+
field.optional('mode', (keyArg) => field.member(keyArg, sparkNodeModes));
|
|
1065
|
+
field.optional('hostname', field.text);
|
|
1066
|
+
field.optional('osRelease', field.text);
|
|
1067
|
+
field.optional('cloudlyUrl', field.text);
|
|
1068
|
+
field.optional('swarmNodeId', field.identifier);
|
|
1069
|
+
field.optional('lastError', (keyArg) => field.messageText(keyArg, maximumSparkMessageTextLength));
|
|
1070
|
+
if (runtimeInfo.serveZoneServices !== undefined) {
|
|
1071
|
+
const services = readDataElements(runtimeInfo.serveZoneServices, maximumSparkRuntimeServices);
|
|
1072
|
+
if (services === null) {
|
|
1073
|
+
field.errors.push(
|
|
1074
|
+
`${labelArg} serveZoneServices must be a bounded list of at most `
|
|
1075
|
+
+ `${maximumSparkRuntimeServices} described services`,
|
|
1076
|
+
);
|
|
1077
|
+
} else {
|
|
1078
|
+
services.forEach((serviceArg, indexArg) => {
|
|
1079
|
+
field.errors.push(...validateServeZoneServiceRuntimeInfo(
|
|
1080
|
+
serviceArg,
|
|
1081
|
+
`${labelArg} serveZoneServices[${indexArg}]`,
|
|
1082
|
+
));
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
return field.errors;
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* Spark -> Cloudly body for `sparkSwarmNodeContracts.heartbeat.endpoint`.
|
|
1091
|
+
*
|
|
1092
|
+
* Stated here beside the shape it judges, so the server that reads the beat and the node that
|
|
1093
|
+
* writes it apply one set of acceptance rules; a reader that kept its own member checks would
|
|
1094
|
+
* decide on its own which beats exist.
|
|
1095
|
+
*
|
|
1096
|
+
* Every Spark route validator answers the same way — one string per refused member, empty for an
|
|
1097
|
+
* admissible body, and the same `(body) => Promise<string[]>` signature, so a server can hold the
|
|
1098
|
+
* four routes in one table.
|
|
1099
|
+
*/
|
|
1100
|
+
export const validateSparkSwarmNodeHeartbeatRequest = async (
|
|
1101
|
+
requestArg: unknown,
|
|
1102
|
+
): Promise<string[]> => {
|
|
1103
|
+
const label = 'Spark Swarm node heartbeat request';
|
|
1104
|
+
try {
|
|
1105
|
+
const envelope = readSparkRouteEnvelope(
|
|
1106
|
+
requestArg,
|
|
1107
|
+
label,
|
|
1108
|
+
['nodeId', 'nodeToken', 'protocol', 'metrics', 'runtimeInfo'],
|
|
1109
|
+
);
|
|
1110
|
+
if (envelope.record === null) {
|
|
1111
|
+
return envelope.errors;
|
|
1112
|
+
}
|
|
1113
|
+
envelope.errors.push(...validateSparkNodeMetrics(
|
|
1114
|
+
envelope.record.metrics,
|
|
1115
|
+
`${label} metrics`,
|
|
1116
|
+
));
|
|
1117
|
+
envelope.errors.push(...validateSparkNodeRuntimeInfo(
|
|
1118
|
+
envelope.record.runtimeInfo,
|
|
1119
|
+
`${label} runtimeInfo`,
|
|
1120
|
+
));
|
|
1121
|
+
return envelope.errors;
|
|
1122
|
+
} catch {
|
|
1123
|
+
return [`${label} must be safely inspectable`];
|
|
1124
|
+
}
|
|
1125
|
+
};
|
|
1126
|
+
|
|
1127
|
+
/** Spark -> Cloudly body for `sparkSwarmNodeContracts.metricsSample.endpoint`. */
|
|
1128
|
+
export const validateSparkMetricsSampleRequest = async (
|
|
1129
|
+
requestArg: unknown,
|
|
1130
|
+
): Promise<string[]> => {
|
|
1131
|
+
const label = 'Spark metrics sample request';
|
|
1132
|
+
try {
|
|
1133
|
+
const envelope = readSparkRouteEnvelope(
|
|
1134
|
+
requestArg,
|
|
1135
|
+
label,
|
|
1136
|
+
['nodeId', 'nodeToken', 'protocol', 'sample'],
|
|
1137
|
+
);
|
|
1138
|
+
if (envelope.record === null) {
|
|
1139
|
+
return envelope.errors;
|
|
1140
|
+
}
|
|
1141
|
+
envelope.errors.push(...validateNodeMetricsSample(
|
|
1142
|
+
envelope.record.sample,
|
|
1143
|
+
`${label} sample`,
|
|
1144
|
+
));
|
|
1145
|
+
return envelope.errors;
|
|
1146
|
+
} catch {
|
|
1147
|
+
return [`${label} must be safely inspectable`];
|
|
1148
|
+
}
|
|
1149
|
+
};
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* Spark -> Cloudly body for `sparkSwarmNodeContracts.actionResult.endpoint`.
|
|
1153
|
+
*
|
|
1154
|
+
* `pending` is the one status a node never reports: an action it has not started has no result to
|
|
1155
|
+
* post, and a server that accepted one would reopen an action it already handed out.
|
|
1156
|
+
*/
|
|
1157
|
+
export const validateSparkActionResultRequest = async (
|
|
1158
|
+
requestArg: unknown,
|
|
1159
|
+
): Promise<string[]> => {
|
|
1160
|
+
const label = 'Spark action result request';
|
|
1161
|
+
try {
|
|
1162
|
+
const envelope = readSparkRouteEnvelope(
|
|
1163
|
+
requestArg,
|
|
1164
|
+
label,
|
|
1165
|
+
['nodeId', 'nodeToken', 'protocol', 'actionId', 'status'],
|
|
1166
|
+
['resultText'],
|
|
1167
|
+
);
|
|
1168
|
+
if (envelope.record === null) {
|
|
1169
|
+
return envelope.errors;
|
|
1170
|
+
}
|
|
1171
|
+
const field = createSparkFieldErrors(envelope.record, label);
|
|
1172
|
+
field.identifier('actionId');
|
|
1173
|
+
field.member('status', sparkActionResultStatuses);
|
|
1174
|
+
field.optional('resultText', (keyArg) => field.messageText(
|
|
1175
|
+
keyArg,
|
|
1176
|
+
maximumSparkActionResultTextLength,
|
|
1177
|
+
));
|
|
1178
|
+
envelope.errors.push(...field.errors);
|
|
1179
|
+
return envelope.errors;
|
|
1180
|
+
} catch {
|
|
1181
|
+
return [`${label} must be safely inspectable`];
|
|
1182
|
+
}
|
|
1183
|
+
};
|
|
1184
|
+
|
|
738
1185
|
/**
|
|
739
1186
|
* The exact five members of a target that the set digest is taken over.
|
|
740
1187
|
*
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IAppStoreFilesystemStorageRequest,
|
|
3
|
+
IAppStoreObjectStorageRequest,
|
|
4
|
+
} from '../appstore/types.js';
|
|
5
|
+
import { deepFreezeValue } from '../private/canonicaljson.js';
|
|
6
|
+
|
|
7
|
+
export interface IStorageRequestCanonicalDigestGoldenVector<TInput> {
|
|
8
|
+
input: TInput;
|
|
9
|
+
canonicalJson: string;
|
|
10
|
+
sha256: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const filesystemRequest: IAppStoreFilesystemStorageRequest = {
|
|
14
|
+
id: 'app-state',
|
|
15
|
+
kind: 'filesystem',
|
|
16
|
+
storageClass: 'databaseFast',
|
|
17
|
+
accessMode: 'ReadWriteOnce',
|
|
18
|
+
reclaimPolicy: 'delete',
|
|
19
|
+
mountPath: '/var/lib/app',
|
|
20
|
+
capacity: {
|
|
21
|
+
request: '20GiB',
|
|
22
|
+
limit: '40GiB',
|
|
23
|
+
},
|
|
24
|
+
protection: {
|
|
25
|
+
backup: 'required',
|
|
26
|
+
snapshots: 'portable',
|
|
27
|
+
consistency: 'applicationConsistent',
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const objectStorageLauncherEnvironmentRequest: IAppStoreObjectStorageRequest = {
|
|
32
|
+
id: 'object-data',
|
|
33
|
+
kind: 'objectStorage',
|
|
34
|
+
storageClass: 'backupCapacity',
|
|
35
|
+
accessMode: 'readWrite',
|
|
36
|
+
reclaimPolicy: 'retain',
|
|
37
|
+
delivery: {
|
|
38
|
+
type: 'launcher-environment',
|
|
39
|
+
keys: {
|
|
40
|
+
endpoint: 'APP_S3_ENDPOINT',
|
|
41
|
+
bucket: 'APP_S3_BUCKET',
|
|
42
|
+
region: 'APP_S3_REGION',
|
|
43
|
+
accessKeyId: 'APP_S3_ACCESS_KEY',
|
|
44
|
+
secretAccessKey: 'APP_S3_SECRET_KEY',
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
capacity: {
|
|
48
|
+
request: '500GiB',
|
|
49
|
+
},
|
|
50
|
+
protection: {
|
|
51
|
+
backup: 'required',
|
|
52
|
+
versioning: 'required',
|
|
53
|
+
retentionDays: 30,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const objectStorageFileRequest: IAppStoreObjectStorageRequest = {
|
|
58
|
+
id: 'object-archive',
|
|
59
|
+
kind: 'objectStorage',
|
|
60
|
+
storageClass: 'archiveCapacity',
|
|
61
|
+
accessMode: 'readOnly',
|
|
62
|
+
reclaimPolicy: 'delete',
|
|
63
|
+
delivery: {
|
|
64
|
+
type: 'file',
|
|
65
|
+
targetPath: '/run/secrets/object-archive.json',
|
|
66
|
+
format: 'servezone-object-storage',
|
|
67
|
+
uid: 10_000,
|
|
68
|
+
gid: 10_000,
|
|
69
|
+
mode: 0o400,
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The storage request digest, written down for both kinds and both deliveries.
|
|
75
|
+
*
|
|
76
|
+
* A control plane and its consumer compare this value, so it is stated here as bytes rather than
|
|
77
|
+
* as whatever the current normalizer happens to produce: a member that starts or stops
|
|
78
|
+
* participating, an optional member that starts being carried when it is absent, or a delivery
|
|
79
|
+
* mode written as anything but its number would move these digests and be caught here.
|
|
80
|
+
*/
|
|
81
|
+
export const storageRequestCanonicalDigestGoldenVectors = deepFreezeValue({
|
|
82
|
+
filesystem: {
|
|
83
|
+
input: filesystemRequest,
|
|
84
|
+
canonicalJson: '{"accessMode":"ReadWriteOnce","capacity":{"limit":"40GiB","request":"20GiB"},"id":"app-state","kind":"filesystem","mountPath":"/var/lib/app","protection":{"backup":"required","consistency":"applicationConsistent","snapshots":"portable"},"reclaimPolicy":"delete","storageClass":"databaseFast"}',
|
|
85
|
+
sha256: '0f8dc2e480ade7110be1f1d95f666cc56d5b90110d1c4a1b8b8383e2eeda6fae',
|
|
86
|
+
},
|
|
87
|
+
objectStorageLauncherEnvironment: {
|
|
88
|
+
input: objectStorageLauncherEnvironmentRequest,
|
|
89
|
+
canonicalJson: '{"accessMode":"readWrite","capacity":{"request":"500GiB"},"delivery":{"keys":{"accessKeyId":"APP_S3_ACCESS_KEY","bucket":"APP_S3_BUCKET","endpoint":"APP_S3_ENDPOINT","region":"APP_S3_REGION","secretAccessKey":"APP_S3_SECRET_KEY"},"type":"launcher-environment"},"id":"object-data","kind":"objectStorage","protection":{"backup":"required","retentionDays":30,"versioning":"required"},"reclaimPolicy":"retain","storageClass":"backupCapacity"}',
|
|
90
|
+
sha256: '88bd943800bea75b576612b966484e66ddbf7ec9529a7f29dd1eadc9a36e2b79',
|
|
91
|
+
},
|
|
92
|
+
objectStorageFile: {
|
|
93
|
+
input: objectStorageFileRequest,
|
|
94
|
+
canonicalJson: '{"accessMode":"readOnly","delivery":{"format":"servezone-object-storage","gid":10000,"mode":256,"targetPath":"/run/secrets/object-archive.json","type":"file","uid":10000},"id":"object-archive","kind":"objectStorage","reclaimPolicy":"delete","storageClass":"archiveCapacity"}',
|
|
95
|
+
sha256: '13186abbfe733b3917363e0694d367f2d6aa87a4064445532d7c857ee274f795',
|
|
96
|
+
},
|
|
97
|
+
});
|
package/ts/platform/storage.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
+
import type { TAppStoreStorageRequest } from '../appstore/types.js';
|
|
1
2
|
import type { TSecretManagementScope } from '../data/secret.js';
|
|
3
|
+
import {
|
|
4
|
+
canonicalizeStrictJson,
|
|
5
|
+
createCanonicalJsonSha256Hex,
|
|
6
|
+
} from '../private/canonicaljson.js';
|
|
7
|
+
import { createStorageContractReaders } from '../private/storagecontract.js';
|
|
2
8
|
|
|
3
9
|
/**
|
|
4
10
|
* Backend-neutral storage vocabulary shared by App Store manifests, control
|
|
@@ -186,8 +192,12 @@ export interface IResolvedStorageBindingBase {
|
|
|
186
192
|
/** Stable App Store storageRequests[].id. */
|
|
187
193
|
requestId: string;
|
|
188
194
|
/**
|
|
189
|
-
* Canonical digest of the normalized request
|
|
190
|
-
*
|
|
195
|
+
* Canonical digest of the normalized request, exactly as
|
|
196
|
+
* `createStorageRequestSha256` states it: bare lowercase hex over the request
|
|
197
|
+
* alone. Adapters use it to distinguish an idempotent reconciliation from a
|
|
198
|
+
* migration, and a consumer re-derives it to recognise the request it asked
|
|
199
|
+
* for, so the control plane and the consumer call that one function rather
|
|
200
|
+
* than each deriving a value the other cannot reproduce.
|
|
191
201
|
*/
|
|
192
202
|
requestDigest: string;
|
|
193
203
|
kind: TStorageResourceKind;
|
|
@@ -232,3 +242,72 @@ extends IResolvedStorageBindingBase {
|
|
|
232
242
|
export type TResolvedStorageBinding =
|
|
233
243
|
| IResolvedFilesystemStorageBinding
|
|
234
244
|
| IResolvedObjectStorageBinding;
|
|
245
|
+
|
|
246
|
+
export class StorageContractError extends Error {
|
|
247
|
+
public constructor(reasonArg: string) {
|
|
248
|
+
super(`storage contract error: ${reasonArg}`);
|
|
249
|
+
this.name = 'StorageContractError';
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const fail = (reasonArg: string): never => {
|
|
254
|
+
throw new StorageContractError(reasonArg);
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const { normalizeStorageRequest: readStorageRequest } =
|
|
258
|
+
createStorageContractReaders(fail);
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The one judgement of what a storage request is: the request read against its exact schema and
|
|
262
|
+
* returned in its contract shape — required members present, optional members carried only when
|
|
263
|
+
* the request declares them, and every value within the bounds an adapter can fulfil.
|
|
264
|
+
*
|
|
265
|
+
* A control plane that admits requests, a fulfillment adapter that provisions them, and an App
|
|
266
|
+
* Store front end that assembles them all have to agree on the same acceptance rules; a second
|
|
267
|
+
* copy of them anywhere is a second answer to which requests exist, and the digest one side issues
|
|
268
|
+
* would name a request the other never accepted. `canonicalizeStorageRequest` and
|
|
269
|
+
* `createStorageRequestSha256` are derived from this function rather than the other way round, so
|
|
270
|
+
* a consumer that needs the judged request itself does not have to re-parse its own canonical
|
|
271
|
+
* bytes.
|
|
272
|
+
*
|
|
273
|
+
* The returned request is a fresh value; the input is never read again after normalization and
|
|
274
|
+
* never mutated. Anything that is not a storage request is refused with `StorageContractError`,
|
|
275
|
+
* whose message names the failing field so a consumer can map it to its own status vocabulary.
|
|
276
|
+
*/
|
|
277
|
+
export const normalizeStorageRequest = (
|
|
278
|
+
valueArg: unknown,
|
|
279
|
+
): TAppStoreStorageRequest => {
|
|
280
|
+
return readStorageRequest(valueArg, 'storage request');
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The exact bytes a storage request's digest is taken over: the request normalized to its contract
|
|
285
|
+
* shape, written as strict canonical JSON — keys in UTF-16 code-unit order, no whitespace, no
|
|
286
|
+
* absent member carried as `null`.
|
|
287
|
+
*
|
|
288
|
+
* Anything that is not a storage request is refused rather than hashed, because a digest of an
|
|
289
|
+
* unjudged body would name a request no adapter could fulfil.
|
|
290
|
+
*/
|
|
291
|
+
export const canonicalizeStorageRequest = (valueArg: unknown): string => {
|
|
292
|
+
return canonicalizeStrictJson(normalizeStorageRequest(valueArg), fail);
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The storage request digest: bare lowercase hex SHA-256 over the canonical request, with no
|
|
297
|
+
* prefix and nothing but the request in the hashed value.
|
|
298
|
+
*
|
|
299
|
+
* A control plane issues this value on a binding and a consumer re-derives it from the request it
|
|
300
|
+
* declared; the two only agree because they call this one function. A second derivation elsewhere
|
|
301
|
+
* — a prefix, or a wrapper object around the request — is a different value for the same binding,
|
|
302
|
+
* and the consumer would keep rejecting a binding that is in fact its own.
|
|
303
|
+
*/
|
|
304
|
+
export const createStorageRequestSha256 = async (
|
|
305
|
+
valueArg: unknown,
|
|
306
|
+
): Promise<string> => {
|
|
307
|
+
return createCanonicalJsonSha256Hex(canonicalizeStorageRequest(valueArg), fail);
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
export {
|
|
311
|
+
storageRequestCanonicalDigestGoldenVectors,
|
|
312
|
+
type IStorageRequestCanonicalDigestGoldenVector,
|
|
313
|
+
} from './storage.golden.js';
|