@figulus/schema 0.4.0-alpha-dev
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/dist/figspec.d.ts +4394 -0
- package/dist/figspec.d.ts.map +1 -0
- package/dist/figspec.js +214 -0
- package/dist/figspec.js.map +1 -0
- package/dist/figstack.d.ts +419 -0
- package/dist/figstack.d.ts.map +1 -0
- package/dist/figstack.js +72 -0
- package/dist/figstack.js.map +1 -0
- package/dist/health.d.ts +16 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +7 -0
- package/dist/health.js.map +1 -0
- package/dist/image.d.ts +115 -0
- package/dist/image.d.ts.map +1 -0
- package/dist/image.js +42 -0
- package/dist/image.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/run-request.d.ts +244 -0
- package/dist/run-request.d.ts.map +1 -0
- package/dist/run-request.js +43 -0
- package/dist/run-request.js.map +1 -0
- package/dist/session.d.ts +447 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +78 -0
- package/dist/session.js.map +1 -0
- package/dist/shared.d.ts +90 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +41 -0
- package/dist/shared.js.map +1 -0
- package/dist/volume.d.ts +140 -0
- package/dist/volume.d.ts.map +1 -0
- package/dist/volume.js +37 -0
- package/dist/volume.js.map +1 -0
- package/package.json +22 -0
- package/src/figspec.ts +279 -0
- package/src/figstack.ts +92 -0
- package/src/health.ts +8 -0
- package/src/image.ts +55 -0
- package/src/index.ts +8 -0
- package/src/run-request.ts +55 -0
- package/src/session.ts +101 -0
- package/src/shared.ts +56 -0
- package/src/volume.ts +50 -0
- package/tests/figspec.test.ts +383 -0
- package/tests/schemas.test.ts +187 -0
- package/tsconfig.build.json +11 -0
- package/tsconfig.json +12 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import {
|
|
3
|
+
JobStatusSchema,
|
|
4
|
+
ParsedOutputSchema,
|
|
5
|
+
ResourceLimitsSchema,
|
|
6
|
+
VolumeMountSchema,
|
|
7
|
+
NamedVolumeMountSchema,
|
|
8
|
+
PortMappingSchema,
|
|
9
|
+
} from './shared.js';
|
|
10
|
+
|
|
11
|
+
export const RunRequestSchema = z.object({
|
|
12
|
+
job_id: z.string().min(1),
|
|
13
|
+
image: z.string().min(1),
|
|
14
|
+
command: z.array(z.string()).optional(),
|
|
15
|
+
environment: z.record(z.string()).optional(),
|
|
16
|
+
volumes: z.array(VolumeMountSchema).optional(),
|
|
17
|
+
named_volumes: z.array(NamedVolumeMountSchema).optional(),
|
|
18
|
+
resources: ResourceLimitsSchema.optional(),
|
|
19
|
+
timeout: z.number().int().positive().max(86400).optional(),
|
|
20
|
+
parser_script: z.string().optional(),
|
|
21
|
+
root_dir: z.string().optional(),
|
|
22
|
+
env_files: z.array(z.string()).optional(),
|
|
23
|
+
secrets: z.record(z.string()).optional(),
|
|
24
|
+
extra_hosts: z.array(z.string()).optional(),
|
|
25
|
+
network_mode: z.string().optional(),
|
|
26
|
+
working_dir: z.string().optional(),
|
|
27
|
+
ports: z.array(PortMappingSchema).optional(),
|
|
28
|
+
});
|
|
29
|
+
export type RunRequest = z.infer<typeof RunRequestSchema>;
|
|
30
|
+
|
|
31
|
+
export const RunResponseSchema = z.object({
|
|
32
|
+
job_id: z.string(),
|
|
33
|
+
status: JobStatusSchema,
|
|
34
|
+
message: z.string().optional(),
|
|
35
|
+
container_id: z.string().optional(),
|
|
36
|
+
exit_code: z.number().int().nullable().optional(),
|
|
37
|
+
stdout: z.string().optional(),
|
|
38
|
+
stderr: z.string().optional(),
|
|
39
|
+
parsed_output: ParsedOutputSchema.optional(),
|
|
40
|
+
started_at: z.string().datetime().optional(),
|
|
41
|
+
completed_at: z.string().datetime().optional(),
|
|
42
|
+
duration: z.number().optional(),
|
|
43
|
+
command: z.array(z.string()).optional(),
|
|
44
|
+
final_command: z.array(z.string()).optional(),
|
|
45
|
+
});
|
|
46
|
+
export type RunResponse = z.infer<typeof RunResponseSchema>;
|
|
47
|
+
|
|
48
|
+
export const DryRunResponseSchema = z.object({
|
|
49
|
+
command: z.array(z.string()),
|
|
50
|
+
final_command: z.array(z.string()),
|
|
51
|
+
image: z.string(),
|
|
52
|
+
working_dir: z.string(),
|
|
53
|
+
volumes: z.array(VolumeMountSchema).optional(),
|
|
54
|
+
});
|
|
55
|
+
export type DryRunResponse = z.infer<typeof DryRunResponseSchema>;
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import {
|
|
3
|
+
SessionStatusSchema,
|
|
4
|
+
ParsedOutputSchema,
|
|
5
|
+
VolumeMountSchema,
|
|
6
|
+
NamedVolumeMountSchema,
|
|
7
|
+
PortMappingSchema,
|
|
8
|
+
} from './shared.js';
|
|
9
|
+
|
|
10
|
+
export const SessionConfigSchema = z.object({
|
|
11
|
+
working_dir: z.string().optional(),
|
|
12
|
+
environment: z.record(z.string()).optional(),
|
|
13
|
+
parser_script: z.string().optional(),
|
|
14
|
+
volumes: z.array(VolumeMountSchema).optional(),
|
|
15
|
+
named_volumes: z.array(NamedVolumeMountSchema).optional(),
|
|
16
|
+
init_cmd: z.array(z.string()).optional(),
|
|
17
|
+
destroy_cmd: z.array(z.string()).optional(),
|
|
18
|
+
ports: z.array(PortMappingSchema).optional(),
|
|
19
|
+
start_cmd: z.array(z.string()).optional(),
|
|
20
|
+
auto_cleanup: z.boolean().optional(),
|
|
21
|
+
});
|
|
22
|
+
export type SessionConfig = z.infer<typeof SessionConfigSchema>;
|
|
23
|
+
|
|
24
|
+
export const CreateSessionRequestSchema = z.object({
|
|
25
|
+
image_tag: z.string().min(1),
|
|
26
|
+
ttl: z.number().int().positive().optional(),
|
|
27
|
+
config: SessionConfigSchema.optional(),
|
|
28
|
+
block_until_complete: z.boolean().optional(),
|
|
29
|
+
});
|
|
30
|
+
export type CreateSessionRequest = z.infer<typeof CreateSessionRequestSchema>;
|
|
31
|
+
|
|
32
|
+
export const CreateSessionResponseSchema = z.object({
|
|
33
|
+
session_id: z.string(),
|
|
34
|
+
container_id: z.string(),
|
|
35
|
+
status: SessionStatusSchema,
|
|
36
|
+
created_at: z.string().datetime(),
|
|
37
|
+
ports: z.array(PortMappingSchema).optional(),
|
|
38
|
+
exit_code: z.number().int().optional(),
|
|
39
|
+
stdout: z.string().optional(),
|
|
40
|
+
stderr: z.string().optional(),
|
|
41
|
+
});
|
|
42
|
+
export type CreateSessionResponse = z.infer<typeof CreateSessionResponseSchema>;
|
|
43
|
+
|
|
44
|
+
export const GetSessionResponseSchema = z.object({
|
|
45
|
+
session_id: z.string(),
|
|
46
|
+
container_id: z.string(),
|
|
47
|
+
image_tag: z.string(),
|
|
48
|
+
status: SessionStatusSchema,
|
|
49
|
+
created_at: z.string().datetime(),
|
|
50
|
+
last_used_at: z.string().datetime(),
|
|
51
|
+
ttl: z.number().int(),
|
|
52
|
+
ports: z.array(PortMappingSchema).optional(),
|
|
53
|
+
});
|
|
54
|
+
export type GetSessionResponse = z.infer<typeof GetSessionResponseSchema>;
|
|
55
|
+
|
|
56
|
+
export const RunInSessionRequestSchema = z.object({
|
|
57
|
+
command: z.array(z.string()).min(1),
|
|
58
|
+
timeout: z.number().int().positive().max(86400).optional(),
|
|
59
|
+
parser_script: z.string().optional(),
|
|
60
|
+
working_dir: z.string().optional(),
|
|
61
|
+
});
|
|
62
|
+
export type RunInSessionRequest = z.infer<typeof RunInSessionRequestSchema>;
|
|
63
|
+
|
|
64
|
+
export const RunInSessionResponseSchema = z.object({
|
|
65
|
+
run_id: z.string(),
|
|
66
|
+
exit_code: z.number().int(),
|
|
67
|
+
stdout: z.string(),
|
|
68
|
+
stderr: z.string(),
|
|
69
|
+
parsed_output: z.record(z.unknown()).optional(),
|
|
70
|
+
duration: z.number(),
|
|
71
|
+
});
|
|
72
|
+
export type RunInSessionResponse = z.infer<typeof RunInSessionResponseSchema>;
|
|
73
|
+
|
|
74
|
+
export const GetRunResponseSchema = z.object({
|
|
75
|
+
run_id: z.string(),
|
|
76
|
+
session_id: z.string(),
|
|
77
|
+
command: z.array(z.string()),
|
|
78
|
+
exit_code: z.number().int(),
|
|
79
|
+
stdout: z.string(),
|
|
80
|
+
stderr: z.string(),
|
|
81
|
+
parsed_output: z.record(z.unknown()).optional(),
|
|
82
|
+
started_at: z.string().datetime(),
|
|
83
|
+
completed_at: z.string().datetime(),
|
|
84
|
+
duration: z.number(),
|
|
85
|
+
});
|
|
86
|
+
export type GetRunResponse = z.infer<typeof GetRunResponseSchema>;
|
|
87
|
+
|
|
88
|
+
export const SyncFileRequestSchema = z.object({
|
|
89
|
+
targetPath: z.string().min(1),
|
|
90
|
+
content: z.string().min(1),
|
|
91
|
+
encoding: z.enum(['base64', 'utf8']).default('base64'),
|
|
92
|
+
});
|
|
93
|
+
export type SyncFileRequest = z.infer<typeof SyncFileRequestSchema>;
|
|
94
|
+
|
|
95
|
+
export const SyncFileResponseSchema = z.object({
|
|
96
|
+
success: z.boolean(),
|
|
97
|
+
path: z.string(),
|
|
98
|
+
bytes_written: z.number().int(),
|
|
99
|
+
message: z.string().optional(),
|
|
100
|
+
});
|
|
101
|
+
export type SyncFileResponse = z.infer<typeof SyncFileResponseSchema>;
|
package/src/shared.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const JobStatusSchema = z.enum([
|
|
4
|
+
'pending',
|
|
5
|
+
'running',
|
|
6
|
+
'completed',
|
|
7
|
+
'failed',
|
|
8
|
+
'timeout',
|
|
9
|
+
'awaiting_cleanup',
|
|
10
|
+
]);
|
|
11
|
+
export type JobStatus = z.infer<typeof JobStatusSchema>;
|
|
12
|
+
|
|
13
|
+
export const SessionStatusSchema = z.enum(['active', 'idle', 'expired']);
|
|
14
|
+
export type SessionStatus = z.infer<typeof SessionStatusSchema>;
|
|
15
|
+
|
|
16
|
+
export const ParsedOutputSchema = z.object({
|
|
17
|
+
success: z.boolean(),
|
|
18
|
+
data: z.record(z.unknown()).optional(),
|
|
19
|
+
});
|
|
20
|
+
export type ParsedOutput = z.infer<typeof ParsedOutputSchema>;
|
|
21
|
+
|
|
22
|
+
export const ResourceLimitsSchema = z.object({
|
|
23
|
+
memory_limit: z.string().optional(),
|
|
24
|
+
memory_reservation: z.string().optional(),
|
|
25
|
+
cpu_limit: z.string().optional(),
|
|
26
|
+
cpu_reservation: z.string().optional(),
|
|
27
|
+
});
|
|
28
|
+
export type ResourceLimits = z.infer<typeof ResourceLimitsSchema>;
|
|
29
|
+
|
|
30
|
+
export const VolumeMountSchema = z.object({
|
|
31
|
+
type: z.string().optional(),
|
|
32
|
+
host_path: z.string(),
|
|
33
|
+
container_path: z.string().optional(),
|
|
34
|
+
read_only: z.boolean(),
|
|
35
|
+
});
|
|
36
|
+
export type VolumeMount = z.infer<typeof VolumeMountSchema>;
|
|
37
|
+
|
|
38
|
+
export const NamedVolumeMountSchema = z.object({
|
|
39
|
+
volume_name: z.string().min(1),
|
|
40
|
+
container_path: z.string().min(1),
|
|
41
|
+
read_only: z.boolean(),
|
|
42
|
+
});
|
|
43
|
+
export type NamedVolumeMount = z.infer<typeof NamedVolumeMountSchema>;
|
|
44
|
+
|
|
45
|
+
export const PortMappingSchema = z.object({
|
|
46
|
+
container: z.number().int().min(1).max(65535),
|
|
47
|
+
host: z.number().int().min(0).max(65535).optional(),
|
|
48
|
+
});
|
|
49
|
+
export type PortMapping = z.infer<typeof PortMappingSchema>;
|
|
50
|
+
|
|
51
|
+
export const ErrorResponseSchema = z.object({
|
|
52
|
+
error: z.string(),
|
|
53
|
+
message: z.string(),
|
|
54
|
+
code: z.number().int(),
|
|
55
|
+
});
|
|
56
|
+
export type ErrorResponse = z.infer<typeof ErrorResponseSchema>;
|
package/src/volume.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const EnsureVolumeRequestSchema = z.object({
|
|
4
|
+
volume_name: z.string().min(1),
|
|
5
|
+
driver: z.string().optional(),
|
|
6
|
+
labels: z.record(z.string()).optional(),
|
|
7
|
+
});
|
|
8
|
+
export type EnsureVolumeRequest = z.infer<typeof EnsureVolumeRequestSchema>;
|
|
9
|
+
|
|
10
|
+
export const EnsureVolumeResponseSchema = z.object({
|
|
11
|
+
volume_name: z.string(),
|
|
12
|
+
created: z.boolean(),
|
|
13
|
+
message: z.string().optional(),
|
|
14
|
+
});
|
|
15
|
+
export type EnsureVolumeResponse = z.infer<typeof EnsureVolumeResponseSchema>;
|
|
16
|
+
|
|
17
|
+
export const VolumeInfoSchema = z.object({
|
|
18
|
+
name: z.string(),
|
|
19
|
+
driver: z.string(),
|
|
20
|
+
mountpoint: z.string(),
|
|
21
|
+
created_at: z.string(),
|
|
22
|
+
labels: z.record(z.string()).optional(),
|
|
23
|
+
scope: z.string(),
|
|
24
|
+
size_bytes: z.number().int().optional(),
|
|
25
|
+
});
|
|
26
|
+
export type VolumeInfo = z.infer<typeof VolumeInfoSchema>;
|
|
27
|
+
|
|
28
|
+
export const ListVolumesResponseSchema = z.object({
|
|
29
|
+
volumes: z.array(VolumeInfoSchema),
|
|
30
|
+
count: z.number().int(),
|
|
31
|
+
});
|
|
32
|
+
export type ListVolumesResponse = z.infer<typeof ListVolumesResponseSchema>;
|
|
33
|
+
|
|
34
|
+
export const DeleteVolumeResponseSchema = z.object({
|
|
35
|
+
volume_name: z.string(),
|
|
36
|
+
message: z.string(),
|
|
37
|
+
});
|
|
38
|
+
export type DeleteVolumeResponse = z.infer<typeof DeleteVolumeResponseSchema>;
|
|
39
|
+
|
|
40
|
+
export const PruneVolumesRequestSchema = z.object({
|
|
41
|
+
filters: z.record(z.array(z.string())).optional(),
|
|
42
|
+
});
|
|
43
|
+
export type PruneVolumesRequest = z.infer<typeof PruneVolumesRequestSchema>;
|
|
44
|
+
|
|
45
|
+
export const PruneVolumesResponseSchema = z.object({
|
|
46
|
+
volumes_deleted: z.array(z.string()),
|
|
47
|
+
space_reclaimed: z.number().int(),
|
|
48
|
+
message: z.string(),
|
|
49
|
+
});
|
|
50
|
+
export type PruneVolumesResponse = z.infer<typeof PruneVolumesResponseSchema>;
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
figSpecSchema,
|
|
4
|
+
figSpecBuildStandaloneSchema,
|
|
5
|
+
figSpecBuildWithBaseSpecSchema,
|
|
6
|
+
figSpecRunSchema,
|
|
7
|
+
runVolumeMountSchema,
|
|
8
|
+
buildVolumeMountSchema,
|
|
9
|
+
installGroupSchema,
|
|
10
|
+
onExitBehaviorSchema,
|
|
11
|
+
executeMainCmdSchema,
|
|
12
|
+
} from '../src/figspec.js';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Helpers
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
const minimalMainCmd = { cmd: 'node server.js' };
|
|
19
|
+
const minimalExecute = { main: minimalMainCmd };
|
|
20
|
+
const minimalRun = { execute: minimalExecute };
|
|
21
|
+
const minimalBuildStandalone = { baseImage: 'ubuntu:24.04' };
|
|
22
|
+
const minimalSpec = { schemaVersion: '1', build: minimalBuildStandalone };
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Top-level FigSpec
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
describe('figSpecSchema', () => {
|
|
29
|
+
it('accepts a minimal spec (baseImage + execute.main.cmd only)', () => {
|
|
30
|
+
expect(() => figSpecSchema.parse(minimalSpec)).not.toThrow();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('accepts a spec with run section', () => {
|
|
34
|
+
expect(() =>
|
|
35
|
+
figSpecSchema.parse({ ...minimalSpec, run: minimalRun }),
|
|
36
|
+
).not.toThrow();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('accepts build as a string specName reference', () => {
|
|
40
|
+
expect(() =>
|
|
41
|
+
figSpecSchema.parse({ schemaVersion: '1', build: 'my-base-spec' }),
|
|
42
|
+
).not.toThrow();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('accepts build as an array of specName strings', () => {
|
|
46
|
+
expect(() =>
|
|
47
|
+
figSpecSchema.parse({ schemaVersion: '1', build: ['stage-a', 'stage-b'] }),
|
|
48
|
+
).not.toThrow();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('rejects missing schemaVersion', () => {
|
|
52
|
+
expect(() => figSpecSchema.parse({ build: minimalBuildStandalone })).toThrow();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('rejects missing build', () => {
|
|
56
|
+
expect(() => figSpecSchema.parse({ schemaVersion: '1' })).toThrow();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Build spec variants
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
describe('figSpecBuildStandaloneSchema', () => {
|
|
65
|
+
it('accepts minimal standalone spec', () => {
|
|
66
|
+
expect(() => figSpecBuildStandaloneSchema.parse(minimalBuildStandalone)).not.toThrow();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('accepts full standalone spec with packages and env', () => {
|
|
70
|
+
const full = {
|
|
71
|
+
baseImage: 'node:20-alpine',
|
|
72
|
+
schemaVersion: '1',
|
|
73
|
+
specName: 'my-app',
|
|
74
|
+
packagesToInstall: [
|
|
75
|
+
{ packageManager: 'npm', packages: ['lodash', { name: 'express', version: '4.18.0' }] },
|
|
76
|
+
],
|
|
77
|
+
env: { NODE_ENV: 'production' },
|
|
78
|
+
workDir: '/app',
|
|
79
|
+
user: 'node',
|
|
80
|
+
platform: 'linux/amd64',
|
|
81
|
+
};
|
|
82
|
+
expect(() => figSpecBuildStandaloneSchema.parse(full)).not.toThrow();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('rejects missing baseImage', () => {
|
|
86
|
+
expect(() => figSpecBuildStandaloneSchema.parse({ workDir: '/app' })).toThrow();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('figSpecBuildWithBaseSpecSchema', () => {
|
|
91
|
+
it('accepts spec with baseSpec and no baseImage', () => {
|
|
92
|
+
expect(() =>
|
|
93
|
+
figSpecBuildWithBaseSpecSchema.parse({ baseSpec: 'node-base' }),
|
|
94
|
+
).not.toThrow();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('accepts spec with both baseSpec and baseImage override', () => {
|
|
98
|
+
expect(() =>
|
|
99
|
+
figSpecBuildWithBaseSpecSchema.parse({ baseSpec: 'node-base', baseImage: 'custom:latest' }),
|
|
100
|
+
).not.toThrow();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('rejects missing baseSpec', () => {
|
|
104
|
+
expect(() =>
|
|
105
|
+
figSpecBuildWithBaseSpecSchema.parse({ baseImage: 'ubuntu:24.04' }),
|
|
106
|
+
).toThrow();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Run spec
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
describe('figSpecRunSchema', () => {
|
|
115
|
+
it('accepts minimal run spec', () => {
|
|
116
|
+
expect(() => figSpecRunSchema.parse(minimalRun)).not.toThrow();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('accepts full run spec with all fields', () => {
|
|
120
|
+
const full = {
|
|
121
|
+
execute: {
|
|
122
|
+
startup: [{ cmd: 'npm install', async: false }],
|
|
123
|
+
main: { cmd: 'node server.js', parser: 'console.log(JSON.stringify({success:true}))' },
|
|
124
|
+
shutdown: [{ cmd: 'node cleanup.js' }],
|
|
125
|
+
scheduled: [{ cmd: 'node cron.js', runAt: '0 9 * * *' }],
|
|
126
|
+
},
|
|
127
|
+
env: { PORT: '3000' },
|
|
128
|
+
secrets: { DB_PASS: 'vault:secrets/db#password' },
|
|
129
|
+
workDir: '/app',
|
|
130
|
+
user: 'node',
|
|
131
|
+
ports: [{ container: 3000, host: 8080 }],
|
|
132
|
+
};
|
|
133
|
+
expect(() => figSpecRunSchema.parse(full)).not.toThrow();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('rejects missing execute', () => {
|
|
137
|
+
expect(() => figSpecRunSchema.parse({ env: { X: '1' } })).toThrow();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('rejects execute without main', () => {
|
|
141
|
+
expect(() =>
|
|
142
|
+
figSpecRunSchema.parse({ execute: { startup: [] } }),
|
|
143
|
+
).toThrow();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Volume discriminated unions
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
describe('runVolumeMountSchema — type discriminator', () => {
|
|
152
|
+
it('accepts regular bind mount', () => {
|
|
153
|
+
expect(() =>
|
|
154
|
+
runVolumeMountSchema.parse({ type: 'regular', host_path: '/tmp/src', container_path: '/app' }),
|
|
155
|
+
).not.toThrow();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('accepts docker-socket mount', () => {
|
|
159
|
+
expect(() =>
|
|
160
|
+
runVolumeMountSchema.parse({ type: 'docker-socket' }),
|
|
161
|
+
).not.toThrow();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('accepts named volume mount', () => {
|
|
165
|
+
expect(() =>
|
|
166
|
+
runVolumeMountSchema.parse({ type: 'named', name: 'my-vol', container_path: '/data' }),
|
|
167
|
+
).not.toThrow();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('accepts anonymous volume mount', () => {
|
|
171
|
+
expect(() =>
|
|
172
|
+
runVolumeMountSchema.parse({ type: 'anonymous', container_path: '/tmp/cache' }),
|
|
173
|
+
).not.toThrow();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('accepts tmpfs mount', () => {
|
|
177
|
+
expect(() =>
|
|
178
|
+
runVolumeMountSchema.parse({ type: 'tmpfs', container_path: '/run', size_mb: 128 }),
|
|
179
|
+
).not.toThrow();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('accepts copy-in mount', () => {
|
|
183
|
+
expect(() =>
|
|
184
|
+
runVolumeMountSchema.parse({ type: 'copy-in', host_path: '/host/src', container_path: '/app/src' }),
|
|
185
|
+
).not.toThrow();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('accepts copy-out mount', () => {
|
|
189
|
+
expect(() =>
|
|
190
|
+
runVolumeMountSchema.parse({ type: 'copy-out', container_path: '/app/dist', host_path: '/host/dist', changed_only: true }),
|
|
191
|
+
).not.toThrow();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('accepts new-file injection', () => {
|
|
195
|
+
expect(() =>
|
|
196
|
+
runVolumeMountSchema.parse({
|
|
197
|
+
type: 'new-file',
|
|
198
|
+
container_path: '/app/.env',
|
|
199
|
+
content: { data: 'KEY=value' },
|
|
200
|
+
}),
|
|
201
|
+
).not.toThrow();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('rejects invalid type discriminator', () => {
|
|
205
|
+
expect(() =>
|
|
206
|
+
runVolumeMountSchema.parse({ type: 'invalid-type', container_path: '/x' }),
|
|
207
|
+
).toThrow();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('rejects named mount missing required name field', () => {
|
|
211
|
+
expect(() =>
|
|
212
|
+
runVolumeMountSchema.parse({ type: 'named', container_path: '/data' }),
|
|
213
|
+
).toThrow();
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('buildVolumeMountSchema — type discriminator', () => {
|
|
218
|
+
it('accepts regular build mount', () => {
|
|
219
|
+
expect(() =>
|
|
220
|
+
buildVolumeMountSchema.parse({ type: 'regular', host_path: '/src', container_path: '/app' }),
|
|
221
|
+
).not.toThrow();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('accepts new-file build mount', () => {
|
|
225
|
+
expect(() =>
|
|
226
|
+
buildVolumeMountSchema.parse({
|
|
227
|
+
type: 'new-file',
|
|
228
|
+
container_path: '/etc/app.conf',
|
|
229
|
+
content: { data: 'debug=true', encoding: 'utf-8' },
|
|
230
|
+
}),
|
|
231
|
+
).not.toThrow();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('rejects invalid type discriminator', () => {
|
|
235
|
+
expect(() =>
|
|
236
|
+
buildVolumeMountSchema.parse({ type: 'docker-socket' }),
|
|
237
|
+
).toThrow();
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// onExit polymorphism
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
describe('onExitBehaviorSchema', () => {
|
|
246
|
+
it('accepts null', () => {
|
|
247
|
+
expect(() => onExitBehaviorSchema.parse(null)).not.toThrow();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('accepts "persist"', () => {
|
|
251
|
+
expect(() => onExitBehaviorSchema.parse('persist')).not.toThrow();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('accepts "shutdown"', () => {
|
|
255
|
+
expect(() => onExitBehaviorSchema.parse('shutdown')).not.toThrow();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('accepts arbitrary command string', () => {
|
|
259
|
+
expect(() => onExitBehaviorSchema.parse('node cleanup.js')).not.toThrow();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('accepts exit-code dispatch table', () => {
|
|
263
|
+
expect(() =>
|
|
264
|
+
onExitBehaviorSchema.parse([
|
|
265
|
+
{ exitCode: 0, cmd: 'shutdown' },
|
|
266
|
+
{ exitCode: 1, cmd: 'node retry.js' },
|
|
267
|
+
]),
|
|
268
|
+
).not.toThrow();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('rejects invalid value (number)', () => {
|
|
272
|
+
expect(() => onExitBehaviorSchema.parse(42)).toThrow();
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe('executeMainCmdSchema — onExit field', () => {
|
|
277
|
+
it('accepts cmd only', () => {
|
|
278
|
+
expect(() => executeMainCmdSchema.parse({ cmd: 'node app.js' })).not.toThrow();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('accepts null onExit', () => {
|
|
282
|
+
expect(() => executeMainCmdSchema.parse({ cmd: 'node app.js', onExit: null })).not.toThrow();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('rejects invalid onExit value', () => {
|
|
286
|
+
expect(() => executeMainCmdSchema.parse({ cmd: 'node app.js', onExit: 99 })).toThrow();
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
// InstallGroup
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
describe('installGroupSchema', () => {
|
|
295
|
+
it('accepts packages as strings', () => {
|
|
296
|
+
expect(() =>
|
|
297
|
+
installGroupSchema.parse({ packageManager: 'apt', packages: ['curl', 'git'] }),
|
|
298
|
+
).not.toThrow();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('accepts packages as PackageSpec objects', () => {
|
|
302
|
+
expect(() =>
|
|
303
|
+
installGroupSchema.parse({
|
|
304
|
+
packageManager: 'npm',
|
|
305
|
+
packages: [{ name: 'express', version: '4.18.0', args: ['--save'] }],
|
|
306
|
+
}),
|
|
307
|
+
).not.toThrow();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('accepts mixed string and PackageSpec packages', () => {
|
|
311
|
+
expect(() =>
|
|
312
|
+
installGroupSchema.parse({
|
|
313
|
+
packageManager: 'pip',
|
|
314
|
+
packages: ['requests', { name: 'boto3', version: '1.34.0' }],
|
|
315
|
+
globalArgs: ['--quiet'],
|
|
316
|
+
}),
|
|
317
|
+
).not.toThrow();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('rejects empty packageManager', () => {
|
|
321
|
+
expect(() =>
|
|
322
|
+
installGroupSchema.parse({ packageManager: '', packages: ['curl'] }),
|
|
323
|
+
).toThrow();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('rejects missing packages array', () => {
|
|
327
|
+
expect(() =>
|
|
328
|
+
installGroupSchema.parse({ packageManager: 'apt' }),
|
|
329
|
+
).toThrow();
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
// Full realistic spec
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
describe('full realistic figspec', () => {
|
|
338
|
+
it('passes a spec with startup, named volume, copy-out, and parser', () => {
|
|
339
|
+
const spec = {
|
|
340
|
+
schemaVersion: '1',
|
|
341
|
+
specName: 'my-service',
|
|
342
|
+
build: {
|
|
343
|
+
baseImage: 'node:20-alpine',
|
|
344
|
+
packagesToInstall: [
|
|
345
|
+
{ packageManager: 'npm', packages: ['express', { name: 'pg', version: '8.11.0' }] },
|
|
346
|
+
],
|
|
347
|
+
workDir: '/app',
|
|
348
|
+
volumes: [
|
|
349
|
+
{ type: 'regular', host_path: '/src', container_path: '/app', read_only: true },
|
|
350
|
+
],
|
|
351
|
+
},
|
|
352
|
+
run: {
|
|
353
|
+
execute: {
|
|
354
|
+
startup: [{ cmd: 'npm ci', async: false }],
|
|
355
|
+
main: {
|
|
356
|
+
cmd: 'node dist/server.js',
|
|
357
|
+
onExit: [
|
|
358
|
+
{ exitCode: 0, cmd: 'shutdown' },
|
|
359
|
+
{ exitCode: 1, cmd: 'node scripts/retry.js' },
|
|
360
|
+
],
|
|
361
|
+
parser: 'return { pid: line.match(/pid (\\d+)/)?.[1] }',
|
|
362
|
+
},
|
|
363
|
+
shutdown: [{ cmd: 'node scripts/drain.js' }],
|
|
364
|
+
scheduled: [
|
|
365
|
+
{
|
|
366
|
+
cmd: 'node scripts/cleanup.js',
|
|
367
|
+
runAt: { cron: '0 2 * * *' },
|
|
368
|
+
onExit: 'shutdown',
|
|
369
|
+
},
|
|
370
|
+
],
|
|
371
|
+
},
|
|
372
|
+
env: { NODE_ENV: 'production', PORT: '3000' },
|
|
373
|
+
secrets: { DATABASE_URL: 'vault:secrets/prod#db_url' },
|
|
374
|
+
volumes: [
|
|
375
|
+
{ type: 'named', name: 'node-cache', container_path: '/root/.npm' },
|
|
376
|
+
{ type: 'copy-out', container_path: '/app/logs', host_path: '/host/logs', changed_only: true },
|
|
377
|
+
],
|
|
378
|
+
ports: [{ container: 3000, host: 3000 }],
|
|
379
|
+
},
|
|
380
|
+
};
|
|
381
|
+
expect(() => figSpecSchema.parse(spec)).not.toThrow();
|
|
382
|
+
});
|
|
383
|
+
});
|