@serve.zone/interfaces 32.2.0 → 32.4.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 +69 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/data/cluster.d.ts +30 -0
- package/dist_ts/data/cluster.js +46 -2
- package/dist_ts/data/clusternode.d.ts +21 -0
- package/dist_ts/data/clusternode.js +345 -11
- package/dist_ts/data/clustervpn.d.ts +135 -0
- package/dist_ts/data/clustervpn.js +147 -0
- package/dist_ts/data/index.d.ts +1 -0
- package/dist_ts/data/index.js +2 -1
- package/dist_ts/data/networkaddressplan.d.ts +54 -11
- package/dist_ts/data/networkaddressplan.js +67 -23
- package/dist_ts/data/networkcredential.d.ts +13 -4
- package/dist_ts/data/networkcredential.js +41 -23
- package/dist_ts/data/runtimeassignment.d.ts +23 -0
- package/dist_ts/data/runtimeassignment.js +42 -7
- package/dist_ts/platform/storage.d.ts +19 -0
- package/dist_ts/platform/storage.js +23 -3
- package/dist_ts/private/networkcontract.d.ts +6 -0
- package/dist_ts/private/networkcontract.js +12 -1
- package/dist_ts/requests/cluster.d.ts +27 -0
- package/dist_ts/requests/cluster.js +1 -1
- package/package.json +1 -1
- package/readme.md +139 -21
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/data/cluster.ts +76 -1
- package/ts/data/clusternode.ts +457 -10
- package/ts/data/clustervpn.ts +248 -0
- package/ts/data/index.ts +1 -0
- package/ts/data/networkaddressplan.ts +103 -27
- package/ts/data/networkcredential.ts +52 -24
- package/ts/data/runtimeassignment.ts +58 -5
- package/ts/platform/storage.ts +27 -5
- package/ts/private/networkcontract.ts +11 -0
- package/ts/requests/cluster.ts +35 -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
|
*
|