@serve.zone/appstore 32.27.0 → 33.1.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.
Files changed (125) hide show
  1. package/.smartconfig.json +4 -1
  2. package/changelog.md +70 -0
  3. package/dist_catalog/catalog.digest +1 -0
  4. package/dist_catalog/catalog.envelope.json +7617 -0
  5. package/{dist_ts_client/data.generated.js → dist_catalog/catalog.json} +6912 -6908
  6. package/dist_ts_client/catalog.d.ts +16 -0
  7. package/dist_ts_client/catalog.js +12 -0
  8. package/dist_ts_client/index.d.ts +3 -1
  9. package/dist_ts_client/index.js +4 -2
  10. package/dist_ts_client/loader.d.ts +69 -0
  11. package/dist_ts_client/loader.js +361 -0
  12. package/dist_ts_client/plugins.d.ts +5 -0
  13. package/dist_ts_client/plugins.js +8 -0
  14. package/dist_ts_client/projection.d.ts +59 -0
  15. package/dist_ts_client/projection.js +83 -0
  16. package/dist_ts_client/storage.d.ts +15 -9
  17. package/dist_ts_client/storage.js +81 -657
  18. package/dist_ts_client/types.d.ts +86 -41
  19. package/dist_ts_client/types.js +1 -1
  20. package/dist_ts_testing/fixtures.d.ts +83 -0
  21. package/dist_ts_testing/fixtures.js +146 -0
  22. package/dist_ts_testing/index.d.ts +1 -0
  23. package/dist_ts_testing/index.js +2 -0
  24. package/dist_ts_testing/plugins.d.ts +7 -0
  25. package/dist_ts_testing/plugins.js +11 -0
  26. package/package.json +16 -11
  27. package/readme.md +162 -93
  28. package/ts_client/catalog.ts +26 -0
  29. package/ts_client/index.ts +3 -1
  30. package/ts_client/loader.ts +414 -0
  31. package/ts_client/plugins.ts +10 -0
  32. package/ts_client/projection.ts +148 -0
  33. package/ts_client/readme.md +17 -27
  34. package/ts_client/storage.ts +93 -882
  35. package/ts_client/types.ts +102 -42
  36. package/ts_testing/fixtures.ts +230 -0
  37. package/ts_testing/index.ts +1 -0
  38. package/ts_testing/plugins.ts +15 -0
  39. package/ts_testing/tspublish.json +3 -0
  40. package/apps/adminer/app.json +0 -10
  41. package/apps/adminer/versions/1.0.0/config.json +0 -6
  42. package/apps/cloudly/app.json +0 -14
  43. package/apps/cloudly/versions/1.0.0/config.json +0 -120
  44. package/apps/cloudly/versions/1.1.0/config.json +0 -120
  45. package/apps/cloudly/versions/1.1.1/config.json +0 -120
  46. package/apps/cloudly/versions/1.1.10/config.json +0 -173
  47. package/apps/cloudly/versions/1.1.2/config.json +0 -130
  48. package/apps/cloudly/versions/1.1.3/config.json +0 -130
  49. package/apps/cloudly/versions/1.1.4/config.json +0 -130
  50. package/apps/cloudly/versions/1.1.5/config.json +0 -149
  51. package/apps/cloudly/versions/1.1.6/config.json +0 -149
  52. package/apps/cloudly/versions/1.1.7/config.json +0 -149
  53. package/apps/cloudly/versions/1.1.8/config.json +0 -173
  54. package/apps/cloudly/versions/1.1.9/config.json +0 -173
  55. package/apps/cloudly/versions/1.10.0/config.json +0 -173
  56. package/apps/cloudly/versions/1.11.0/config.json +0 -173
  57. package/apps/cloudly/versions/1.12.0/config.json +0 -173
  58. package/apps/cloudly/versions/1.13.0/config.json +0 -173
  59. package/apps/cloudly/versions/1.14.0/config.json +0 -173
  60. package/apps/cloudly/versions/1.15.0/config.json +0 -173
  61. package/apps/cloudly/versions/1.16.0/config.json +0 -173
  62. package/apps/cloudly/versions/1.17.0/config.json +0 -173
  63. package/apps/cloudly/versions/1.18.0/config.json +0 -173
  64. package/apps/cloudly/versions/1.19.0/config.json +0 -173
  65. package/apps/cloudly/versions/1.2.0/config.json +0 -173
  66. package/apps/cloudly/versions/1.2.1/config.json +0 -173
  67. package/apps/cloudly/versions/1.20.0/config.json +0 -173
  68. package/apps/cloudly/versions/1.21.0/config.json +0 -173
  69. package/apps/cloudly/versions/1.22.0/config.json +0 -173
  70. package/apps/cloudly/versions/1.23.0/config.json +0 -173
  71. package/apps/cloudly/versions/1.24.0/config.json +0 -173
  72. package/apps/cloudly/versions/1.25.0/config.json +0 -173
  73. package/apps/cloudly/versions/1.26.0/config.json +0 -173
  74. package/apps/cloudly/versions/1.27.0/config.json +0 -173
  75. package/apps/cloudly/versions/1.3.0/config.json +0 -173
  76. package/apps/cloudly/versions/1.4.0/config.json +0 -173
  77. package/apps/cloudly/versions/1.5.0/config.json +0 -173
  78. package/apps/cloudly/versions/1.6.0/config.json +0 -173
  79. package/apps/cloudly/versions/1.7.0/config.json +0 -173
  80. package/apps/cloudly/versions/1.8.0/config.json +0 -173
  81. package/apps/cloudly/versions/1.9.0/config.json +0 -173
  82. package/apps/ghost/app.json +0 -10
  83. package/apps/ghost/versions/1.0.0/config.json +0 -18
  84. package/apps/gitea/app.json +0 -10
  85. package/apps/gitea/versions/1.0.0/config.json +0 -16
  86. package/apps/gitops/app.json +0 -14
  87. package/apps/gitops/versions/1.0.0/config.json +0 -28
  88. package/apps/grafana/app.json +0 -10
  89. package/apps/grafana/versions/1.0.0/config.json +0 -9
  90. package/apps/mariadb/app.json +0 -10
  91. package/apps/mariadb/versions/1.0.0/config.json +0 -9
  92. package/apps/mattermost/app.json +0 -10
  93. package/apps/mattermost/versions/1.0.0/config.json +0 -6
  94. package/apps/n8n/app.json +0 -10
  95. package/apps/n8n/versions/1.0.0/config.json +0 -6
  96. package/apps/nextcloud/app.json +0 -10
  97. package/apps/nextcloud/versions/1.0.0/config.json +0 -16
  98. package/apps/nginx/app.json +0 -10
  99. package/apps/nginx/versions/1.0.0/config.json +0 -6
  100. package/apps/openbao/app.json +0 -16
  101. package/apps/openbao/versions/1.0.0/config.json +0 -52
  102. package/apps/plausible/app.json +0 -10
  103. package/apps/plausible/versions/1.0.0/config.json +0 -9
  104. package/apps/portainer/app.json +0 -10
  105. package/apps/portainer/versions/1.0.0/config.json +0 -6
  106. package/apps/postgres/app.json +0 -10
  107. package/apps/postgres/versions/1.0.0/config.json +0 -11
  108. package/apps/rustdesk-server/app.json +0 -14
  109. package/apps/rustdesk-server/versions/1.0.0/config.json +0 -19
  110. package/apps/siprouter/app.json +0 -14
  111. package/apps/siprouter/versions/1.0.0/config.json +0 -84
  112. package/apps/uptime-kuma/app.json +0 -10
  113. package/apps/uptime-kuma/versions/1.0.0/config.json +0 -6
  114. package/apps/valkey/app.json +0 -10
  115. package/apps/valkey/versions/1.0.0/config.json +0 -6
  116. package/apps/vaultwarden/app.json +0 -10
  117. package/apps/vaultwarden/versions/1.0.0/config.json +0 -6
  118. package/apps/wordpress/app.json +0 -10
  119. package/apps/wordpress/versions/1.0.0/config.json +0 -15
  120. package/appstore.json +0 -194
  121. package/dist_ts_client/classes.appstoreresolver.d.ts +0 -59
  122. package/dist_ts_client/classes.appstoreresolver.js +0 -736
  123. package/dist_ts_client/data.generated.d.ts +0 -2
  124. package/ts_client/classes.appstoreresolver.ts +0 -896
  125. package/ts_client/data.generated.ts +0 -7606
