@ankhorage/contracts 21.0.0 → 22.0.1
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 +12 -0
- package/README.md +3 -1
- package/dist/appManifest/apis.d.ts +3 -3
- package/dist/appManifest/apis.d.ts.map +1 -1
- package/dist/appManifest/apis.js +4 -6
- package/dist/appManifest/apis.js.map +1 -1
- package/dist/appManifest/bindings.d.ts.map +1 -1
- package/dist/appManifest/bindings.js +2 -1
- package/dist/appManifest/bindings.js.map +1 -1
- package/dist/appManifest/data.d.ts.map +1 -1
- package/dist/appManifest/data.js +5 -4
- package/dist/appManifest/data.js.map +1 -1
- package/dist/appManifest/isAppManifest.js +3 -3
- package/dist/appManifest/isAppManifest.js.map +1 -1
- package/dist/appManifest/isManifestValue.d.ts +1 -1
- package/dist/appManifest/isManifestValue.d.ts.map +1 -1
- package/dist/appManifest/isManifestValue.js +3 -12
- package/dist/appManifest/isManifestValue.js.map +1 -1
- package/dist/appManifest/screens.d.ts +3 -2
- package/dist/appManifest/screens.d.ts.map +1 -1
- package/dist/appManifest/screens.js +15 -7
- package/dist/appManifest/screens.js.map +1 -1
- package/dist/appManifest/splashScreen.d.ts +8 -11
- package/dist/appManifest/splashScreen.d.ts.map +1 -1
- package/dist/appManifest/splashScreen.js.map +1 -1
- package/dist/bindings.d.ts +2 -3
- package/dist/bindings.d.ts.map +1 -1
- package/dist/bindings.js.map +1 -1
- package/dist/collections.d.ts +4 -0
- package/dist/collections.d.ts.map +1 -0
- package/dist/collections.js +6 -0
- package/dist/collections.js.map +1 -0
- package/dist/data/apis.d.ts +1 -1
- package/dist/data/apis.d.ts.map +1 -1
- package/dist/data/apis.js.map +1 -1
- package/dist/data/values.d.ts +2 -3
- package/dist/data/values.d.ts.map +1 -1
- package/dist/data/values.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/infra/isInfraEnvironmentSpec.d.ts.map +1 -1
- package/dist/infra/isInfraEnvironmentSpec.js +10 -12
- package/dist/infra/isInfraEnvironmentSpec.js.map +1 -1
- package/dist/infra/isInfraManifest.d.ts.map +1 -1
- package/dist/infra/isInfraManifest.js +14 -5
- package/dist/infra/isInfraManifest.js.map +1 -1
- package/dist/infra/isInfraWorkloadSpec.d.ts.map +1 -1
- package/dist/infra/isInfraWorkloadSpec.js +26 -20
- package/dist/infra/isInfraWorkloadSpec.js.map +1 -1
- package/dist/requirements.d.ts +5 -10
- package/dist/requirements.d.ts.map +1 -1
- package/dist/requirements.js.map +1 -1
- package/dist/serializable.d.ts +6 -0
- package/dist/serializable.d.ts.map +1 -0
- package/dist/serializable.js +14 -0
- package/dist/serializable.js.map +1 -0
- package/dist/state.d.ts +2 -3
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js.map +1 -1
- package/dist/types/infraManifest.d.ts +14 -13
- package/dist/types/infraManifest.d.ts.map +1 -1
- package/dist/types/infraManifest.js.map +1 -1
- package/dist/types/infraWorkload.d.ts +11 -11
- package/dist/types/infraWorkload.d.ts.map +1 -1
- package/dist/types/infraWorkload.js.map +1 -1
- package/dist/types.d.ts +9 -8
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +2 -3
- package/src/appManifest/apis.ts +9 -6
- package/src/appManifest/bindings.ts +7 -1
- package/src/appManifest/data.ts +13 -4
- package/src/appManifest/isAppManifest.ts +3 -3
- package/src/appManifest/isManifestValue.ts +3 -16
- package/src/appManifest/screens.ts +21 -10
- package/src/appManifest/splashScreen.test.ts +3 -3
- package/src/appManifest/splashScreen.ts +8 -18
- package/src/appManifest.test.ts +21 -16
- package/src/bindings.test.ts +11 -8
- package/src/bindings.ts +2 -9
- package/src/collections.ts +8 -0
- package/src/contracts.test.ts +16 -13
- package/src/data/apis.test.ts +6 -4
- package/src/data/apis.ts +1 -1
- package/src/data/values.ts +3 -9
- package/src/deploy.test.ts +4 -4
- package/src/index.ts +2 -0
- package/src/infra/isInfraEnvironmentSpec.ts +16 -16
- package/src/infra/isInfraManifest.ts +17 -6
- package/src/infra/isInfraWorkloadSpec.ts +39 -24
- package/src/infra-published-port.test.ts +8 -8
- package/src/infra-workload-persistence.test.ts +4 -4
- package/src/infra-workload.test.ts +24 -22
- package/src/infra.test.ts +16 -20
- package/src/infraPersistence.test.ts +1 -1
- package/src/infraServices.test.ts +1 -1
- package/src/requirements.test.ts +2 -2
- package/src/requirements.ts +6 -12
- package/src/secrets.test.ts +1 -1
- package/src/serializable.ts +25 -0
- package/src/state.ts +3 -6
- package/src/types/infraManifest.ts +17 -13
- package/src/types/infraWorkload.ts +12 -11
- package/src/types.ts +11 -12
|
@@ -2,6 +2,7 @@ import { isStringArray } from '@ankhorage/utility/array';
|
|
|
2
2
|
import { isRecord } from '@ankhorage/utility/object';
|
|
3
3
|
import { isNonEmptyString } from '@ankhorage/utility/string';
|
|
4
4
|
|
|
5
|
+
import { isSerializableSet } from '../collections';
|
|
5
6
|
import type { InfraShape } from '../types/infraValidation';
|
|
6
7
|
import type { InfraWorkloadSpec } from '../types/infraWorkload';
|
|
7
8
|
import { infraFields } from './infraFields';
|
|
@@ -18,15 +19,11 @@ export function isInfraWorkloadSpec(value: unknown): value is InfraWorkloadSpec
|
|
|
18
19
|
isInfraShape(artifact, { kind: (kind) => kind === 'image', image: isNonEmptyString }),
|
|
19
20
|
command: infraFields.optionalStrings,
|
|
20
21
|
args: (args) => args === undefined || isStringArray(args),
|
|
21
|
-
ports: (ports) =>
|
|
22
|
-
ports === undefined ||
|
|
23
|
-
(Array.isArray(ports) && ports.every(isPort) && hasUniqueField(ports, 'name')),
|
|
22
|
+
ports: (ports) => ports === undefined || isPortRegistry(ports),
|
|
24
23
|
environment: (environment) =>
|
|
25
24
|
environment === undefined ||
|
|
26
25
|
(isRecord(environment) && Object.values(environment).every(isInfraWorkloadValue)),
|
|
27
|
-
files: (files) =>
|
|
28
|
-
files === undefined ||
|
|
29
|
-
(Array.isArray(files) && files.every(isFile) && hasUniqueField(files, 'path')),
|
|
26
|
+
files: (files) => files === undefined || isFileMap(files),
|
|
30
27
|
health: (health) => health === undefined || isInfraWorkloadHealth(health),
|
|
31
28
|
resources: (resources) =>
|
|
32
29
|
resources === undefined ||
|
|
@@ -34,16 +31,11 @@ export function isInfraWorkloadSpec(value: unknown): value is InfraWorkloadSpec
|
|
|
34
31
|
cpuMillis: infraFields.optionalPositiveInteger,
|
|
35
32
|
memoryMiB: infraFields.optionalPositiveInteger,
|
|
36
33
|
}),
|
|
37
|
-
persistence: (volumes) =>
|
|
38
|
-
volumes === undefined ||
|
|
39
|
-
(Array.isArray(volumes) &&
|
|
40
|
-
volumes.every(isVolume) &&
|
|
41
|
-
hasUniqueField(volumes, 'id') &&
|
|
42
|
-
hasUniqueField(volumes, 'mountPath')),
|
|
34
|
+
persistence: (volumes) => volumes === undefined || isVolumeRegistry(volumes),
|
|
43
35
|
exposure: (exposure) =>
|
|
44
36
|
exposure === undefined || exposure === 'internal' || exposure === 'public',
|
|
45
37
|
replicas: infraFields.optionalNonnegativeInteger,
|
|
46
|
-
dependsOn:
|
|
38
|
+
dependsOn: (dependsOn) => dependsOn === undefined || isSerializableSet(dependsOn),
|
|
47
39
|
} satisfies InfraShape<InfraWorkloadSpec>)
|
|
48
40
|
) {
|
|
49
41
|
return false;
|
|
@@ -51,10 +43,17 @@ export function isInfraWorkloadSpec(value: unknown): value is InfraWorkloadSpec
|
|
|
51
43
|
return hasValidPublishedPorts(value);
|
|
52
44
|
}
|
|
53
45
|
|
|
46
|
+
/*** Validate named transport ports keyed by their stable authored name. */
|
|
47
|
+
function isPortRegistry(value: unknown): boolean {
|
|
48
|
+
return (
|
|
49
|
+
isRecord(value) &&
|
|
50
|
+
Object.entries(value).every(([name, port]) => isNonEmptyString(name) && isPort(port))
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
54
|
/*** Named transport ports stay independent from runtime resource types. */
|
|
55
55
|
function isPort(value: unknown): boolean {
|
|
56
56
|
return isInfraShape(value, {
|
|
57
|
-
name: isNonEmptyString,
|
|
58
57
|
port: infraFields.port,
|
|
59
58
|
protocol: (protocol) => protocol === undefined || protocol === 'tcp' || protocol === 'udp',
|
|
60
59
|
publishedPort: infraFields.optionalPort,
|
|
@@ -64,7 +63,7 @@ function isPort(value: unknown): boolean {
|
|
|
64
63
|
/*** Require fixed external listeners to belong to one public workload replica. */
|
|
65
64
|
function hasValidPublishedPorts(workload: unknown): boolean {
|
|
66
65
|
if (!isRecord(workload)) return false;
|
|
67
|
-
const ports =
|
|
66
|
+
const ports = isRecord(workload.ports) ? Object.values(workload.ports) : [];
|
|
68
67
|
const published = ports.flatMap((port) => {
|
|
69
68
|
if (!isRecord(port) || typeof port.publishedPort !== 'number') return [];
|
|
70
69
|
return [port.publishedPort];
|
|
@@ -78,9 +77,31 @@ function hasValidPublishedPorts(workload: unknown): boolean {
|
|
|
78
77
|
);
|
|
79
78
|
}
|
|
80
79
|
|
|
81
|
-
/***
|
|
82
|
-
function
|
|
83
|
-
return
|
|
80
|
+
/*** Validate portable config/policy files keyed by their absolute container path. */
|
|
81
|
+
function isFileMap(value: unknown): boolean {
|
|
82
|
+
return (
|
|
83
|
+
isRecord(value) &&
|
|
84
|
+
Object.entries(value).every(
|
|
85
|
+
([filePath, content]) => isAbsoluteWorkloadPath(filePath) && isInfraWorkloadValue(content),
|
|
86
|
+
)
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/*** Validate persistence volumes by stable ID and keep mount paths unique within one workload. */
|
|
91
|
+
function isVolumeRegistry(value: unknown): boolean {
|
|
92
|
+
if (!isRecord(value)) return false;
|
|
93
|
+
const entries = Object.entries(value);
|
|
94
|
+
if (
|
|
95
|
+
!entries.every(
|
|
96
|
+
([volumeId, volume]) => isVolume(volume) && isRecord(volume) && volume.id === volumeId,
|
|
97
|
+
)
|
|
98
|
+
) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
const mountPaths = Object.values(value).map((volume) =>
|
|
102
|
+
isRecord(volume) ? volume.mountPath : undefined,
|
|
103
|
+
);
|
|
104
|
+
return new Set(mountPaths).size === mountPaths.length;
|
|
84
105
|
}
|
|
85
106
|
|
|
86
107
|
/*** Persistence has explicit ownership-local identity, optional initialization and retention policy. */
|
|
@@ -103,9 +124,3 @@ function isAbsoluteWorkloadPath(value: unknown): boolean {
|
|
|
103
124
|
!value.split('/').includes('..')
|
|
104
125
|
);
|
|
105
126
|
}
|
|
106
|
-
|
|
107
|
-
/*** Duplicate workload-local identities would make runtime projection ambiguous. */
|
|
108
|
-
function hasUniqueField(values: readonly unknown[], field: string): boolean {
|
|
109
|
-
const ids = values.map((value) => (isRecord(value) ? Reflect.get(value, field) : undefined));
|
|
110
|
-
return new Set(ids).size === ids.length;
|
|
111
|
-
}
|
|
@@ -5,7 +5,7 @@ import { isInfraEnvironmentSpec, isInfraWorkloadSpec } from './infra';
|
|
|
5
5
|
const publicWorkload = {
|
|
6
6
|
id: 'api',
|
|
7
7
|
artifact: { kind: 'image', image: 'example/api:1' },
|
|
8
|
-
ports:
|
|
8
|
+
ports: { http: { port: 8080, publishedPort: 18_080 } },
|
|
9
9
|
exposure: 'public',
|
|
10
10
|
} as const;
|
|
11
11
|
|
|
@@ -17,16 +17,16 @@ describe('published workload ports', () => {
|
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
it.each([
|
|
20
|
-
{ ...publicWorkload, ports:
|
|
21
|
-
{ ...publicWorkload, ports:
|
|
20
|
+
{ ...publicWorkload, ports: { http: { port: 80, publishedPort: 0 } } },
|
|
21
|
+
{ ...publicWorkload, ports: { http: { port: 80, publishedPort: 65_536 } } },
|
|
22
22
|
{ ...publicWorkload, exposure: 'internal' },
|
|
23
23
|
{ ...publicWorkload, replicas: 2 },
|
|
24
24
|
{
|
|
25
25
|
...publicWorkload,
|
|
26
|
-
ports:
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
ports: {
|
|
27
|
+
http: { port: 80, publishedPort: 8080 },
|
|
28
|
+
admin: { port: 81, publishedPort: 8080 },
|
|
29
|
+
},
|
|
30
30
|
},
|
|
31
31
|
])('rejects an invalid fixed external listener: %j', (workload) => {
|
|
32
32
|
expect(isInfraWorkloadSpec(workload)).toBe(false);
|
|
@@ -36,7 +36,7 @@ describe('published workload ports', () => {
|
|
|
36
36
|
expect(
|
|
37
37
|
isInfraEnvironmentSpec({
|
|
38
38
|
deployment: { compute: { provider: 'local' }, runtime: { provider: 'minikube' } },
|
|
39
|
-
workloads:
|
|
39
|
+
workloads: { api: publicWorkload, admin: { ...publicWorkload, id: 'admin' } },
|
|
40
40
|
}),
|
|
41
41
|
).toBe(false);
|
|
42
42
|
});
|
|
@@ -5,15 +5,15 @@ import { isInfraWorkloadSpec } from './infra';
|
|
|
5
5
|
const workload = {
|
|
6
6
|
id: 'database',
|
|
7
7
|
artifact: { kind: 'image', image: 'example/database:1' },
|
|
8
|
-
persistence:
|
|
9
|
-
{
|
|
8
|
+
persistence: {
|
|
9
|
+
config: {
|
|
10
10
|
id: 'config',
|
|
11
11
|
mountPath: '/etc/database-custom',
|
|
12
12
|
sizeGiB: 1,
|
|
13
13
|
seed: 'image',
|
|
14
14
|
retention: 'retain',
|
|
15
15
|
},
|
|
16
|
-
|
|
16
|
+
},
|
|
17
17
|
} as const;
|
|
18
18
|
|
|
19
19
|
it('accepts image-seeded persistence initialization', () => {
|
|
@@ -24,7 +24,7 @@ it('rejects unsupported persistence initialization modes', () => {
|
|
|
24
24
|
expect(
|
|
25
25
|
isInfraWorkloadSpec({
|
|
26
26
|
...workload,
|
|
27
|
-
persistence:
|
|
27
|
+
persistence: { config: { ...workload.persistence.config, seed: 'empty' } },
|
|
28
28
|
}),
|
|
29
29
|
).toBe(false);
|
|
30
30
|
});
|
|
@@ -10,6 +10,13 @@ import {
|
|
|
10
10
|
} from './infra';
|
|
11
11
|
import type { AppManifest } from './types';
|
|
12
12
|
|
|
13
|
+
const defaultTheme = {
|
|
14
|
+
id: 'default',
|
|
15
|
+
name: 'Default',
|
|
16
|
+
light: { primaryColor: '#3366ff', harmony: 'analogous' },
|
|
17
|
+
dark: { primaryColor: '#6699ff', harmony: 'analogous' },
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
13
20
|
const reference = {
|
|
14
21
|
source: 'secret-store',
|
|
15
22
|
projectId: 'example',
|
|
@@ -22,7 +29,7 @@ const workload = {
|
|
|
22
29
|
artifact: { kind: 'image', image: 'example/backend@sha256:abc' },
|
|
23
30
|
command: ['server'],
|
|
24
31
|
args: ['--verbose'],
|
|
25
|
-
ports:
|
|
32
|
+
ports: { http: { port: 8080, publishedPort: 18_080 } },
|
|
26
33
|
environment: {
|
|
27
34
|
NODE_ENV: { kind: 'literal', value: 'production' },
|
|
28
35
|
DB_PASSWORD: { kind: 'secret', reference },
|
|
@@ -45,36 +52,31 @@ const workload = {
|
|
|
45
52
|
],
|
|
46
53
|
},
|
|
47
54
|
},
|
|
48
|
-
files:
|
|
55
|
+
files: { '/etc/backend/config.json': { kind: 'literal', value: '{}' } },
|
|
49
56
|
health: { kind: 'http', port: 8080, path: '/health', intervalSeconds: 5 },
|
|
50
57
|
resources: { cpuMillis: 500, memoryMiB: 256 },
|
|
51
|
-
persistence:
|
|
58
|
+
persistence: { data: { id: 'data', mountPath: '/data', sizeGiB: 10, retention: 'retain' } },
|
|
52
59
|
exposure: 'public',
|
|
53
60
|
replicas: 1,
|
|
54
|
-
dependsOn:
|
|
61
|
+
dependsOn: { database: true },
|
|
55
62
|
} as const satisfies InfraWorkloadSpec;
|
|
56
63
|
|
|
57
64
|
const invalidWorkloads = [
|
|
58
65
|
{ artifact: { kind: 'build', dockerfile: 'Dockerfile' } },
|
|
59
66
|
{ artifact: { kind: 'image', image: '' } },
|
|
60
|
-
{ ports:
|
|
61
|
-
{ ports:
|
|
62
|
-
{ ports:
|
|
63
|
-
{ ports:
|
|
64
|
-
{
|
|
65
|
-
ports: [
|
|
66
|
-
{ name: 'http', port: 80 },
|
|
67
|
-
{ name: 'http', port: 81 },
|
|
68
|
-
],
|
|
69
|
-
},
|
|
67
|
+
{ ports: { http: { port: 0 } } },
|
|
68
|
+
{ ports: { http: { port: 65536 } } },
|
|
69
|
+
{ ports: { http: { port: 1.5 } } },
|
|
70
|
+
{ ports: { http: { port: 80, protocol: 'sctp' } } },
|
|
71
|
+
{ ports: { '': { port: 80 } } },
|
|
70
72
|
{ replicas: -1 },
|
|
71
73
|
{ replicas: NaN },
|
|
72
74
|
{ replicas: 1.5 },
|
|
73
75
|
{ resources: { memoryMiB: Infinity } },
|
|
74
76
|
{ resources: { cpuMillis: 0 } },
|
|
75
|
-
{ persistence:
|
|
76
|
-
{ persistence:
|
|
77
|
-
{ files:
|
|
77
|
+
{ persistence: { data: { id: 'data', mountPath: '/data', sizeGiB: 1 } } },
|
|
78
|
+
{ persistence: { data: { id: 'data', mountPath: '../data', sizeGiB: 1, retention: 'retain' } } },
|
|
79
|
+
{ files: { '/etc/../secret': { kind: 'literal', value: 'x' } } },
|
|
78
80
|
{ health: { kind: 'http', port: 80, path: '/health', failureThreshold: 0 } },
|
|
79
81
|
{ health: { kind: 'tcp', port: 80, path: '/health' } },
|
|
80
82
|
{ health: { kind: 'command', command: [] } },
|
|
@@ -134,7 +136,7 @@ describe('runtime-neutral workload boundary', () => {
|
|
|
134
136
|
expect(
|
|
135
137
|
isInfraEnvironmentSpec({
|
|
136
138
|
deployment: { compute: { provider: 'local' }, runtime: { provider } },
|
|
137
|
-
workloads:
|
|
139
|
+
workloads: { backend: workload },
|
|
138
140
|
}),
|
|
139
141
|
).toBe(true);
|
|
140
142
|
}
|
|
@@ -156,11 +158,11 @@ describe('runtime-neutral workload boundary', () => {
|
|
|
156
158
|
).toBe(true);
|
|
157
159
|
});
|
|
158
160
|
|
|
159
|
-
it('rejects
|
|
161
|
+
it('rejects workload registries whose key disagrees with workload id', () => {
|
|
160
162
|
expect(
|
|
161
163
|
isInfraEnvironmentSpec({
|
|
162
164
|
deployment: { compute: { provider: 'local' }, runtime: { provider: 'k3s' } },
|
|
163
|
-
workloads:
|
|
165
|
+
workloads: { duplicate: workload },
|
|
164
166
|
}),
|
|
165
167
|
).toBe(false);
|
|
166
168
|
});
|
|
@@ -266,7 +268,7 @@ describe('application state boundary', () => {
|
|
|
266
268
|
category: 'developer_tools',
|
|
267
269
|
themeId: 'default',
|
|
268
270
|
},
|
|
269
|
-
themes:
|
|
271
|
+
themes: { default: defaultTheme },
|
|
270
272
|
activeThemeId: 'default',
|
|
271
273
|
screens: {},
|
|
272
274
|
navigator: { type: 'stack', routes: [] },
|
|
@@ -276,7 +278,7 @@ describe('application state boundary', () => {
|
|
|
276
278
|
deployment: { compute: { provider: 'local' }, runtime: { provider: 'minikube' } },
|
|
277
279
|
},
|
|
278
280
|
},
|
|
279
|
-
modules:
|
|
281
|
+
modules: {},
|
|
280
282
|
},
|
|
281
283
|
settings: { localization: { defaultLocale: 'en', locales: ['en'] } },
|
|
282
284
|
} satisfies AppManifest;
|
package/src/infra.test.ts
CHANGED
|
@@ -23,7 +23,7 @@ const production = {
|
|
|
23
23
|
},
|
|
24
24
|
database: { provider: 'supabase' },
|
|
25
25
|
auth: { provider: 'supabase' },
|
|
26
|
-
objectStorage: { provider: 'r2', buckets:
|
|
26
|
+
objectStorage: { provider: 'r2', buckets: { media: true } },
|
|
27
27
|
authz: { provider: 'cerbos', kind: 'ABAC' },
|
|
28
28
|
secretStore: { provider: 'supabase-vault' },
|
|
29
29
|
networking: { domain: 'api.example.ch', publicBaseUrl: 'https://api.example.ch' },
|
|
@@ -33,7 +33,7 @@ describe('standalone Infra environments', () => {
|
|
|
33
33
|
it('parses local/preview/production without an AppManifest or Deploy', () => {
|
|
34
34
|
const manifest = {
|
|
35
35
|
environments: { local, preview: production, production },
|
|
36
|
-
modules:
|
|
36
|
+
modules: {},
|
|
37
37
|
} satisfies InfraManifest;
|
|
38
38
|
expect(parseInfraManifest(JSON.parse(JSON.stringify(manifest)))).toEqual({
|
|
39
39
|
ok: true,
|
|
@@ -45,14 +45,14 @@ describe('standalone Infra environments', () => {
|
|
|
45
45
|
it.each([
|
|
46
46
|
null,
|
|
47
47
|
{},
|
|
48
|
-
{ modules:
|
|
49
|
-
{ environments: {}, modules:
|
|
50
|
-
{ environments: { production }, modules:
|
|
51
|
-
{ environments: { local, staging: local }, modules:
|
|
52
|
-
{ environments: { local, preview: null }, modules:
|
|
53
|
-
{ environments: { local }, modules:
|
|
54
|
-
{ environments: { local }, modules:
|
|
55
|
-
{ environments: { local }, modules:
|
|
48
|
+
{ modules: {} },
|
|
49
|
+
{ environments: {}, modules: {} },
|
|
50
|
+
{ environments: { production }, modules: {} },
|
|
51
|
+
{ environments: { local, staging: local }, modules: {} },
|
|
52
|
+
{ environments: { local, preview: null }, modules: {} },
|
|
53
|
+
{ environments: { local }, modules: { invalid: 42 } },
|
|
54
|
+
{ environments: { local }, modules: {}, modulesConfig: {} },
|
|
55
|
+
{ environments: { local }, modules: {}, apis: 'invalid' },
|
|
56
56
|
])('rejects invalid environment boundaries: %j', (value) => {
|
|
57
57
|
expect(isInfraManifest(value)).toBe(false);
|
|
58
58
|
expect(parseInfraManifest(value).ok).toBe(false);
|
|
@@ -69,7 +69,7 @@ describe('standalone Infra environments', () => {
|
|
|
69
69
|
'plugins',
|
|
70
70
|
'pluginsConfig',
|
|
71
71
|
])('rejects removed top-level %s', (key) => {
|
|
72
|
-
expect(isInfraManifest({ environments: { local }, modules:
|
|
72
|
+
expect(isInfraManifest({ environments: { local }, modules: {}, [key]: {} })).toBe(false);
|
|
73
73
|
});
|
|
74
74
|
|
|
75
75
|
it.each(['storage', 'state', 'monitoring', 'observability', 'target'])(
|
|
@@ -148,21 +148,17 @@ describe('Cerbos policy configuration', () => {
|
|
|
148
148
|
authz: {
|
|
149
149
|
provider: 'cerbos',
|
|
150
150
|
kind: 'ABAC',
|
|
151
|
-
policies:
|
|
151
|
+
policies: { 'app.yaml': 'apiVersion: api.cerbos.dev/v1' },
|
|
152
152
|
},
|
|
153
153
|
}),
|
|
154
154
|
).toBe(true);
|
|
155
155
|
});
|
|
156
156
|
|
|
157
157
|
it.each([
|
|
158
|
-
{ policies:
|
|
159
|
-
{
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
{ path: 'app.yaml', content: 'y' },
|
|
163
|
-
],
|
|
164
|
-
},
|
|
165
|
-
])('rejects unsafe or duplicate policy files: %j', ({ policies }) => {
|
|
158
|
+
{ policies: { '../app.yaml': 'x' } },
|
|
159
|
+
{ policies: { 'app.yaml': 42 } },
|
|
160
|
+
{ policies: [{ path: 'app.yaml', content: 'legacy-array-shape' }] },
|
|
161
|
+
])('rejects unsafe or non-canonical policy files: %j', ({ policies }) => {
|
|
166
162
|
expect(
|
|
167
163
|
isInfraEnvironmentSpec({
|
|
168
164
|
...local,
|
|
@@ -12,7 +12,7 @@ const production = {
|
|
|
12
12
|
},
|
|
13
13
|
database: { provider: 'supabase' },
|
|
14
14
|
auth: { provider: 'supabase' },
|
|
15
|
-
objectStorage: { provider: 'r2', buckets:
|
|
15
|
+
objectStorage: { provider: 'r2', buckets: { media: true } },
|
|
16
16
|
authz: { provider: 'cerbos', kind: 'ABAC' },
|
|
17
17
|
secretStore: { provider: 'supabase-vault' },
|
|
18
18
|
networking: { domain: 'api.example.ch', publicBaseUrl: 'https://api.example.ch' },
|
package/src/requirements.test.ts
CHANGED
|
@@ -11,10 +11,10 @@ describe('platform requirements', () => {
|
|
|
11
11
|
it('exports ebookReader as the canonical reader capability', () => {
|
|
12
12
|
const capability: AnkhorageCapabilityName = 'ebookReader';
|
|
13
13
|
const componentRequirements: ComponentRequirements = {
|
|
14
|
-
capabilities:
|
|
14
|
+
capabilities: { [capability]: true },
|
|
15
15
|
};
|
|
16
16
|
const screenRequirements: ScreenRequirements = {
|
|
17
|
-
capabilities:
|
|
17
|
+
capabilities: { [capability]: true },
|
|
18
18
|
};
|
|
19
19
|
|
|
20
20
|
expect(ANKHORAGE_CAPABILITY_NAMES).toContain(capability);
|
package/src/requirements.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { SerializableSet } from './collections';
|
|
2
|
+
|
|
1
3
|
export const ANKHORAGE_PERMISSION_NAMES = [
|
|
2
4
|
'camera',
|
|
3
5
|
'microphone',
|
|
@@ -24,20 +26,12 @@ export const ANKHORAGE_CAPABILITY_NAMES = [
|
|
|
24
26
|
|
|
25
27
|
export type AnkhorageCapabilityName = (typeof ANKHORAGE_CAPABILITY_NAMES)[number];
|
|
26
28
|
|
|
27
|
-
export interface ScreenPermissionRequirement {
|
|
28
|
-
readonly permission: AnkhoragePermissionName;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export interface ScreenCapabilityRequirement {
|
|
32
|
-
readonly capability: AnkhorageCapabilityName;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
29
|
export interface ScreenRequirements {
|
|
36
|
-
readonly permissions?:
|
|
37
|
-
readonly capabilities?:
|
|
30
|
+
readonly permissions?: SerializableSet<AnkhoragePermissionName>;
|
|
31
|
+
readonly capabilities?: SerializableSet<AnkhorageCapabilityName>;
|
|
38
32
|
}
|
|
39
33
|
|
|
40
34
|
export interface ComponentRequirements {
|
|
41
|
-
readonly permissions?:
|
|
42
|
-
readonly capabilities?:
|
|
35
|
+
readonly permissions?: SerializableSet<AnkhoragePermissionName>;
|
|
36
|
+
readonly capabilities?: SerializableSet<AnkhorageCapabilityName>;
|
|
43
37
|
}
|
package/src/secrets.test.ts
CHANGED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { isRecord } from '@ankhorage/utility/object';
|
|
2
|
+
|
|
3
|
+
export type SerializableValue =
|
|
4
|
+
| string
|
|
5
|
+
| number
|
|
6
|
+
| boolean
|
|
7
|
+
| null
|
|
8
|
+
| readonly SerializableValue[]
|
|
9
|
+
| {
|
|
10
|
+
readonly [key: string]: SerializableValue;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/*** Validate recursively serializable manifest/config values without accepting functions or host objects. */
|
|
14
|
+
export function isSerializableValue(value: unknown): value is SerializableValue {
|
|
15
|
+
if (
|
|
16
|
+
value === null ||
|
|
17
|
+
typeof value === 'string' ||
|
|
18
|
+
typeof value === 'number' ||
|
|
19
|
+
typeof value === 'boolean'
|
|
20
|
+
) {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
if (Array.isArray(value)) return value.every(isSerializableValue);
|
|
24
|
+
return isRecord(value) && Object.values(value).every(isSerializableValue);
|
|
25
|
+
}
|
package/src/state.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { SerializableValue } from './serializable';
|
|
2
|
+
|
|
1
3
|
/** Legend currently implements in-memory state only; unsupported persistence cannot be selected. */
|
|
2
4
|
export interface AppStateSpec {
|
|
3
5
|
readonly provider: 'legend';
|
|
@@ -6,12 +8,7 @@ export interface AppStateSpec {
|
|
|
6
8
|
|
|
7
9
|
export type StatePrimitive = string | number | boolean | null;
|
|
8
10
|
|
|
9
|
-
export type StateValue =
|
|
10
|
-
| StatePrimitive
|
|
11
|
-
| readonly StateValue[]
|
|
12
|
-
| {
|
|
13
|
-
readonly [key: string]: StateValue;
|
|
14
|
-
};
|
|
11
|
+
export type StateValue = SerializableValue;
|
|
15
12
|
|
|
16
13
|
export type StatePath = string | readonly string[];
|
|
17
14
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { AuthFlowConfig, AuthOAuthConfig } from '../auth';
|
|
2
|
-
import type {
|
|
2
|
+
import type { SerializableSet } from '../collections';
|
|
3
|
+
import type { ApiDefinitionRegistry, DataContractValue } from '../data';
|
|
3
4
|
import type { AppEnvironmentId } from '../environments';
|
|
4
5
|
import type { INFRA_ADAPTER_CATALOG, INFRA_RUNTIME_COMPATIBILITY } from '../infra/constants';
|
|
5
6
|
import type { AuthProfileSpec, AuthScope, AuthSignInSpec, AuthSignUpSpec } from '../types';
|
|
6
7
|
import type { InfraControlPlaneCredentialRef } from './infraSecrets';
|
|
7
|
-
import type {
|
|
8
|
+
import type { InfraWorkloadRegistry } from './infraWorkload';
|
|
8
9
|
|
|
9
10
|
export type InfraAdapterId = keyof typeof INFRA_ADAPTER_CATALOG;
|
|
10
11
|
export type InfraCapability =
|
|
@@ -86,11 +87,11 @@ export interface InfraDatabaseConfigMap {
|
|
|
86
87
|
}
|
|
87
88
|
export interface InfraObjectStorageConfigMap {
|
|
88
89
|
readonly supabase: {
|
|
89
|
-
readonly buckets?:
|
|
90
|
+
readonly buckets?: SerializableSet;
|
|
90
91
|
readonly backend?: InfraS3PersistenceTarget;
|
|
91
92
|
};
|
|
92
93
|
readonly r2: {
|
|
93
|
-
readonly buckets?:
|
|
94
|
+
readonly buckets?: SerializableSet;
|
|
94
95
|
readonly accountId?: string;
|
|
95
96
|
readonly credentials?: InfraControlPlaneCredentialRef;
|
|
96
97
|
};
|
|
@@ -108,13 +109,9 @@ export interface InfraAuthConfigMap {
|
|
|
108
109
|
export interface InfraAuthzConfigMap {
|
|
109
110
|
readonly cerbos: {
|
|
110
111
|
readonly kind: 'RBAC' | 'ABAC';
|
|
111
|
-
readonly policies?:
|
|
112
|
+
readonly policies?: Readonly<Record<string, string>>;
|
|
112
113
|
};
|
|
113
114
|
}
|
|
114
|
-
export interface InfraAuthzPolicyFile {
|
|
115
|
-
readonly path: string;
|
|
116
|
-
readonly content: string;
|
|
117
|
-
}
|
|
118
115
|
export interface InfraSecretStoreConfigMap {
|
|
119
116
|
readonly 'supabase-vault': { readonly schema?: string };
|
|
120
117
|
}
|
|
@@ -161,15 +158,22 @@ export interface InfraEnvironmentSpec {
|
|
|
161
158
|
readonly authz?: InfraAuthzSpec;
|
|
162
159
|
readonly secretStore?: InfraSecretStoreSpec;
|
|
163
160
|
readonly networking?: InfraNetworkingSpec;
|
|
164
|
-
readonly workloads?:
|
|
161
|
+
readonly workloads?: InfraWorkloadRegistry;
|
|
165
162
|
}
|
|
166
163
|
|
|
164
|
+
export type InfraModuleId = string;
|
|
165
|
+
|
|
166
|
+
export interface InfraModuleSpec {
|
|
167
|
+
readonly config?: DataContractValue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export type InfraModuleRegistry = Readonly<Record<InfraModuleId, InfraModuleSpec>>;
|
|
171
|
+
|
|
167
172
|
export interface InfraManifest {
|
|
168
173
|
readonly environments: Readonly<
|
|
169
174
|
Record<'local', InfraEnvironmentSpec> &
|
|
170
175
|
Partial<Record<Exclude<AppEnvironmentId, 'local'>, InfraEnvironmentSpec>>
|
|
171
176
|
>;
|
|
172
|
-
readonly apis?:
|
|
173
|
-
readonly modules:
|
|
174
|
-
readonly modulesConfig?: Readonly<Record<string, unknown>>;
|
|
177
|
+
readonly apis?: ApiDefinitionRegistry;
|
|
178
|
+
readonly modules: InfraModuleRegistry;
|
|
175
179
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SerializableSet } from '../collections';
|
|
1
2
|
import type { InfraControlPlaneCredentialRef, InfraSecretReference } from './infraSecrets';
|
|
2
3
|
|
|
3
4
|
export interface InfraWorkloadArtifact {
|
|
@@ -25,7 +26,6 @@ export type InfraWorkloadValue =
|
|
|
25
26
|
};
|
|
26
27
|
|
|
27
28
|
export interface InfraWorkloadPort {
|
|
28
|
-
readonly name: string;
|
|
29
29
|
readonly port: number;
|
|
30
30
|
readonly protocol?: 'tcp' | 'udp';
|
|
31
31
|
/** Exact external listener port requested for a public single-replica workload. */
|
|
@@ -57,25 +57,26 @@ export interface InfraWorkloadVolumeSpec {
|
|
|
57
57
|
readonly retention: 'retain' | 'delete-on-destroy';
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
export
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
60
|
+
export type InfraWorkloadId = string;
|
|
61
|
+
export type InfraWorkloadPortRegistry = Readonly<Record<string, InfraWorkloadPort>>;
|
|
62
|
+
export type InfraWorkloadFileMap = Readonly<Record<string, InfraWorkloadValue>>;
|
|
63
|
+
export type InfraWorkloadVolumeRegistry = Readonly<Record<string, InfraWorkloadVolumeSpec>>;
|
|
65
64
|
|
|
66
65
|
export interface InfraWorkloadSpec {
|
|
67
|
-
readonly id:
|
|
66
|
+
readonly id: InfraWorkloadId;
|
|
68
67
|
readonly artifact: InfraWorkloadArtifact;
|
|
69
68
|
readonly command?: readonly string[];
|
|
70
69
|
readonly args?: readonly string[];
|
|
71
|
-
readonly ports?:
|
|
70
|
+
readonly ports?: InfraWorkloadPortRegistry;
|
|
72
71
|
readonly environment?: Readonly<Record<string, InfraWorkloadValue>>;
|
|
73
|
-
readonly files?:
|
|
72
|
+
readonly files?: InfraWorkloadFileMap;
|
|
74
73
|
readonly health?: InfraWorkloadHealthSpec;
|
|
75
74
|
readonly resources?: InfraWorkloadResourceSpec;
|
|
76
|
-
readonly persistence?:
|
|
75
|
+
readonly persistence?: InfraWorkloadVolumeRegistry;
|
|
77
76
|
readonly exposure?: 'internal' | 'public';
|
|
78
77
|
readonly replicas?: number;
|
|
79
78
|
/** IDs in the composed desired-state graph; the orchestrator validates missing/cyclic edges. */
|
|
80
|
-
readonly dependsOn?:
|
|
79
|
+
readonly dependsOn?: SerializableSet;
|
|
81
80
|
}
|
|
81
|
+
|
|
82
|
+
export type InfraWorkloadRegistry = Readonly<Record<InfraWorkloadId, InfraWorkloadSpec>>;
|