@@ -1,289 +1,124 @@
1
- import * as interfaces from '@serve.zone/interfaces';
1
+ import * as plugins from './plugins.js';
2
2
  import type {
3
3
  IAppStoreStorageNormalizationOptions,
4
4
  INormalizedAppStoreStorageConfig,
5
- TAppStoreStorageFeatureId,
6
5
  TAppStoreStorageRequestOrigin,
7
- TAppStoreVersionConfig,
8
6
  } from './types.js';
9
7
 
10
- const storageFeatureIds = interfaces.appstore.appStoreStorageFeatureIds;
11
- const knownStorageFeatureIds = new Set<string>(Object.values(storageFeatureIds));
12
- const storageClassKeyPattern = /^[A-Za-z][A-Za-z0-9-]{0,62}$/;
13
- const storageRequestIdPattern = /^[a-z][a-z0-9-]{0,62}$/;
14
- const environmentKeyPattern = /^[A-Z_][A-Z0-9_]{0,252}$/;
15
- const capacityPattern = /^([1-9][0-9]*)(KiB|MiB|GiB|TiB)$/;
16
- const capacityMultipliers = {
17
- KiB: 1024n,
18
- MiB: 1024n ** 2n,
19
- GiB: 1024n ** 3n,
20
- TiB: 1024n ** 4n,
21
- } as const;
8
+ type TCatalogVersionConfig = plugins.servezoneInterfaces.appstore.TAppStoreCatalogVersionConfig;
9
+ type TStorageFeatureId = plugins.servezoneInterfaces.appstore.TAppStoreStorageFeatureId;
10
+ type TStorageRequest = plugins.servezoneInterfaces.appstore.TAppStoreStorageRequest;
11
+ type TVolume = plugins.servezoneInterfaces.appstore.IAppStoreVolume;
12
+
13
+ const storageFeatureIds = plugins.servezoneInterfaces.appstore.appStoreStorageFeatureIds;
22
14
  const legacyFilesystemClassKey = 'legacyFilesystem';
23
15
  const legacyObjectStorageClassKey = 'legacyObjectStorage';
24
16
  const legacyObjectStorageRequestId = 'legacy-s3';
25
- const appStoreVersionConfigKeys = [
26
- 'image',
27
- 'port',
28
- 'containerArgs',
29
- 'targetPorts',
30
- 'domains',
31
- 'publicPortMappings',
32
- 'envVars',
33
- 'volumes',
34
- 'storageClasses',
35
- 'storageRequests',
36
- 'publishedPorts',
37
- 'platformRequirements',
38
- 'platformOidc',
39
- 'minOneboxVersion',
40
- 'minCloudlyVersion',
41
- 'appStoreVersion',
42
- 'upgradeStrategy',
43
- 'source',
44
- 'resolvedSource',
45
- 'resolvedImageDigest',
46
- 'changelog',
47
- 'breaking',
48
- 'requiresManualReview',
49
- 'migrationRequired',
50
- 'backupBeforeUpgrade',
51
- 'requiresFeatures',
52
- 'healthCheck',
53
- ] as const;
54
-
55
- type TPlainRecord = Record<string, unknown>;
17
+ const storageRequestIdPattern = /^[a-z][a-z0-9-]{0,62}$/;
18
+ const defaultLabel = 'appstore config';
56
19
 
57
- interface IAppInputDestinations {
58
- declaredEnvironmentKeys: Set<string>;
59
- runtimeEnvironmentKeys: Set<string>;
60
- declaredSecretPaths: Set<string>;
61
- }
20
+ /**
21
+ * Throws the first storage violation of a version config, judged by the interfaces contract
22
+ * (`appstore.validateAppStoreVersionStorage`), which owns every storage rule.
23
+ */
24
+ const assertValidStorage = (configArg: TCatalogVersionConfig, labelArg: string): void => {
25
+ const errors = plugins.servezoneInterfaces.appstore.validateAppStoreVersionStorage(configArg, labelArg);
26
+ if (errors.length > 0) throw new Error(errors.join('; '));
27
+ };
28
+
29
+ const normalizeVolumes = (volumesArg: TCatalogVersionConfig['volumes']): TVolume[] =>
30
+ (volumesArg ?? []).map((volumeArg): TVolume => {
31
+ const volume = typeof volumeArg === 'string' ? { mountPath: volumeArg } : volumeArg;
32
+ return {
33
+ ...(volume.name === undefined ? {} : { name: volume.name }),
34
+ mountPath: volume.mountPath,
35
+ ...(volume.readOnly === undefined ? {} : { readOnly: volume.readOnly }),
36
+ ...(volume.backup === undefined ? {} : { backup: volume.backup }),
37
+ };
38
+ });
62
39
 
63
- export function parseStorageCapacityQuantity(
64
- quantityArg: unknown,
65
- labelArg = 'storage capacity',
66
- ): bigint {
67
- if (typeof quantityArg !== 'string' || quantityArg.length > 64) {
40
+ /** The stable request id a legacy volume becomes: its name, or its mount path flattened. */
41
+ const createLegacyVolumeRequestId = (volumeArg: TVolume, indexArg: number, labelArg: string): string => {
42
+ const stablePart = volumeArg.name || volumeArg.mountPath
43
+ .toLowerCase()
44
+ .split('/')
45
+ .filter(Boolean)
46
+ .join('-')
47
+ .replace(/[^a-z0-9-]+/g, '-')
48
+ .replace(/^-+|-+$/g, '');
49
+ const requestId = `legacy-volume-${stablePart}`;
50
+ if (!stablePart || !storageRequestIdPattern.test(requestId)) {
68
51
  throw new Error(
69
- `Invalid ${labelArg}: expected a positive integer followed by KiB, MiB, GiB, or TiB`,
52
+ `Invalid ${labelArg} volumes[${indexArg}]: cannot derive a stable request id; declare a shorter lowercase name`,
70
53
  );
71
54
  }
72
- const match = quantityArg.match(capacityPattern);
73
- if (!match) {
74
- throw new Error(
75
- `Invalid ${labelArg} '${quantityArg}': expected a positive integer followed by KiB, MiB, GiB, or TiB`,
76
- );
55
+ return requestId;
56
+ };
57
+
58
+ const deriveRequiredFeatureIds = (explicitRequestsArg: TStorageRequest[]): TStorageFeatureId[] => {
59
+ const required: TStorageFeatureId[] = [];
60
+ if (explicitRequestsArg.length > 0) required.push(storageFeatureIds.bindings);
61
+ if (explicitRequestsArg.some((requestArg) => requestArg.kind === 'filesystem')) {
62
+ required.push(storageFeatureIds.filesystem);
77
63
  }
78
- // The storage contract reads a capacity quantity as a safe integer amount of its unit, so a
79
- // larger quantity names a request no adapter can be handed. This parser is the one place the
80
- // App Store reads a quantity, so the same bound is applied here rather than after a request has
81
- // already been assembled from it.
82
- if (!Number.isSafeInteger(Number(match[1]))) {
83
- throw new Error(
84
- `Invalid ${labelArg} '${quantityArg}': the quantity exceeds the safe integer range`,
85
- );
64
+ if (explicitRequestsArg.some((requestArg) => requestArg.kind === 'objectStorage')) {
65
+ required.push(storageFeatureIds.objectStorage);
86
66
  }
87
- return BigInt(match[1]) * capacityMultipliers[match[2] as keyof typeof capacityMultipliers];
88
- }
67
+ if (explicitRequestsArg.some((requestArg) => (
68
+ requestArg.kind === 'objectStorage' && requestArg.delivery.type === 'file'
69
+ ))) {
70
+ required.push(storageFeatureIds.objectStorageFile);
71
+ }
72
+ return required;
73
+ };
89
74
 
90
75
  /**
91
- * Strict structural validation plus deterministic legacy normalization. This
92
- * does not claim that a runtime can fulfill explicit requests; installers must
93
- * call normalizeAppStoreStorageConfig with an advertised feature set.
76
+ * The legacy `volumes` of a version config in their object form (`{ name?, mountPath, readOnly?,
77
+ * backup? }`), after the config's storage passed the interfaces contract.
94
78
  */
95
- export function validateAppStoreStorageConfig(
96
- configArg: TAppStoreVersionConfig,
97
- labelArg = 'appstore config',
98
- ): INormalizedAppStoreStorageConfig {
99
- return validateAndNormalizeStorageConfig(configArg, labelArg);
79
+ export function normalizeAppStoreVolumes(
80
+ configArg: TCatalogVersionConfig,
81
+ labelArg = defaultLabel,
82
+ ): TVolume[] {
83
+ assertValidStorage(configArg, labelArg);
84
+ return normalizeVolumes(configArg.volumes);
100
85
  }
101
86
 
102
87
  /**
103
- * Canonical pre-fulfillment handoff. Explicit storage contracts fail closed
104
- * unless every derived storage feature is advertised by the target runtime.
88
+ * The canonical pre-fulfillment storage handoff of a version config.
89
+ *
90
+ * The config's storage is judged by the interfaces contract first. Legacy `volumes` become
91
+ * filesystem requests of the reserved `legacyFilesystem` class, `platformRequirements.s3` becomes
92
+ * the `legacy-s3` object-storage request, and explicit requests fail closed unless every storage
93
+ * feature they need is advertised by the target runtime.
105
94
  */
106
95
  export function normalizeAppStoreStorageConfig(
107
- configArg: TAppStoreVersionConfig,
96
+ configArg: TCatalogVersionConfig,
108
97
  optionsArg: IAppStoreStorageNormalizationOptions,
109
- labelArg = 'appstore config',
98
+ labelArg = defaultLabel,
110
99
  ): INormalizedAppStoreStorageConfig {
111
- const normalized = validateAndNormalizeStorageConfig(configArg, labelArg);
112
- if (!optionsArg || optionsArg.supportedFeatures === undefined) {
113
- throw new Error(`Invalid ${labelArg}: supportedFeatures is required for storage feature negotiation`);
114
- }
100
+ assertValidStorage(configArg, labelArg);
115
101
 
116
- let supportedFeatures: Set<string>;
117
- try {
118
- supportedFeatures = new Set(optionsArg.supportedFeatures);
119
- } catch {
120
- throw new Error(`Invalid ${labelArg}: supportedFeatures must be iterable`);
121
- }
122
- const unsupportedFeatures = normalized.requiredFeatureIds.filter(
123
- (featureIdArg) => !supportedFeatures.has(featureIdArg),
124
- );
125
- if (unsupportedFeatures.length > 0) {
126
- throw new Error(
127
- `Unsupported ${labelArg} storage features: ${unsupportedFeatures.join(', ')}`,
128
- );
129
- }
130
- return normalized;
131
- }
132
-
133
- function collectAppInputDestinations(
134
- configArg: TAppStoreVersionConfig,
135
- labelArg: string,
136
- ): IAppInputDestinations {
137
- const declaredEnvironmentKeys = new Set<string>();
138
- const runtimeEnvironmentOwners = new Map<string, string>();
139
- const secretFileOwners = new Map<string, string>();
140
-
141
- const addDestination = (
142
- ownersArg: Map<string, string>,
143
- destinationArg: string,
144
- ownerArg: string,
145
- kindArg: 'environment' | 'secret file',
146
- ): void => {
147
- const existingOwner = ownersArg.get(destinationArg);
148
- if (existingOwner) {
149
- throw new Error(
150
- `Invalid ${labelArg}: ${kindArg} destination '${destinationArg}' is used by both ${existingOwner} and ${ownerArg}`,
151
- );
152
- }
153
- ownersArg.set(destinationArg, ownerArg);
154
- };
155
-
156
- for (const declaration of configArg.envVars || []) {
157
- declaredEnvironmentKeys.add(declaration.key);
158
- const owner = `envVars.${declaration.key}`;
159
- if (declaration.secret === true) {
160
- if (declaration.delivery.type === 'launcher-environment') {
161
- addDestination(
162
- runtimeEnvironmentOwners,
163
- declaration.delivery.variableName,
164
- owner,
165
- 'environment',
166
- );
167
- } else {
168
- addDestination(
169
- secretFileOwners,
170
- declaration.delivery.targetPath,
171
- owner,
172
- 'secret file',
173
- );
174
- }
175
- } else {
176
- addDestination(runtimeEnvironmentOwners, declaration.key, owner, 'environment');
177
- }
178
- }
102
+ const storageClasses = structuredClone(configArg.storageClasses ?? {});
103
+ const explicitRequests = structuredClone(configArg.storageRequests ?? []);
104
+ const storageRequests: TStorageRequest[] = [...explicitRequests];
105
+ const requestOrigins: Record<string, TAppStoreStorageRequestOrigin> = {};
106
+ for (const request of explicitRequests) requestOrigins[request.id] = 'explicit';
107
+ const requiredFeatureIds = deriveRequiredFeatureIds(explicitRequests);
179
108
 
180
- const platformOidc = configArg.platformOidc as unknown;
181
- if (platformOidc && typeof platformOidc === 'object' && !Array.isArray(platformOidc)) {
182
- const environmentVariables = (platformOidc as TPlainRecord).environmentVariables;
183
- if (environmentVariables && typeof environmentVariables === 'object'
184
- && !Array.isArray(environmentVariables)) {
185
- for (const [field, destination] of Object.entries(environmentVariables)) {
186
- if (typeof destination === 'string') {
187
- addDestination(
188
- runtimeEnvironmentOwners,
189
- destination,
190
- `platformOidc.${field}`,
191
- 'environment',
192
- );
193
- }
194
- }
195
- }
196
- }
197
-
198
- return {
199
- declaredEnvironmentKeys,
200
- runtimeEnvironmentKeys: new Set(runtimeEnvironmentOwners.keys()),
201
- declaredSecretPaths: new Set(secretFileOwners.keys()),
202
- };
203
- }
204
-
205
- function validateAndNormalizeStorageConfig(
206
- configArg: TAppStoreVersionConfig,
207
- labelArg: string,
208
- ): INormalizedAppStoreStorageConfig {
209
- assertExactKeys(
210
- assertPlainRecord(configArg, labelArg),
211
- appStoreVersionConfigKeys,
212
- labelArg,
213
- );
214
- const environmentErrors = interfaces.appstore.validateAppStoreEnvironmentDeclarations(
215
- configArg.envVars,
216
- );
217
- if (environmentErrors.length > 0) {
218
- throw new Error(`Invalid ${labelArg}: ${environmentErrors.join('; ')}`);
219
- }
220
- const appInputDestinations = collectAppInputDestinations(configArg, labelArg);
221
- validatePlatformRequirements(configArg.platformRequirements, labelArg);
222
- const explicitClassInput = configArg.storageClasses;
223
- const explicitRequestInput = configArg.storageRequests;
224
- const hasExplicitClasses = explicitClassInput !== undefined;
225
- const hasExplicitRequests = explicitRequestInput !== undefined;
226
- if (hasExplicitClasses !== hasExplicitRequests) {
227
- throw new Error(
228
- `Invalid ${labelArg}: storageClasses and storageRequests must be declared together`,
229
- );
230
- }
231
-
232
- const storageClasses = validateStorageClasses(explicitClassInput, labelArg);
233
- const explicitRequests = validateExplicitStorageRequests(
234
- explicitRequestInput,
235
- storageClasses,
236
- appInputDestinations,
237
- labelArg,
238
- );
239
- const requiredFeatureIds = validateFeatureDeclaration(configArg, explicitRequests, labelArg);
240
-
241
- if (configArg.platformRequirements?.s3 && explicitRequests.some(
242
- (requestArg) => requestArg.kind === 'objectStorage',
243
- )) {
244
- throw new Error(
245
- `Invalid ${labelArg}: platformRequirements.s3 cannot coexist with explicit objectStorage requests`,
246
- );
247
- }
248
-
249
- const storageRequests = [...explicitRequests];
250
- const requestOriginEntries: Array<[string, TAppStoreStorageRequestOrigin]> = explicitRequests.map(
251
- (requestArg) => [requestArg.id, 'explicit'],
252
- );
253
- const requestIds = new Set(explicitRequests.map((requestArg) => requestArg.id));
254
- const mountPaths = explicitRequests
255
- .filter((requestArg): requestArg is interfaces.appstore.IAppStoreFilesystemStorageRequest => (
256
- requestArg.kind === 'filesystem'
257
- ))
258
- .map((requestArg) => requestArg.mountPath);
259
-
260
- const legacyVolumes = normalizeLegacyVolumes(configArg.volumes, labelArg);
109
+ const legacyVolumes = normalizeVolumes(configArg.volumes);
261
110
  if (legacyVolumes.length > 0) {
262
- if (Object.hasOwn(storageClasses, legacyFilesystemClassKey)) {
263
- throw new Error(
264
- `Invalid ${labelArg}: storageClasses key '${legacyFilesystemClassKey}' is reserved for legacy volume normalization`,
265
- );
266
- }
267
111
  storageClasses[legacyFilesystemClassKey] = {
268
112
  kind: 'filesystem',
269
113
  purpose: 'runtime',
270
114
  required: {
271
115
  durability: 'persistent',
272
- ...(legacyVolumes.some((volumeArg) => volumeArg.backup !== false)
273
- ? { backup: true as const }
274
- : {}),
116
+ ...(legacyVolumes.some((volumeArg) => volumeArg.backup !== false) ? { backup: true } : {}),
275
117
  },
276
118
  };
277
119
  }
278
-
279
- for (const [indexArg, volumeArg] of legacyVolumes.entries()) {
120
+ legacyVolumes.forEach((volumeArg, indexArg) => {
280
121
  const requestId = createLegacyVolumeRequestId(volumeArg, indexArg, labelArg);
281
- if (requestIds.has(requestId)) {
282
- throw new Error(`Invalid ${labelArg}: storage request id '${requestId}' is duplicated`);
283
- }
284
- assertNoPathCollision(volumeArg.mountPath, mountPaths, `${labelArg} legacy volume ${indexArg + 1}`);
285
- requestIds.add(requestId);
286
- mountPaths.push(volumeArg.mountPath);
287
122
  storageRequests.push({
288
123
  id: requestId,
289
124
  kind: 'filesystem',
@@ -291,23 +126,13 @@ function validateAndNormalizeStorageConfig(
291
126
  mountPath: volumeArg.mountPath,
292
127
  accessMode: volumeArg.readOnly ? 'ReadOnlyMany' : 'ReadWriteOnce',
293
128
  reclaimPolicy: 'retain',
294
- ...(volumeArg.backup === false ? {} : { protection: { backup: 'required' as const } }),
129
+ ...(volumeArg.backup === false ? {} : { protection: { backup: 'required' } }),
295
130
  });
296
- requestOriginEntries.push([requestId, 'legacyVolume']);
297
- }
131
+ requestOrigins[requestId] = 'legacyVolume';
132
+ });
298
133
 
299
134
  let legacyCompatibility: INormalizedAppStoreStorageConfig['legacyCompatibility'];
300
135
  if (configArg.platformRequirements?.s3) {
301
- if (Object.hasOwn(storageClasses, legacyObjectStorageClassKey)) {
302
- throw new Error(
303
- `Invalid ${labelArg}: storageClasses key '${legacyObjectStorageClassKey}' is reserved for legacy S3 normalization`,
304
- );
305
- }
306
- if (requestIds.has(legacyObjectStorageRequestId)) {
307
- throw new Error(
308
- `Invalid ${labelArg}: storage request id '${legacyObjectStorageRequestId}' is reserved for legacy S3 normalization`,
309
- );
310
- }
311
136
  storageClasses[legacyObjectStorageClassKey] = {
312
137
  kind: 'objectStorage',
313
138
  purpose: 'runtime',
@@ -330,7 +155,7 @@ function validateAndNormalizeStorageConfig(
330
155
  },
331
156
  },
332
157
  });
333
- requestOriginEntries.push([legacyObjectStorageRequestId, 'legacyS3']);
158
+ requestOrigins[legacyObjectStorageRequestId] = 'legacyS3';
334
159
  legacyCompatibility = {
335
160
  objectStorage: {
336
161
  requestId: legacyObjectStorageRequestId,
@@ -340,631 +165,17 @@ function validateAndNormalizeStorageConfig(
340
165
  };
341
166
  }
342
167
 
343
- validateAllFilesystemMountsAndSecretTargets(
344
- storageRequests,
345
- appInputDestinations.declaredSecretPaths,
346
- labelArg,
347
- );
168
+ const supportedFeatures = new Set(optionsArg.supportedFeatures);
169
+ const unsupportedFeatures = requiredFeatureIds.filter((featureIdArg) => !supportedFeatures.has(featureIdArg));
170
+ if (unsupportedFeatures.length > 0) {
171
+ throw new Error(`Unsupported ${labelArg} storage features: ${unsupportedFeatures.join(', ')}`);
172
+ }
348
173
 
349
174
  return {
350
175
  storageClasses,
351
176
  storageRequests,
352
- requestOrigins: Object.fromEntries(requestOriginEntries),
177
+ requestOrigins,
353
178
  requiredFeatureIds,
354
179
  ...(legacyCompatibility ? { legacyCompatibility } : {}),
355
180
  };
356
181
  }
357
-
358
- function validateStorageClasses(
359
- classesArg: TAppStoreVersionConfig['storageClasses'],
360
- labelArg: string,
361
- ): Record<string, interfaces.appstore.IAppStoreStorageClass> {
362
- if (classesArg === undefined) return {};
363
- const classesRecord = assertPlainRecord(classesArg, `${labelArg} storageClasses`);
364
- const entries = Object.entries(classesRecord);
365
- if (entries.length === 0 || entries.length > 64) {
366
- throw new Error(`Invalid ${labelArg}: storageClasses must contain between 1 and 64 entries`);
367
- }
368
-
369
- const normalizedEntries = entries.map(([classKeyArg, candidateArg]) => {
370
- if (!storageClassKeyPattern.test(classKeyArg)) {
371
- throw new Error(`Invalid ${labelArg}: storage class key '${classKeyArg}' is not stable`);
372
- }
373
- const classLabel = `${labelArg} storageClasses.${classKeyArg}`;
374
- const candidate = assertPlainRecord(candidateArg, classLabel);
375
- assertExactKeys(candidate, ['kind', 'purpose', 'required', 'preferred'], classLabel);
376
- if (!['filesystem', 'objectStorage'].includes(candidate.kind as string)) {
377
- throw new Error(`Invalid ${classLabel}: kind must be filesystem or objectStorage`);
378
- }
379
- if (!['runtime', 'database', 'registry', 'backup'].includes(candidate.purpose as string)) {
380
- throw new Error(`Invalid ${classLabel}: purpose is not supported`);
381
- }
382
- const required = validateClassRequirement(candidate.required, `${classLabel}.required`);
383
- const preferred = validateClassRequirement(candidate.preferred, `${classLabel}.preferred`);
384
- if (required && preferred) {
385
- for (const requirementKey of Object.keys(required) as Array<keyof typeof required>) {
386
- if (preferred[requirementKey] !== undefined
387
- && preferred[requirementKey] !== required[requirementKey]) {
388
- throw new Error(
389
- `Invalid ${classLabel}: preferred.${String(requirementKey)} conflicts with required.${String(requirementKey)}`,
390
- );
391
- }
392
- }
393
- }
394
- const normalized: interfaces.appstore.IAppStoreStorageClass = {
395
- kind: candidate.kind as interfaces.platform.TStorageResourceKind,
396
- purpose: candidate.purpose as interfaces.appstore.TAppStoreStoragePurpose,
397
- ...(required ? { required } : {}),
398
- ...(preferred ? { preferred } : {}),
399
- };
400
- return [classKeyArg, normalized] as const;
401
- });
402
-
403
- return Object.fromEntries(normalizedEntries);
404
- }
405
-
406
- function validateClassRequirement(
407
- requirementArg: unknown,
408
- labelArg: string,
409
- ): interfaces.platform.IStorageClassRequirement | undefined {
410
- if (requirementArg === undefined) return undefined;
411
- const requirement = assertPlainRecord(requirementArg, labelArg);
412
- assertExactKeys(requirement, [
413
- 'performanceTier',
414
- 'durability',
415
- 'topology',
416
- 'hardQuota',
417
- 'snapshots',
418
- 'backup',
419
- 'encryptedInTransit',
420
- ], labelArg);
421
- assertOptionalMember(
422
- requirement.performanceTier,
423
- ['standard', 'highIops', 'capacity'],
424
- `${labelArg}.performanceTier`,
425
- );
426
- assertOptionalMember(
427
- requirement.durability,
428
- ['ephemeral', 'persistent'],
429
- `${labelArg}.durability`,
430
- );
431
- assertOptionalMember(
432
- requirement.topology,
433
- ['singleNode', 'multiNode'],
434
- `${labelArg}.topology`,
435
- );
436
- assertOptionalMember(requirement.snapshots, ['portable', 'native'], `${labelArg}.snapshots`);
437
- for (const booleanKey of ['hardQuota', 'backup', 'encryptedInTransit'] as const) {
438
- if (requirement[booleanKey] !== undefined && requirement[booleanKey] !== true) {
439
- throw new Error(`Invalid ${labelArg}.${booleanKey}: only true may be declared`);
440
- }
441
- }
442
- return { ...requirement } as interfaces.platform.IStorageClassRequirement;
443
- }
444
-
445
- function validateExplicitStorageRequests(
446
- requestsArg: TAppStoreVersionConfig['storageRequests'],
447
- classesArg: Record<string, interfaces.appstore.IAppStoreStorageClass>,
448
- appInputDestinationsArg: IAppInputDestinations,
449
- labelArg: string,
450
- ): interfaces.appstore.TAppStoreStorageRequest[] {
451
- if (requestsArg === undefined) return [];
452
- if (!Array.isArray(requestsArg) || requestsArg.length === 0 || requestsArg.length > 64) {
453
- throw new Error(`Invalid ${labelArg}: storageRequests must contain between 1 and 64 entries`);
454
- }
455
-
456
- const requestIds = new Set<string>();
457
- const usedClassKeys = new Set<string>();
458
- const deliveryEnvironmentKeys = new Set<string>();
459
- const {
460
- declaredEnvironmentKeys,
461
- runtimeEnvironmentKeys,
462
- declaredSecretPaths,
463
- } = appInputDestinationsArg;
464
- const deliverySecretPaths = new Set<string>();
465
-
466
- const requests = requestsArg.map((candidateArg, indexArg) => {
467
- const requestLabel = `${labelArg} storageRequests[${indexArg}]`;
468
- const candidate = assertPlainRecord(candidateArg, requestLabel);
469
- if (candidate.kind === 'filesystem') {
470
- assertExactKeys(candidate, [
471
- 'id', 'kind', 'storageClass', 'capacity', 'reclaimPolicy',
472
- 'mountPath', 'accessMode', 'protection',
473
- ], requestLabel);
474
- } else if (candidate.kind === 'objectStorage') {
475
- assertExactKeys(candidate, [
476
- 'id', 'kind', 'storageClass', 'capacity', 'reclaimPolicy',
477
- 'accessMode', 'delivery', 'protection',
478
- ], requestLabel);
479
- } else {
480
- throw new Error(`Invalid ${requestLabel}: kind must be filesystem or objectStorage`);
481
- }
482
- if (typeof candidate.id !== 'string' || !storageRequestIdPattern.test(candidate.id)) {
483
- throw new Error(`Invalid ${requestLabel}: id must be a stable lowercase identifier`);
484
- }
485
- if (requestIds.has(candidate.id)) {
486
- throw new Error(`Invalid ${labelArg}: storage request id '${candidate.id}' is duplicated`);
487
- }
488
- requestIds.add(candidate.id);
489
- if (typeof candidate.storageClass !== 'string'
490
- || !Object.hasOwn(classesArg, candidate.storageClass)) {
491
- throw new Error(`Invalid ${requestLabel}: storageClass '${candidate.storageClass}' is not declared`);
492
- }
493
- const storageClass = classesArg[candidate.storageClass];
494
- if (storageClass.kind !== candidate.kind) {
495
- throw new Error(`Invalid ${requestLabel}: storageClass kind does not match request kind`);
496
- }
497
- usedClassKeys.add(candidate.storageClass);
498
- if (!['retain', 'delete'].includes(candidate.reclaimPolicy as string)) {
499
- throw new Error(`Invalid ${requestLabel}: reclaimPolicy must be retain or delete`);
500
- }
501
- const capacity = validateCapacity(candidate.capacity, `${requestLabel}.capacity`);
502
- if (capacity?.limit && storageClass.required?.hardQuota !== true) {
503
- throw new Error(`Invalid ${requestLabel}: capacity.limit requires storageClass.required.hardQuota`);
504
- }
505
-
506
- if (candidate.kind === 'filesystem') {
507
- if (typeof candidate.mountPath !== 'string'
508
- || interfaces.data.normalizeServiceAbsolutePath(candidate.mountPath) !== candidate.mountPath
509
- || candidate.mountPath === '/'
510
- || pathsOverlap(candidate.mountPath, '/run/secrets')) {
511
- throw new Error(`Invalid ${requestLabel}: mountPath must be a canonical non-root path outside /run/secrets`);
512
- }
513
- assertOptionalMember(
514
- candidate.accessMode,
515
- ['ReadWriteOnce', 'ReadOnlyMany', 'ReadWriteMany'],
516
- `${requestLabel}.accessMode`,
517
- false,
518
- );
519
- const protection = validateFilesystemProtection(
520
- candidate.protection,
521
- `${requestLabel}.protection`,
522
- );
523
- if (protection?.backup === 'required' && storageClass.required?.backup !== true) {
524
- throw new Error(`Invalid ${requestLabel}: required backup must be declared by the storage class`);
525
- }
526
- if (protection?.snapshots && storageClass.required?.snapshots !== protection.snapshots) {
527
- throw new Error(`Invalid ${requestLabel}: required snapshots must be declared by the storage class`);
528
- }
529
- return {
530
- id: candidate.id,
531
- kind: 'filesystem',
532
- storageClass: candidate.storageClass,
533
- ...(capacity ? { capacity } : {}),
534
- reclaimPolicy: candidate.reclaimPolicy as interfaces.platform.TStorageReclaimPolicy,
535
- mountPath: candidate.mountPath,
536
- accessMode: candidate.accessMode as interfaces.platform.TFilesystemStorageAccessMode,
537
- ...(protection ? { protection } : {}),
538
- } satisfies interfaces.appstore.IAppStoreFilesystemStorageRequest;
539
- }
540
-
541
- assertOptionalMember(
542
- candidate.accessMode,
543
- ['readOnly', 'readWrite'],
544
- `${requestLabel}.accessMode`,
545
- false,
546
- );
547
- const delivery = validateObjectStorageDelivery(
548
- candidate.delivery,
549
- requestLabel,
550
- declaredEnvironmentKeys,
551
- runtimeEnvironmentKeys,
552
- deliveryEnvironmentKeys,
553
- declaredSecretPaths,
554
- deliverySecretPaths,
555
- );
556
- const protection = validateObjectStorageProtection(
557
- candidate.protection,
558
- `${requestLabel}.protection`,
559
- );
560
- if (protection?.backup === 'required' && storageClass.required?.backup !== true) {
561
- throw new Error(`Invalid ${requestLabel}: required backup must be declared by the storage class`);
562
- }
563
- return {
564
- id: candidate.id,
565
- kind: 'objectStorage',
566
- storageClass: candidate.storageClass,
567
- ...(capacity ? { capacity } : {}),
568
- reclaimPolicy: candidate.reclaimPolicy as interfaces.platform.TStorageReclaimPolicy,
569
- accessMode: candidate.accessMode as interfaces.platform.TObjectStorageAccessMode,
570
- delivery,
571
- ...(protection ? { protection } : {}),
572
- } satisfies interfaces.appstore.IAppStoreObjectStorageRequest;
573
- });
574
-
575
- for (const classKeyArg of Object.keys(classesArg)) {
576
- if (!usedClassKeys.has(classKeyArg)) {
577
- throw new Error(`Invalid ${labelArg}: storage class '${classKeyArg}' is not used by a request`);
578
- }
579
- }
580
- return requests;
581
- }
582
-
583
- function validateCapacity(
584
- capacityArg: unknown,
585
- labelArg: string,
586
- ): interfaces.platform.IStorageCapacityRequest | undefined {
587
- if (capacityArg === undefined) return undefined;
588
- const capacity = assertPlainRecord(capacityArg, labelArg);
589
- assertExactKeys(capacity, ['request', 'limit'], labelArg);
590
- const requestedBytes = parseStorageCapacityQuantity(capacity.request, `${labelArg}.request`);
591
- const limitBytes = capacity.limit === undefined
592
- ? undefined
593
- : parseStorageCapacityQuantity(capacity.limit, `${labelArg}.limit`);
594
- if (limitBytes !== undefined && limitBytes < requestedBytes) {
595
- throw new Error(`Invalid ${labelArg}: limit must be greater than or equal to request`);
596
- }
597
- return {
598
- request: capacity.request as string,
599
- ...(capacity.limit === undefined ? {} : { limit: capacity.limit as string }),
600
- };
601
- }
602
-
603
- /**
604
- * An absent `protection` member already states that the request asks for no protection, so an
605
- * empty one states nothing a fulfillment adapter could act on. The storage contract refuses it by
606
- * name (`protection must not be empty`), and this package is the producer of those requests: the
607
- * manifest author is told here instead of a control plane refusing the request later. Members are
608
- * copied one by one rather than spread, so a member declared as an explicit `undefined` — which
609
- * the member checks treat as absent, and which the contract refuses as a non-JSON data property —
610
- * can never reach the emitted request.
611
- */
612
- function assertProtectionDeclaresRequirement<TProtection extends object>(
613
- protectionArg: TProtection,
614
- labelArg: string,
615
- ): TProtection {
616
- if (Object.keys(protectionArg).length === 0) {
617
- throw new Error(
618
- `Invalid ${labelArg}: declare at least one protection requirement or omit protection`,
619
- );
620
- }
621
- return protectionArg;
622
- }
623
-
624
- function validateFilesystemProtection(
625
- protectionArg: unknown,
626
- labelArg: string,
627
- ): interfaces.appstore.IAppStoreFilesystemProtection | undefined {
628
- if (protectionArg === undefined) return undefined;
629
- const protection = assertPlainRecord(protectionArg, labelArg);
630
- assertExactKeys(protection, ['backup', 'snapshots', 'consistency'], labelArg);
631
- assertOptionalMember(protection.backup, ['required'], `${labelArg}.backup`);
632
- assertOptionalMember(protection.snapshots, ['portable', 'native'], `${labelArg}.snapshots`);
633
- assertOptionalMember(
634
- protection.consistency,
635
- ['crashConsistent', 'applicationConsistent'],
636
- `${labelArg}.consistency`,
637
- );
638
- return assertProtectionDeclaresRequirement({
639
- ...(protection.backup === undefined
640
- ? {}
641
- : { backup: protection.backup as 'required' }),
642
- ...(protection.snapshots === undefined
643
- ? {}
644
- : { snapshots: protection.snapshots as Exclude<interfaces.platform.TStorageSnapshotMode, 'none'> }),
645
- ...(protection.consistency === undefined
646
- ? {}
647
- : { consistency: protection.consistency as 'crashConsistent' | 'applicationConsistent' }),
648
- }, labelArg);
649
- }
650
-
651
- function validateObjectStorageProtection(
652
- protectionArg: unknown,
653
- labelArg: string,
654
- ): interfaces.appstore.IAppStoreObjectStorageProtection | undefined {
655
- if (protectionArg === undefined) return undefined;
656
- const protection = assertPlainRecord(protectionArg, labelArg);
657
- assertExactKeys(protection, ['backup', 'versioning', 'retentionDays'], labelArg);
658
- assertOptionalMember(protection.backup, ['required'], `${labelArg}.backup`);
659
- assertOptionalMember(protection.versioning, ['required'], `${labelArg}.versioning`);
660
- if (protection.retentionDays !== undefined
661
- && (!Number.isInteger(protection.retentionDays)
662
- || (protection.retentionDays as number) < 1
663
- || (protection.retentionDays as number) > 36500)) {
664
- throw new Error(`Invalid ${labelArg}.retentionDays: expected an integer between 1 and 36500`);
665
- }
666
- return assertProtectionDeclaresRequirement({
667
- ...(protection.backup === undefined
668
- ? {}
669
- : { backup: protection.backup as 'required' }),
670
- ...(protection.versioning === undefined
671
- ? {}
672
- : { versioning: protection.versioning as 'required' }),
673
- ...(protection.retentionDays === undefined
674
- ? {}
675
- : { retentionDays: protection.retentionDays as number }),
676
- }, labelArg);
677
- }
678
-
679
- function validateObjectStorageDelivery(
680
- deliveryArg: unknown,
681
- requestLabelArg: string,
682
- declaredEnvironmentKeysArg: Set<string>,
683
- runtimeEnvironmentKeysArg: Set<string>,
684
- deliveryEnvironmentKeysArg: Set<string>,
685
- declaredSecretPathsArg: Set<string>,
686
- deliverySecretPathsArg: Set<string>,
687
- ): interfaces.platform.TObjectStorageDelivery {
688
- const deliveryLabel = `${requestLabelArg}.delivery`;
689
- const delivery = assertPlainRecord(deliveryArg, deliveryLabel);
690
- if (delivery.type === 'launcher-environment') {
691
- assertExactKeys(delivery, ['type', 'keys'], deliveryLabel);
692
- const keys = assertPlainRecord(delivery.keys, `${deliveryLabel}.keys`);
693
- assertExactKeys(keys, [
694
- 'endpoint', 'bucket', 'region', 'accessKeyId', 'secretAccessKey', 'sessionToken',
695
- ], `${deliveryLabel}.keys`);
696
- for (const requiredKey of ['endpoint', 'bucket', 'region', 'accessKeyId', 'secretAccessKey']) {
697
- if (typeof keys[requiredKey] !== 'string') {
698
- throw new Error(`Invalid ${deliveryLabel}.keys: '${requiredKey}' is required`);
699
- }
700
- }
701
- const targetKeys = Object.values(keys);
702
- const localKeys = new Set<string>();
703
- for (const targetKeyArg of targetKeys) {
704
- if (typeof targetKeyArg !== 'string' || !environmentKeyPattern.test(targetKeyArg)) {
705
- throw new Error(`Invalid ${deliveryLabel}: environment delivery keys must be canonical`);
706
- }
707
- if (localKeys.has(targetKeyArg)) {
708
- throw new Error(`Invalid ${deliveryLabel}: environment key '${targetKeyArg}' is duplicated`);
709
- }
710
- if (declaredEnvironmentKeysArg.has(targetKeyArg)
711
- || runtimeEnvironmentKeysArg.has(targetKeyArg)) {
712
- throw new Error(
713
- `Invalid ${deliveryLabel}: environment key '${targetKeyArg}' collides with an app runtime input`,
714
- );
715
- }
716
- if (deliveryEnvironmentKeysArg.has(targetKeyArg)) {
717
- throw new Error(`Invalid ${deliveryLabel}: environment key '${targetKeyArg}' is used by another storage request`);
718
- }
719
- localKeys.add(targetKeyArg);
720
- deliveryEnvironmentKeysArg.add(targetKeyArg);
721
- }
722
- return {
723
- type: 'launcher-environment',
724
- keys: { ...keys },
725
- } as interfaces.platform.IObjectStorageLauncherEnvironmentDelivery;
726
- }
727
-
728
- if (delivery.type === 'file') {
729
- assertExactKeys(delivery, ['type', 'targetPath', 'format', 'uid', 'gid', 'mode'], deliveryLabel);
730
- if (delivery.format !== 'servezone-object-storage') {
731
- throw new Error(`Invalid ${deliveryLabel}: secret file format is not supported`);
732
- }
733
- if (typeof delivery.targetPath !== 'string'
734
- || interfaces.data.normalizeServiceAbsolutePath(delivery.targetPath) !== delivery.targetPath
735
- || !/^\/run\/secrets\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(delivery.targetPath)) {
736
- throw new Error(`Invalid ${deliveryLabel}: targetPath must be a unique canonical file below /run/secrets`);
737
- }
738
- if (declaredSecretPathsArg.has(delivery.targetPath)) {
739
- throw new Error(`Invalid ${deliveryLabel}: targetPath collides with an app secret delivery`);
740
- }
741
- if (deliverySecretPathsArg.has(delivery.targetPath)) {
742
- throw new Error(`Invalid ${deliveryLabel}: targetPath is used by another storage request`);
743
- }
744
- // `-0` survives JSON.parse and passes both the safe-integer and the range check, but the
745
- // storage contract refuses it because it is a second spelling of the same identity and would
746
- // give one request two canonical digests.
747
- for (const identityField of ['uid', 'gid'] as const) {
748
- if (!Number.isSafeInteger(delivery[identityField])
749
- || Object.is(delivery[identityField], -0)
750
- || (delivery[identityField] as number) < 0
751
- || (delivery[identityField] as number) > 2_147_483_647) {
752
- throw new Error(`Invalid ${deliveryLabel}: ${identityField} must be a bounded numeric identity`);
753
- }
754
- }
755
- if (delivery.mode !== 0o400 && delivery.mode !== 0o440) {
756
- throw new Error(`Invalid ${deliveryLabel}: mode must be 0400 or 0440`);
757
- }
758
- deliverySecretPathsArg.add(delivery.targetPath);
759
- return {
760
- type: 'file',
761
- targetPath: delivery.targetPath,
762
- format: 'servezone-object-storage',
763
- uid: delivery.uid as number,
764
- gid: delivery.gid as number,
765
- mode: delivery.mode as 0o400 | 0o440,
766
- };
767
- }
768
-
769
- throw new Error(`Invalid ${deliveryLabel}: type must be launcher-environment or file`);
770
- }
771
-
772
- function validateFeatureDeclaration(
773
- configArg: TAppStoreVersionConfig,
774
- explicitRequestsArg: interfaces.appstore.TAppStoreStorageRequest[],
775
- labelArg: string,
776
- ): TAppStoreStorageFeatureId[] {
777
- const requiredFeatures = new Set<TAppStoreStorageFeatureId>();
778
- if (explicitRequestsArg.length > 0) {
779
- requiredFeatures.add(storageFeatureIds.bindings);
780
- }
781
- if (explicitRequestsArg.some((requestArg) => requestArg.kind === 'filesystem')) {
782
- requiredFeatures.add(storageFeatureIds.filesystem);
783
- }
784
- if (explicitRequestsArg.some((requestArg) => requestArg.kind === 'objectStorage')) {
785
- requiredFeatures.add(storageFeatureIds.objectStorage);
786
- }
787
- if (explicitRequestsArg.some((requestArg) => (
788
- requestArg.kind === 'objectStorage' && requestArg.delivery.type === 'file'
789
- ))) {
790
- requiredFeatures.add(storageFeatureIds.objectStorageFile);
791
- }
792
-
793
- const declaredFeatures = configArg.requiresFeatures || [];
794
- if (!Array.isArray(declaredFeatures)
795
- || declaredFeatures.some((entryArg) => typeof entryArg !== 'string')) {
796
- throw new Error(`Invalid ${labelArg}: requiresFeatures must be an array of strings`);
797
- }
798
- if (new Set(declaredFeatures).size !== declaredFeatures.length) {
799
- throw new Error(`Invalid ${labelArg}: requiresFeatures must not contain duplicates`);
800
- }
801
- for (const requiredFeature of requiredFeatures) {
802
- if (!declaredFeatures.includes(requiredFeature)) {
803
- throw new Error(`Invalid ${labelArg}: requiresFeatures is missing '${requiredFeature}'`);
804
- }
805
- }
806
- for (const declaredFeature of declaredFeatures) {
807
- if (declaredFeature.startsWith('storage.') && !knownStorageFeatureIds.has(declaredFeature)) {
808
- throw new Error(`Invalid ${labelArg}: unknown storage feature '${declaredFeature}'`);
809
- }
810
- if (knownStorageFeatureIds.has(declaredFeature)
811
- && !requiredFeatures.has(declaredFeature as TAppStoreStorageFeatureId)) {
812
- throw new Error(`Invalid ${labelArg}: storage feature '${declaredFeature}' is not used`);
813
- }
814
- }
815
- return Array.from(requiredFeatures);
816
- }
817
-
818
- function validatePlatformRequirements(
819
- requirementsArg: TAppStoreVersionConfig['platformRequirements'],
820
- labelArg: string,
821
- ): void {
822
- if (requirementsArg === undefined) return;
823
- const requirements = assertPlainRecord(requirementsArg, `${labelArg} platformRequirements`);
824
- assertExactKeys(
825
- requirements,
826
- ['mongodb', 's3', 'clickhouse', 'valkey', 'mariadb', 'mail'],
827
- `${labelArg} platformRequirements`,
828
- );
829
- for (const [keyArg, valueArg] of Object.entries(requirements)) {
830
- if (typeof valueArg !== 'boolean') {
831
- throw new Error(`Invalid ${labelArg} platformRequirements.${keyArg}: expected a boolean`);
832
- }
833
- }
834
- }
835
-
836
- function normalizeLegacyVolumes(
837
- volumesArg: TAppStoreVersionConfig['volumes'],
838
- labelArg: string,
839
- ): interfaces.appstore.IAppStoreVolume[] {
840
- if (volumesArg === undefined) return [];
841
- if (!Array.isArray(volumesArg) || volumesArg.length > 64) {
842
- throw new Error(`Invalid ${labelArg}: volumes must be an array with at most 64 entries`);
843
- }
844
- return volumesArg.map((volumeArg, indexArg) => {
845
- const volumeLabel = `${labelArg} volumes[${indexArg}]`;
846
- const normalized = typeof volumeArg === 'string'
847
- ? { mountPath: volumeArg }
848
- : assertPlainRecord(volumeArg, volumeLabel);
849
- assertExactKeys(
850
- normalized,
851
- ['name', 'source', 'mountPath', 'driver', 'readOnly', 'backup', 'options'],
852
- volumeLabel,
853
- );
854
- for (const forbiddenKey of ['source', 'driver', 'options']) {
855
- if (normalized[forbiddenKey] !== undefined) {
856
- throw new Error(`Invalid ${volumeLabel}: physical field '${forbiddenKey}' cannot be normalized portably`);
857
- }
858
- }
859
- if (typeof normalized.mountPath !== 'string'
860
- || interfaces.data.normalizeServiceAbsolutePath(normalized.mountPath) !== normalized.mountPath
861
- || normalized.mountPath === '/'
862
- || pathsOverlap(normalized.mountPath, '/run/secrets')) {
863
- throw new Error(`Invalid ${volumeLabel}: mountPath must be a canonical non-root path outside /run/secrets`);
864
- }
865
- if (normalized.name !== undefined
866
- && (typeof normalized.name !== 'string' || !storageRequestIdPattern.test(normalized.name))) {
867
- throw new Error(`Invalid ${volumeLabel}: name must be a stable lowercase identifier`);
868
- }
869
- for (const booleanKey of ['readOnly', 'backup']) {
870
- if (normalized[booleanKey] !== undefined && typeof normalized[booleanKey] !== 'boolean') {
871
- throw new Error(`Invalid ${volumeLabel}.${booleanKey}: expected a boolean`);
872
- }
873
- }
874
- return {
875
- ...(normalized.name === undefined ? {} : { name: normalized.name as string }),
876
- mountPath: normalized.mountPath,
877
- ...(normalized.readOnly === undefined ? {} : { readOnly: normalized.readOnly as boolean }),
878
- ...(normalized.backup === undefined ? {} : { backup: normalized.backup as boolean }),
879
- };
880
- });
881
- }
882
-
883
- function createLegacyVolumeRequestId(
884
- volumeArg: interfaces.appstore.IAppStoreVolume,
885
- indexArg: number,
886
- labelArg: string,
887
- ): string {
888
- const stablePart = volumeArg.name || volumeArg.mountPath
889
- .toLowerCase()
890
- .split('/')
891
- .filter(Boolean)
892
- .join('-')
893
- .replace(/[^a-z0-9-]+/g, '-')
894
- .replace(/^-+|-+$/g, '');
895
- const requestId = `legacy-volume-${stablePart}`;
896
- if (!stablePart || !storageRequestIdPattern.test(requestId)) {
897
- throw new Error(
898
- `Invalid ${labelArg} volumes[${indexArg}]: cannot derive a stable request id; declare a shorter lowercase name`,
899
- );
900
- }
901
- return requestId;
902
- }
903
-
904
- function validateAllFilesystemMountsAndSecretTargets(
905
- requestsArg: interfaces.appstore.TAppStoreStorageRequest[],
906
- declaredSecretPathsArg: Set<string>,
907
- labelArg: string,
908
- ): void {
909
- const mountPaths: string[] = [];
910
- const objectSecretPaths: string[] = [];
911
- for (const requestArg of requestsArg) {
912
- if (requestArg.kind === 'filesystem') {
913
- assertNoPathCollision(requestArg.mountPath, mountPaths, `${labelArg} request '${requestArg.id}'`);
914
- mountPaths.push(requestArg.mountPath);
915
- } else if (requestArg.delivery.type === 'file') {
916
- objectSecretPaths.push(requestArg.delivery.targetPath);
917
- }
918
- }
919
- for (const secretPathArg of [...declaredSecretPathsArg, ...objectSecretPaths]) {
920
- if (mountPaths.some((mountPathArg) => pathsOverlap(secretPathArg, mountPathArg))) {
921
- throw new Error(`Invalid ${labelArg}: secret target '${secretPathArg}' overlaps a filesystem mount`);
922
- }
923
- }
924
- }
925
-
926
- function assertNoPathCollision(pathArg: string, pathsArg: string[], labelArg: string): void {
927
- const collision = pathsArg.find((existingPathArg) => pathsOverlap(pathArg, existingPathArg));
928
- if (collision) {
929
- throw new Error(`Invalid ${labelArg}: path '${pathArg}' overlaps '${collision}'`);
930
- }
931
- }
932
-
933
- function pathsOverlap(leftArg: string, rightArg: string): boolean {
934
- const left = interfaces.data.normalizeServiceAbsolutePath(leftArg);
935
- const right = interfaces.data.normalizeServiceAbsolutePath(rightArg);
936
- if (!left || !right) return false;
937
- if (left === '/' || right === '/') return true;
938
- return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
939
- }
940
-
941
- function assertPlainRecord(valueArg: unknown, labelArg: string): TPlainRecord {
942
- if (!valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg)) {
943
- throw new Error(`Invalid ${labelArg}: expected an object`);
944
- }
945
- return valueArg as TPlainRecord;
946
- }
947
-
948
- function assertExactKeys(
949
- recordArg: TPlainRecord,
950
- allowedKeysArg: readonly string[],
951
- labelArg: string,
952
- ): void {
953
- const allowedKeys = new Set(allowedKeysArg);
954
- const unknownKeys = Object.keys(recordArg).filter((keyArg) => !allowedKeys.has(keyArg));
955
- if (unknownKeys.length > 0) {
956
- throw new Error(`Invalid ${labelArg}: unknown field(s) ${unknownKeys.sort().join(', ')}`);
957
- }
958
- }
959
-
960
- function assertOptionalMember(
961
- valueArg: unknown,
962
- allowedValuesArg: readonly string[],
963
- labelArg: string,
964
- optionalArg = true,
965
- ): void {
966
- if (valueArg === undefined && optionalArg) return;
967
- if (typeof valueArg !== 'string' || !allowedValuesArg.includes(valueArg)) {
968
- throw new Error(`Invalid ${labelArg}: expected one of ${allowedValuesArg.join(', ')}`);
969
- }
970
- }