vyft 0.1.0-alpha → 0.2.0-alpha
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/README.md +19 -152
- package/dist/cli.js +39 -2
- package/dist/docker.d.ts +10 -1
- package/dist/docker.js +257 -64
- package/dist/swarm/factories.d.ts +6 -5
- package/dist/swarm/factories.js +76 -40
- package/dist/swarm/index.d.ts +3 -2
- package/dist/swarm/types.d.ts +12 -1
- package/package.json +1 -1
- package/templates/fullstack/apps/api/Dockerfile +2 -1
- package/templates/fullstack/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,189 +1,56 @@
|
|
|
1
1
|
# Vyft
|
|
2
2
|
|
|
3
|
-
Deploy apps with TypeScript
|
|
3
|
+
Deploy apps with TypeScript
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Getting Started
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
|
|
8
|
+
npx vyft init
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
This scaffolds a fullstack project with a Hono API, React SPA, and Postgres — all wired up and ready to deploy.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
## Example
|
|
14
14
|
|
|
15
15
|
```typescript
|
|
16
|
+
// vyft.config.ts
|
|
16
17
|
import { interpolate } from 'vyft';
|
|
17
18
|
import { swarm } from 'vyft/swarm';
|
|
18
19
|
|
|
19
|
-
const { secret, volume,
|
|
20
|
+
const { service, secret, volume, site } = swarm();
|
|
20
21
|
|
|
21
|
-
export const dbPassword = secret('db-password');
|
|
22
|
-
export const dbData = volume('db-data', { size: '
|
|
22
|
+
export const dbPassword = secret('db-password', { length: 32 });
|
|
23
|
+
export const dbData = volume('db-data', { size: '10GB' });
|
|
23
24
|
|
|
24
25
|
export const db = service('db', {
|
|
25
|
-
image: 'postgres:
|
|
26
|
+
image: 'postgres:17',
|
|
27
|
+
volumes: [{ volume: dbData, mount: '/var/lib/postgresql/data' }],
|
|
26
28
|
env: {
|
|
27
29
|
POSTGRES_PASSWORD: dbPassword,
|
|
28
|
-
POSTGRES_DB: 'myapp'
|
|
29
|
-
}
|
|
30
|
-
volumes: [
|
|
31
|
-
{ volume: dbData, mount: '/var/lib/postgresql/data' }
|
|
32
|
-
]
|
|
30
|
+
POSTGRES_DB: 'myapp',
|
|
31
|
+
}
|
|
33
32
|
});
|
|
34
33
|
|
|
35
34
|
export const api = service('api', {
|
|
36
|
-
route: '
|
|
37
|
-
image: {
|
|
35
|
+
route: 'example.com/api/*',
|
|
36
|
+
image: { dockerfile: './apps/api/Dockerfile' },
|
|
38
37
|
env: {
|
|
39
|
-
DATABASE_URL: interpolate`postgres://postgres:${dbPassword}@${db.host}:5432/myapp
|
|
38
|
+
DATABASE_URL: interpolate`postgres://postgres:${dbPassword}@${db.host}:5432/myapp`,
|
|
40
39
|
}
|
|
41
40
|
});
|
|
42
41
|
|
|
43
|
-
export const
|
|
42
|
+
export const web = site('web', {
|
|
44
43
|
route: 'example.com',
|
|
45
44
|
spa: true,
|
|
46
|
-
build: {
|
|
45
|
+
build: { cwd: './apps/web' }
|
|
47
46
|
});
|
|
48
47
|
```
|
|
49
48
|
|
|
50
|
-
Deploy:
|
|
51
|
-
|
|
52
49
|
```bash
|
|
53
50
|
vyft deploy
|
|
54
51
|
```
|
|
55
52
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
```bash
|
|
59
|
-
vyft destroy
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
## Runtime
|
|
63
|
-
|
|
64
|
-
Resources are created through a runtime. The `swarm` runtime manages containers, networking, and routing.
|
|
65
|
-
|
|
66
|
-
```typescript
|
|
67
|
-
import { swarm } from 'vyft/swarm';
|
|
68
|
-
|
|
69
|
-
const { secret, volume, service, site } = swarm();
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
Volumes support size limits (requires xfs with project quotas on the host):
|
|
73
|
-
|
|
74
|
-
```typescript
|
|
75
|
-
const { volume } = swarm();
|
|
76
|
-
|
|
77
|
-
export const data = volume('data', { size: '50GB' });
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
## Primitives
|
|
81
|
-
|
|
82
|
-
### volume
|
|
83
|
-
|
|
84
|
-
Persistent storage.
|
|
85
|
-
|
|
86
|
-
```typescript
|
|
87
|
-
volume('data');
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
### secret
|
|
91
|
-
|
|
92
|
-
Auto-generated secure values. Mounted as files at `/run/secrets/<id>`.
|
|
93
|
-
|
|
94
|
-
```typescript
|
|
95
|
-
const apiKey = secret('api-key');
|
|
96
|
-
const jwtSecret = secret('jwt-secret', { length: 64 });
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
Pass a secret directly as an env value to mount it. The `_FILE` suffix is added automatically:
|
|
100
|
-
|
|
101
|
-
```typescript
|
|
102
|
-
env: {
|
|
103
|
-
API_KEY: apiKey
|
|
104
|
-
}
|
|
105
|
-
// → sets API_KEY_FILE=/run/secrets/api-key
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
### interpolate
|
|
109
|
-
|
|
110
|
-
Compose env values that contain secrets. Imported from `vyft`.
|
|
111
|
-
|
|
112
|
-
```typescript
|
|
113
|
-
import { interpolate } from 'vyft';
|
|
114
|
-
|
|
115
|
-
env: {
|
|
116
|
-
DATABASE_URL: interpolate`postgres://user:${dbPassword}@${db.host}:5432/mydb`
|
|
117
|
-
}
|
|
118
|
-
// → sets DATABASE_URL_FILE=/run/secrets/<derived-secret>
|
|
119
|
-
```
|
|
120
|
-
|
|
121
|
-
The result is stored as a derived secret and mounted as a file.
|
|
122
|
-
|
|
123
|
-
### service
|
|
124
|
-
|
|
125
|
-
Run containers.
|
|
126
|
-
|
|
127
|
-
```typescript
|
|
128
|
-
const api = service('api', {
|
|
129
|
-
image: { context: './api' }, // Build from directory
|
|
130
|
-
// or: image: 'node:20', // Use existing image
|
|
131
|
-
route: 'api.example.com',
|
|
132
|
-
port: 3000,
|
|
133
|
-
env: { NODE_ENV: 'production' },
|
|
134
|
-
command: ['node', 'server.js'],
|
|
135
|
-
volumes: [{ volume: data, mount: '/app/data' }],
|
|
136
|
-
replicas: 3,
|
|
137
|
-
healthCheck: {
|
|
138
|
-
command: ['curl', '-f', 'http://localhost:3000/health'],
|
|
139
|
-
interval: '30s',
|
|
140
|
-
timeout: '10s',
|
|
141
|
-
retries: 3
|
|
142
|
-
},
|
|
143
|
-
resources: {
|
|
144
|
-
memory: '512MB',
|
|
145
|
-
cpus: 0.5
|
|
146
|
-
},
|
|
147
|
-
restartPolicy: 'on-failure'
|
|
148
|
-
});
|
|
149
|
-
```
|
|
150
|
-
|
|
151
|
-
Services expose output properties for referencing elsewhere:
|
|
152
|
-
|
|
153
|
-
```typescript
|
|
154
|
-
api.host // internal hostname
|
|
155
|
-
api.port // internal port (default 3000)
|
|
156
|
-
api.url // full URL (https if routed, http otherwise)
|
|
157
|
-
```
|
|
158
|
-
|
|
159
|
-
### site
|
|
160
|
-
|
|
161
|
-
Serve static sites with automatic builds.
|
|
162
|
-
|
|
163
|
-
```typescript
|
|
164
|
-
const app = site('app', {
|
|
165
|
-
route: 'example.com',
|
|
166
|
-
spa: true,
|
|
167
|
-
build: {
|
|
168
|
-
context: './frontend',
|
|
169
|
-
output: './dist',
|
|
170
|
-
command: 'npm run build'
|
|
171
|
-
}
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
app.url // https://example.com
|
|
175
|
-
```
|
|
176
|
-
|
|
177
|
-
## Routing
|
|
178
|
-
|
|
179
|
-
Routes go through a reverse proxy with automatic SSL.
|
|
180
|
-
|
|
181
|
-
```typescript
|
|
182
|
-
'example.com' // Root domain
|
|
183
|
-
'example.com/api/*' // Path prefix
|
|
184
|
-
'api.example.com' // Subdomain
|
|
185
|
-
'*.example.com' // Wildcard subdomain
|
|
186
|
-
```
|
|
53
|
+
Secrets are auto-generated, images are built, static sites are compiled, and everything is routed with automatic SSL.
|
|
187
54
|
|
|
188
55
|
## Requirements
|
|
189
56
|
|
package/dist/cli.js
CHANGED
|
@@ -46,6 +46,27 @@ function isResource(value) {
|
|
|
46
46
|
typeof value.type === "string" &&
|
|
47
47
|
typeof value.id === "string");
|
|
48
48
|
}
|
|
49
|
+
function collectResources(exports) {
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
const resources = [];
|
|
52
|
+
for (const value of Object.values(exports)) {
|
|
53
|
+
if (isResource(value)) {
|
|
54
|
+
if (!seen.has(value.id)) {
|
|
55
|
+
seen.add(value.id);
|
|
56
|
+
resources.push(value);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else if (typeof value === "object" && value !== null) {
|
|
60
|
+
for (const nested of Object.values(value)) {
|
|
61
|
+
if (isResource(nested) && !seen.has(nested.id)) {
|
|
62
|
+
seen.add(nested.id);
|
|
63
|
+
resources.push(nested);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return resources;
|
|
69
|
+
}
|
|
49
70
|
function hasRuntime(value) {
|
|
50
71
|
return (typeof value === "object" &&
|
|
51
72
|
value !== null &&
|
|
@@ -69,9 +90,10 @@ async function deploy(configFile, verbose) {
|
|
|
69
90
|
const sessionLog = logger.child({ sessionId, command: "deploy" });
|
|
70
91
|
const start = performance.now();
|
|
71
92
|
const project = await findProjectName(configFile);
|
|
93
|
+
process.env.VYFT_PROJECT = project;
|
|
72
94
|
intro(`Deploying ${project}`);
|
|
73
95
|
const config = await import(configFile);
|
|
74
|
-
const resources =
|
|
96
|
+
const resources = collectResources(config);
|
|
75
97
|
if (resources.length === 0) {
|
|
76
98
|
log.warn("No resources found");
|
|
77
99
|
outro();
|
|
@@ -87,7 +109,7 @@ async function deploy(configFile, verbose) {
|
|
|
87
109
|
let skipped = 0;
|
|
88
110
|
for (const resource of resources) {
|
|
89
111
|
const exists = await docker.exists(resource);
|
|
90
|
-
if (exists) {
|
|
112
|
+
if (exists && resource.type !== "service" && resource.type !== "site") {
|
|
91
113
|
log.success(resource.id);
|
|
92
114
|
skipped++;
|
|
93
115
|
}
|
|
@@ -142,6 +164,7 @@ async function destroy(searchDir) {
|
|
|
142
164
|
removed++;
|
|
143
165
|
}
|
|
144
166
|
}
|
|
167
|
+
await docker.removeProjectNetwork();
|
|
145
168
|
const durationMs = Math.round(performance.now() - start);
|
|
146
169
|
sessionLog.info({ project, removed, durationMs }, "destroy completed");
|
|
147
170
|
outro("Destroy complete");
|
|
@@ -181,6 +204,20 @@ program
|
|
|
181
204
|
throw err;
|
|
182
205
|
}
|
|
183
206
|
});
|
|
207
|
+
const proxy = program.command("proxy").description("Manage the proxy");
|
|
208
|
+
proxy
|
|
209
|
+
.command("logs")
|
|
210
|
+
.description("View proxy request logs")
|
|
211
|
+
.option("-f, --follow", "stream logs continuously", false)
|
|
212
|
+
.option("--tail <n>", "number of lines to show", "100")
|
|
213
|
+
.action(async (opts) => {
|
|
214
|
+
const project = await findProjectName(path.join(process.cwd(), "package.json"));
|
|
215
|
+
const docker = new DockerClient(project);
|
|
216
|
+
await docker.proxyLogs({
|
|
217
|
+
follow: opts.follow,
|
|
218
|
+
tail: parseInt(opts.tail, 10),
|
|
219
|
+
});
|
|
220
|
+
});
|
|
184
221
|
program
|
|
185
222
|
.command("destroy")
|
|
186
223
|
.description("Destroy all deployed resources")
|
package/dist/docker.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export declare function parseRoute(route: string): {
|
|
|
5
5
|
host: string;
|
|
6
6
|
path?: string;
|
|
7
7
|
};
|
|
8
|
-
export declare function buildCaddyRoute(
|
|
8
|
+
export declare function buildCaddyRoute(resourceId: string, route: string, handler: Record<string, unknown>): Record<string, unknown>;
|
|
9
9
|
export interface ManagedResource {
|
|
10
10
|
id: string;
|
|
11
11
|
type: ResourceType;
|
|
@@ -15,11 +15,14 @@ export interface ManagedResource {
|
|
|
15
15
|
}
|
|
16
16
|
export declare class DockerClient implements Runtime {
|
|
17
17
|
private docker;
|
|
18
|
+
private localDocker;
|
|
19
|
+
private isRemote;
|
|
18
20
|
private project;
|
|
19
21
|
private secretValues;
|
|
20
22
|
private log;
|
|
21
23
|
verbose: boolean;
|
|
22
24
|
constructor(project: string, parentLogger?: Logger);
|
|
25
|
+
private ensureNetwork;
|
|
23
26
|
ensureInfrastructure(): Promise<void>;
|
|
24
27
|
private findProxyContainer;
|
|
25
28
|
private caddyApiRequest;
|
|
@@ -43,6 +46,12 @@ export declare class DockerClient implements Runtime {
|
|
|
43
46
|
private createService;
|
|
44
47
|
private serviceExists;
|
|
45
48
|
private removeService;
|
|
49
|
+
private pullImage;
|
|
46
50
|
private createStatic;
|
|
47
51
|
private removeStatic;
|
|
52
|
+
proxyLogs(options: {
|
|
53
|
+
follow?: boolean;
|
|
54
|
+
tail?: number;
|
|
55
|
+
}): Promise<void>;
|
|
56
|
+
removeProjectNetwork(): Promise<void>;
|
|
48
57
|
}
|
package/dist/docker.js
CHANGED
|
@@ -67,13 +67,13 @@ export function parseRoute(route) {
|
|
|
67
67
|
path: route.slice(slashIndex),
|
|
68
68
|
};
|
|
69
69
|
}
|
|
70
|
-
export function buildCaddyRoute(
|
|
70
|
+
export function buildCaddyRoute(resourceId, route, handler) {
|
|
71
71
|
const { host, path } = parseRoute(route);
|
|
72
72
|
const match = { host: [host] };
|
|
73
73
|
if (path)
|
|
74
74
|
match.path = [path];
|
|
75
75
|
return {
|
|
76
|
-
"@id": `vyft-${
|
|
76
|
+
"@id": `vyft-${resourceId}`,
|
|
77
77
|
match: [match],
|
|
78
78
|
terminal: true,
|
|
79
79
|
handle: [handler],
|
|
@@ -81,15 +81,19 @@ export function buildCaddyRoute(project, resourceId, route, handler) {
|
|
|
81
81
|
}
|
|
82
82
|
export class DockerClient {
|
|
83
83
|
docker;
|
|
84
|
+
localDocker;
|
|
85
|
+
isRemote;
|
|
84
86
|
project;
|
|
85
87
|
secretValues = new Map();
|
|
86
88
|
log;
|
|
87
89
|
verbose = false;
|
|
88
90
|
constructor(project, parentLogger) {
|
|
91
|
+
this.localDocker = new Docker({ socketPath: "/var/run/docker.sock" });
|
|
89
92
|
const contextHost = resolveDockerHost();
|
|
90
93
|
if (contextHost)
|
|
91
94
|
process.env.DOCKER_HOST = contextHost;
|
|
92
95
|
this.docker = new Docker();
|
|
96
|
+
this.isRemote = !!(contextHost || process.env.DOCKER_HOST);
|
|
93
97
|
this.project = project;
|
|
94
98
|
this.log = (parentLogger ?? defaultLogger).child({
|
|
95
99
|
component: "docker",
|
|
@@ -98,27 +102,49 @@ export class DockerClient {
|
|
|
98
102
|
if (contextHost)
|
|
99
103
|
this.log.debug({ host: contextHost }, "using docker context endpoint");
|
|
100
104
|
}
|
|
101
|
-
async
|
|
102
|
-
const start = performance.now();
|
|
103
|
-
this.log.debug("checking proxy existence");
|
|
104
|
-
const exists = await this.serviceExists(PROXY_SERVICE_NAME);
|
|
105
|
-
if (exists) {
|
|
106
|
-
this.log.debug("proxy already exists");
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
109
|
-
// Create network for services to communicate
|
|
105
|
+
async ensureNetwork(name, labels = {}) {
|
|
110
106
|
const networks = await this.docker.listNetworks({
|
|
111
|
-
filters: JSON.stringify({ name: [
|
|
107
|
+
filters: JSON.stringify({ name: [name] }),
|
|
112
108
|
});
|
|
113
|
-
|
|
114
|
-
|
|
109
|
+
const exactMatch = networks.some((n) => n.Name === name);
|
|
110
|
+
if (!exactMatch) {
|
|
111
|
+
this.log.debug({ network: name }, "creating network");
|
|
115
112
|
await this.docker.createNetwork({
|
|
116
|
-
Name:
|
|
113
|
+
Name: name,
|
|
117
114
|
Driver: "overlay",
|
|
118
115
|
Attachable: true,
|
|
119
|
-
Labels:
|
|
116
|
+
Labels: labels,
|
|
120
117
|
});
|
|
121
118
|
}
|
|
119
|
+
}
|
|
120
|
+
async ensureInfrastructure() {
|
|
121
|
+
const start = performance.now();
|
|
122
|
+
// Ensure per-project network for inter-service DNS
|
|
123
|
+
await this.ensureNetwork(`${this.project}-internal`, {
|
|
124
|
+
"com.docker.stack.namespace": this.project,
|
|
125
|
+
"vyft.managed": "true",
|
|
126
|
+
"vyft.project": this.project,
|
|
127
|
+
});
|
|
128
|
+
this.log.debug("checking proxy existence");
|
|
129
|
+
const exists = await this.serviceExists(PROXY_SERVICE_NAME);
|
|
130
|
+
if (exists) {
|
|
131
|
+
this.log.debug("proxy already exists, verifying config structure");
|
|
132
|
+
try {
|
|
133
|
+
const raw = await this.caddyApiRequest("GET", "/config/");
|
|
134
|
+
const config = JSON.parse(raw);
|
|
135
|
+
if (!config?.apps?.http?.servers?.main) {
|
|
136
|
+
this.log.debug("proxy config missing servers.main, re-seeding");
|
|
137
|
+
await this.seedCaddyConfig();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
this.log.debug("proxy config check failed, re-seeding");
|
|
142
|
+
await this.seedCaddyConfig();
|
|
143
|
+
}
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
// Create shared proxy network
|
|
147
|
+
await this.ensureNetwork("vyft-network", { "vyft.infrastructure": "true" });
|
|
122
148
|
this.log.debug("creating proxy service");
|
|
123
149
|
await this.docker.createService({
|
|
124
150
|
Name: PROXY_SERVICE_NAME,
|
|
@@ -266,12 +292,20 @@ export class DockerClient {
|
|
|
266
292
|
}
|
|
267
293
|
}
|
|
268
294
|
const baseConfig = {
|
|
295
|
+
logging: {
|
|
296
|
+
logs: {
|
|
297
|
+
default: {
|
|
298
|
+
level: "INFO",
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
},
|
|
269
302
|
apps: {
|
|
270
303
|
http: {
|
|
271
304
|
servers: {
|
|
272
305
|
main: {
|
|
273
306
|
listen: [":443", ":80"],
|
|
274
307
|
routes: [],
|
|
308
|
+
logs: {},
|
|
275
309
|
},
|
|
276
310
|
},
|
|
277
311
|
},
|
|
@@ -298,10 +332,10 @@ export class DockerClient {
|
|
|
298
332
|
}
|
|
299
333
|
async addRoute(resourceId, route, handler) {
|
|
300
334
|
this.log.debug({ resourceId, route }, "adding route");
|
|
301
|
-
const caddyRoute = buildCaddyRoute(
|
|
335
|
+
const caddyRoute = buildCaddyRoute(resourceId, route, handler);
|
|
302
336
|
// Delete existing route for idempotency
|
|
303
337
|
try {
|
|
304
|
-
await this.caddyApiRequest("DELETE", `/id/vyft-${
|
|
338
|
+
await this.caddyApiRequest("DELETE", `/id/vyft-${resourceId}`);
|
|
305
339
|
}
|
|
306
340
|
catch (err) {
|
|
307
341
|
this.log.debug({ err, resourceId }, "idempotent route delete failed");
|
|
@@ -326,7 +360,7 @@ export class DockerClient {
|
|
|
326
360
|
async removeRoute(resourceId) {
|
|
327
361
|
this.log.debug({ resourceId }, "removing route");
|
|
328
362
|
try {
|
|
329
|
-
await this.caddyApiRequest("DELETE", `/id/vyft-${
|
|
363
|
+
await this.caddyApiRequest("DELETE", `/id/vyft-${resourceId}`);
|
|
330
364
|
this.log.debug({ resourceId }, "route removed");
|
|
331
365
|
}
|
|
332
366
|
catch (err) {
|
|
@@ -419,6 +453,7 @@ export class DockerClient {
|
|
|
419
453
|
this.log.debug({ volumeName: volume.id }, "creating volume");
|
|
420
454
|
const config = volume.config;
|
|
421
455
|
const labels = {
|
|
456
|
+
"com.docker.stack.namespace": this.project,
|
|
422
457
|
"vyft.managed": "true",
|
|
423
458
|
"vyft.project": this.project,
|
|
424
459
|
"vyft.type": "volume",
|
|
@@ -497,6 +532,7 @@ export class DockerClient {
|
|
|
497
532
|
Name: name,
|
|
498
533
|
Data: Buffer.from(value).toString("base64"),
|
|
499
534
|
Labels: {
|
|
535
|
+
"com.docker.stack.namespace": this.project,
|
|
500
536
|
"vyft.managed": "true",
|
|
501
537
|
"vyft.project": this.project,
|
|
502
538
|
"vyft.type": "secret",
|
|
@@ -531,25 +567,39 @@ export class DockerClient {
|
|
|
531
567
|
envList.push(`${envKey}=/run/secrets/${value.id}`);
|
|
532
568
|
}
|
|
533
569
|
else if (isInterpolation(value)) {
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
570
|
+
const derivedName = `${serviceId}-${key.toLowerCase().replace(/_/g, "-")}`;
|
|
571
|
+
// Check if all referenced secrets have their values available
|
|
572
|
+
let allValuesAvailable = true;
|
|
573
|
+
for (const v of value.values) {
|
|
574
|
+
if (isReference(v) && !this.secretValues.has(v.id)) {
|
|
575
|
+
allValuesAvailable = false;
|
|
576
|
+
break;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if (allValuesAvailable) {
|
|
580
|
+
// Build the interpolated value and create/update the derived secret
|
|
581
|
+
const parts = [];
|
|
582
|
+
for (let i = 0; i < value.strings.length; i++) {
|
|
583
|
+
parts.push(value.strings[i]);
|
|
584
|
+
if (i < value.values.length) {
|
|
585
|
+
const v = value.values[i];
|
|
586
|
+
if (isReference(v)) {
|
|
587
|
+
parts.push(this.secretValues.get(v.id));
|
|
588
|
+
}
|
|
589
|
+
else {
|
|
590
|
+
parts.push(v);
|
|
543
591
|
}
|
|
544
|
-
parts.push(secretValue);
|
|
545
|
-
}
|
|
546
|
-
else {
|
|
547
|
-
parts.push(v);
|
|
548
592
|
}
|
|
549
593
|
}
|
|
594
|
+
await this.createDerivedSecret(derivedName, parts.join(""), serviceId);
|
|
595
|
+
}
|
|
596
|
+
else if (!(await this.secretExists(derivedName))) {
|
|
597
|
+
// Source secret values not in memory and derived secret doesn't exist
|
|
598
|
+
const missing = value.values
|
|
599
|
+
.filter((v) => isReference(v) && !this.secretValues.has(v.id))
|
|
600
|
+
.map((v) => v.id);
|
|
601
|
+
throw new Error(`Secret(s) ${missing.join(", ")} value not available and derived secret "${derivedName}" does not exist — destroy and redeploy to regenerate`);
|
|
550
602
|
}
|
|
551
|
-
const derivedName = `${serviceId}-${key.toLowerCase().replace(/_/g, "-")}`;
|
|
552
|
-
await this.createDerivedSecret(derivedName, parts.join(""), serviceId);
|
|
553
603
|
const derivedId = await this.lookupSecretId(derivedName);
|
|
554
604
|
secrets.push(secretMount(derivedId, derivedName));
|
|
555
605
|
const envKey = key.endsWith("_FILE") ? key : `${key}_FILE`;
|
|
@@ -561,20 +611,25 @@ export class DockerClient {
|
|
|
561
611
|
}
|
|
562
612
|
async createService(service) {
|
|
563
613
|
const start = performance.now();
|
|
614
|
+
await this.ensureNetwork(`${this.project}-internal`, {
|
|
615
|
+
"com.docker.stack.namespace": this.project,
|
|
616
|
+
"vyft.managed": "true",
|
|
617
|
+
"vyft.project": this.project,
|
|
618
|
+
});
|
|
564
619
|
const config = service.config;
|
|
565
620
|
let imageName;
|
|
566
621
|
if (typeof config.image === "object") {
|
|
567
622
|
const { context = ".", dockerfile = "Dockerfile" } = config.image;
|
|
568
|
-
|
|
623
|
+
const buildTag = `${service.id}:latest`;
|
|
569
624
|
this.log.debug({ resourceId: service.id, context, dockerfile }, "image build started");
|
|
570
625
|
const buildStart = performance.now();
|
|
571
626
|
const tarStream = tar.pack(path.resolve(context));
|
|
572
|
-
const stream = await this.
|
|
573
|
-
t:
|
|
627
|
+
const stream = await this.localDocker.buildImage(tarStream, {
|
|
628
|
+
t: buildTag,
|
|
574
629
|
dockerfile,
|
|
575
630
|
});
|
|
576
631
|
await new Promise((resolve, reject) => {
|
|
577
|
-
this.
|
|
632
|
+
this.localDocker.modem.followProgress(stream, (err) => (err ? reject(err) : resolve()), (event) => {
|
|
578
633
|
if (event.error)
|
|
579
634
|
log.error(event.error);
|
|
580
635
|
else if (this.verbose && event.stream)
|
|
@@ -585,14 +640,29 @@ export class DockerClient {
|
|
|
585
640
|
const buildDurationMs = Math.round(performance.now() - buildStart);
|
|
586
641
|
this.log.debug({
|
|
587
642
|
resourceId: service.id,
|
|
588
|
-
image:
|
|
643
|
+
image: buildTag,
|
|
589
644
|
durationMs: buildDurationMs,
|
|
590
645
|
}, "image build completed");
|
|
591
|
-
const images = await this.
|
|
592
|
-
filters: JSON.stringify({ reference: [
|
|
646
|
+
const images = await this.localDocker.listImages({
|
|
647
|
+
filters: JSON.stringify({ reference: [buildTag] }),
|
|
593
648
|
});
|
|
594
649
|
if (images.length === 0) {
|
|
595
|
-
throw new Error(`Image ${
|
|
650
|
+
throw new Error(`Image ${buildTag} was not built successfully`);
|
|
651
|
+
}
|
|
652
|
+
// Tag with content hash so the orchestrator detects the new image
|
|
653
|
+
const shortHash = images[0].Id.replace("sha256:", "").slice(0, 12);
|
|
654
|
+
imageName = `${service.id}:${shortHash}`;
|
|
655
|
+
await this.localDocker.getImage(buildTag).tag({
|
|
656
|
+
repo: service.id,
|
|
657
|
+
tag: shortHash,
|
|
658
|
+
});
|
|
659
|
+
if (this.isRemote) {
|
|
660
|
+
this.log.debug({ image: imageName }, "transferring image to remote");
|
|
661
|
+
const transferStart = performance.now();
|
|
662
|
+
const imageStream = await this.localDocker.getImage(imageName).get();
|
|
663
|
+
await this.docker.loadImage(imageStream);
|
|
664
|
+
const transferDurationMs = Math.round(performance.now() - transferStart);
|
|
665
|
+
this.log.debug({ image: imageName, durationMs: transferDurationMs }, "image transferred to remote");
|
|
596
666
|
}
|
|
597
667
|
}
|
|
598
668
|
else {
|
|
@@ -600,11 +670,13 @@ export class DockerClient {
|
|
|
600
670
|
}
|
|
601
671
|
// Build labels for service and container
|
|
602
672
|
const serviceLabels = {
|
|
673
|
+
"com.docker.stack.namespace": this.project,
|
|
603
674
|
"vyft.managed": "true",
|
|
604
675
|
"vyft.project": this.project,
|
|
605
676
|
"vyft.type": "service",
|
|
606
677
|
};
|
|
607
678
|
const containerLabels = {
|
|
679
|
+
"com.docker.stack.namespace": this.project,
|
|
608
680
|
"vyft.managed": "true",
|
|
609
681
|
"vyft.project": this.project,
|
|
610
682
|
};
|
|
@@ -641,9 +713,15 @@ export class DockerClient {
|
|
|
641
713
|
: undefined,
|
|
642
714
|
};
|
|
643
715
|
}
|
|
716
|
+
const networks = [
|
|
717
|
+
{ Target: `${this.project}-internal` },
|
|
718
|
+
];
|
|
719
|
+
if (config.route) {
|
|
720
|
+
networks.push({ Target: "vyft-network" });
|
|
721
|
+
}
|
|
644
722
|
const taskTemplate = {
|
|
645
723
|
ContainerSpec: containerSpec,
|
|
646
|
-
Networks:
|
|
724
|
+
Networks: networks,
|
|
647
725
|
};
|
|
648
726
|
if (config.resources) {
|
|
649
727
|
taskTemplate.Resources = {
|
|
@@ -687,8 +765,20 @@ export class DockerClient {
|
|
|
687
765
|
: undefined,
|
|
688
766
|
};
|
|
689
767
|
this.log.trace({ serviceSpec }, "docker create service spec");
|
|
690
|
-
this.
|
|
691
|
-
|
|
768
|
+
const existing = await this.serviceExists(service.id);
|
|
769
|
+
if (existing) {
|
|
770
|
+
this.log.debug({ resourceId: service.id }, "updating existing service");
|
|
771
|
+
const svc = this.docker.getService(service.id);
|
|
772
|
+
const info = await svc.inspect();
|
|
773
|
+
await svc.update({
|
|
774
|
+
...serviceSpec,
|
|
775
|
+
version: info.Version.Index,
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
else {
|
|
779
|
+
this.log.debug({ resourceId: service.id }, "creating new service");
|
|
780
|
+
await this.docker.createService(serviceSpec);
|
|
781
|
+
}
|
|
692
782
|
if (config.route) {
|
|
693
783
|
const port = config.port || 3000;
|
|
694
784
|
await this.addRoute(service.id, config.route, {
|
|
@@ -702,8 +792,8 @@ export class DockerClient {
|
|
|
702
792
|
image: imageName,
|
|
703
793
|
hasRoute: !!config.route,
|
|
704
794
|
durationMs,
|
|
705
|
-
}, "service created");
|
|
706
|
-
log.step(
|
|
795
|
+
}, existing ? "service updated" : "service created");
|
|
796
|
+
log.step(`${existing ? "Updated" : "Created"} ${service.id}`);
|
|
707
797
|
}
|
|
708
798
|
async serviceExists(id) {
|
|
709
799
|
try {
|
|
@@ -743,8 +833,21 @@ export class DockerClient {
|
|
|
743
833
|
this.log.info({ resourceId: id, durationMs }, "service removed");
|
|
744
834
|
log.step(`Removed ${id}`);
|
|
745
835
|
}
|
|
836
|
+
async pullImage(image) {
|
|
837
|
+
this.log.debug({ image }, "pulling image");
|
|
838
|
+
const stream = await this.docker.pull(image);
|
|
839
|
+
await new Promise((resolve, reject) => {
|
|
840
|
+
this.docker.modem.followProgress(stream, (err) => err ? reject(err) : resolve());
|
|
841
|
+
});
|
|
842
|
+
this.log.debug({ image }, "image pulled");
|
|
843
|
+
}
|
|
746
844
|
async createStatic(static_) {
|
|
747
845
|
const start = performance.now();
|
|
846
|
+
await this.ensureNetwork(`${this.project}-internal`, {
|
|
847
|
+
"com.docker.stack.namespace": this.project,
|
|
848
|
+
"vyft.managed": "true",
|
|
849
|
+
"vyft.project": this.project,
|
|
850
|
+
});
|
|
748
851
|
this.log.debug({ resourceId: static_.id, route: static_.config.route }, "creating static site");
|
|
749
852
|
this.log.debug({ resourceId: static_.id }, "static build starting");
|
|
750
853
|
const { outputPath } = await buildStatic(static_.config.build.cwd, {
|
|
@@ -753,17 +856,21 @@ export class DockerClient {
|
|
|
753
856
|
env: static_.config.build.env,
|
|
754
857
|
}, this.log);
|
|
755
858
|
const volumeName = `${static_.id}-files`;
|
|
756
|
-
this.
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
859
|
+
if (!(await this.volumeExists(volumeName))) {
|
|
860
|
+
this.log.debug({ volumeName }, "creating static volume");
|
|
861
|
+
await this.docker.createVolume({
|
|
862
|
+
Name: volumeName,
|
|
863
|
+
Labels: {
|
|
864
|
+
"com.docker.stack.namespace": this.project,
|
|
865
|
+
"vyft.managed": "true",
|
|
866
|
+
"vyft.project": this.project,
|
|
867
|
+
"vyft.type": "volume",
|
|
868
|
+
"vyft.static": static_.id,
|
|
869
|
+
},
|
|
870
|
+
});
|
|
871
|
+
}
|
|
766
872
|
this.log.debug({ resourceId: static_.id, outputPath }, "tar copy started");
|
|
873
|
+
await this.pullImage("alpine:latest");
|
|
767
874
|
const tarStream = tar.pack(outputPath);
|
|
768
875
|
const container = await this.docker.createContainer({
|
|
769
876
|
Image: "alpine:latest",
|
|
@@ -773,16 +880,24 @@ export class DockerClient {
|
|
|
773
880
|
},
|
|
774
881
|
});
|
|
775
882
|
await container.start();
|
|
883
|
+
// Clear old files before copying new ones
|
|
884
|
+
await container.exec({
|
|
885
|
+
Cmd: ["sh", "-c", "rm -rf /data/*"],
|
|
886
|
+
AttachStdout: true,
|
|
887
|
+
AttachStderr: true,
|
|
888
|
+
});
|
|
776
889
|
await container.putArchive(tarStream, { path: "/data" });
|
|
777
890
|
await container.remove({ force: true });
|
|
778
891
|
this.log.debug({ resourceId: static_.id }, "tar copy completed");
|
|
779
892
|
const serviceLabels = {
|
|
893
|
+
"com.docker.stack.namespace": this.project,
|
|
780
894
|
"vyft.managed": "true",
|
|
781
895
|
"vyft.project": this.project,
|
|
782
896
|
"vyft.type": "site",
|
|
783
897
|
"vyft.route": static_.config.route,
|
|
784
898
|
};
|
|
785
899
|
const containerLabels = {
|
|
900
|
+
"com.docker.stack.namespace": this.project,
|
|
786
901
|
"vyft.managed": "true",
|
|
787
902
|
"vyft.project": this.project,
|
|
788
903
|
};
|
|
@@ -793,28 +908,50 @@ export class DockerClient {
|
|
|
793
908
|
"printf ':80 {\\nroot * /srv\\ntry_files {path} /index.html\\nfile_server\\n}\\n' > /etc/caddy/Caddyfile && caddy run --config /etc/caddy/Caddyfile --adapter caddyfile",
|
|
794
909
|
]
|
|
795
910
|
: ["caddy", "file-server", "--root", "/srv", "--listen", ":80"];
|
|
796
|
-
|
|
797
|
-
await this.docker.createService({
|
|
911
|
+
const serviceSpec = {
|
|
798
912
|
Name: static_.id,
|
|
799
913
|
Labels: serviceLabels,
|
|
800
914
|
TaskTemplate: {
|
|
801
915
|
ContainerSpec: {
|
|
802
916
|
Image: "caddy:latest",
|
|
803
917
|
Command: command,
|
|
804
|
-
Mounts: [
|
|
918
|
+
Mounts: [
|
|
919
|
+
{ Type: "volume", Source: volumeName, Target: "/srv" },
|
|
920
|
+
],
|
|
805
921
|
Labels: containerLabels,
|
|
806
922
|
},
|
|
807
|
-
Networks: [
|
|
923
|
+
Networks: [
|
|
924
|
+
{ Target: `${this.project}-internal` },
|
|
925
|
+
{ Target: "vyft-network" },
|
|
926
|
+
],
|
|
808
927
|
},
|
|
809
928
|
Mode: { Replicated: { Replicas: 1 } },
|
|
810
|
-
}
|
|
929
|
+
};
|
|
930
|
+
const existing = await this.serviceExists(static_.id);
|
|
931
|
+
if (existing) {
|
|
932
|
+
this.log.debug({ resourceId: static_.id }, "updating static site service");
|
|
933
|
+
const svc = this.docker.getService(static_.id);
|
|
934
|
+
const info = await svc.inspect();
|
|
935
|
+
await svc.update({
|
|
936
|
+
...serviceSpec,
|
|
937
|
+
version: info.Version.Index,
|
|
938
|
+
TaskTemplate: {
|
|
939
|
+
...serviceSpec.TaskTemplate,
|
|
940
|
+
ForceUpdate: (info.Spec?.TaskTemplate?.ForceUpdate ?? 0) + 1,
|
|
941
|
+
},
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
else {
|
|
945
|
+
this.log.debug({ resourceId: static_.id }, "creating caddy service for static site");
|
|
946
|
+
await this.docker.createService(serviceSpec);
|
|
947
|
+
}
|
|
811
948
|
await this.addRoute(static_.id, static_.config.route, {
|
|
812
949
|
handler: "reverse_proxy",
|
|
813
950
|
upstreams: [{ dial: `${static_.id}:80` }],
|
|
814
951
|
});
|
|
815
952
|
const durationMs = Math.round(performance.now() - start);
|
|
816
|
-
this.log.info({ resourceId: static_.id, route: static_.config.route, durationMs }, "site created");
|
|
817
|
-
log.step(
|
|
953
|
+
this.log.info({ resourceId: static_.id, route: static_.config.route, durationMs }, existing ? "site updated" : "site created");
|
|
954
|
+
log.step(`${existing ? "Updated" : "Created"} ${static_.id}`);
|
|
818
955
|
}
|
|
819
956
|
async removeStatic(id) {
|
|
820
957
|
const start = performance.now();
|
|
@@ -852,4 +989,60 @@ export class DockerClient {
|
|
|
852
989
|
this.log.info({ resourceId: id, durationMs }, "site removed");
|
|
853
990
|
log.step(`Removed ${id}`);
|
|
854
991
|
}
|
|
992
|
+
async proxyLogs(options) {
|
|
993
|
+
const container = await this.findProxyContainer();
|
|
994
|
+
const tail = options.tail ?? 100;
|
|
995
|
+
if (options.follow) {
|
|
996
|
+
const stream = await container.logs({
|
|
997
|
+
stdout: true,
|
|
998
|
+
stderr: true,
|
|
999
|
+
follow: true,
|
|
1000
|
+
tail,
|
|
1001
|
+
timestamps: true,
|
|
1002
|
+
});
|
|
1003
|
+
const stdoutStream = new PassThrough();
|
|
1004
|
+
const stderrStream = new PassThrough();
|
|
1005
|
+
stdoutStream.pipe(process.stdout);
|
|
1006
|
+
stderrStream.pipe(process.stderr);
|
|
1007
|
+
this.docker.modem.demuxStream(stream, stdoutStream, stderrStream);
|
|
1008
|
+
await new Promise((resolve) => {
|
|
1009
|
+
stream.on("end", resolve);
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
else {
|
|
1013
|
+
const buf = await container.logs({
|
|
1014
|
+
stdout: true,
|
|
1015
|
+
stderr: true,
|
|
1016
|
+
follow: false,
|
|
1017
|
+
tail,
|
|
1018
|
+
timestamps: true,
|
|
1019
|
+
});
|
|
1020
|
+
// Demux the multiplexed buffer: each frame has an 8-byte header
|
|
1021
|
+
// [stream_type(1), padding(3), size(4)] followed by the payload.
|
|
1022
|
+
let offset = 0;
|
|
1023
|
+
while (offset + 8 <= buf.length) {
|
|
1024
|
+
const type = buf[offset];
|
|
1025
|
+
const size = buf.readUInt32BE(offset + 4);
|
|
1026
|
+
offset += 8;
|
|
1027
|
+
const payload = buf.subarray(offset, offset + size);
|
|
1028
|
+
if (type === 2) {
|
|
1029
|
+
process.stderr.write(payload);
|
|
1030
|
+
}
|
|
1031
|
+
else {
|
|
1032
|
+
process.stdout.write(payload);
|
|
1033
|
+
}
|
|
1034
|
+
offset += size;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
async removeProjectNetwork() {
|
|
1039
|
+
const name = `${this.project}-internal`;
|
|
1040
|
+
try {
|
|
1041
|
+
await this.docker.getNetwork(name).remove();
|
|
1042
|
+
this.log.debug({ network: name }, "project network removed");
|
|
1043
|
+
}
|
|
1044
|
+
catch {
|
|
1045
|
+
this.log.debug({ network: name }, "project network removal skipped");
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
855
1048
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { Secret, SecretConfig, Service, Site, SiteConfig, Volume } from "../resource.js";
|
|
2
|
-
import type { StorageDriver, SwarmConfig, SwarmServiceConfig, SwarmVolumeConfig } from "./types.js";
|
|
2
|
+
import type { Postgres, PostgresConfig, StorageDriver, SwarmConfig, SwarmServiceConfig, SwarmVolumeConfig } from "./types.js";
|
|
3
3
|
export declare function createFactories<D extends StorageDriver = "local">(swarmConfig: SwarmConfig<D>): {
|
|
4
|
-
volume(id: string, config?: SwarmVolumeConfig<D>)
|
|
5
|
-
service(id: string, config: SwarmServiceConfig)
|
|
6
|
-
secret(id: string, config?: SecretConfig)
|
|
7
|
-
site(id: string, config: SiteConfig)
|
|
4
|
+
volume: (id: string, config?: SwarmVolumeConfig<D>) => Volume;
|
|
5
|
+
service: (id: string, config: SwarmServiceConfig) => Service;
|
|
6
|
+
secret: (id: string, config?: SecretConfig) => Secret;
|
|
7
|
+
site: (id: string, config: SiteConfig) => Site;
|
|
8
|
+
postgres: (id: string, config?: PostgresConfig) => Postgres;
|
|
8
9
|
};
|
package/dist/swarm/factories.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { interpolate } from "../interpolate.js";
|
|
1
2
|
import { validateId, validateRoute } from "../resource.js";
|
|
2
3
|
import { VYFT_RUNTIME } from "../symbols.js";
|
|
3
4
|
function attachRuntime(obj, config) {
|
|
@@ -6,45 +7,80 @@ function attachRuntime(obj, config) {
|
|
|
6
7
|
});
|
|
7
8
|
}
|
|
8
9
|
export function createFactories(swarmConfig) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const port = config.port || 3000;
|
|
20
|
-
return attachRuntime({
|
|
21
|
-
type: "service",
|
|
22
|
-
id,
|
|
23
|
-
config,
|
|
24
|
-
host: id,
|
|
25
|
-
port,
|
|
26
|
-
url: config.route
|
|
27
|
-
? `https://${config.route}`
|
|
28
|
-
: `http://${id}:${port}`,
|
|
29
|
-
}, swarmConfig);
|
|
30
|
-
},
|
|
31
|
-
secret(id, config = {}) {
|
|
32
|
-
validateId(id);
|
|
33
|
-
return attachRuntime({
|
|
34
|
-
type: "secret",
|
|
35
|
-
id,
|
|
36
|
-
config,
|
|
37
|
-
}, swarmConfig);
|
|
38
|
-
},
|
|
39
|
-
site(id, config) {
|
|
40
|
-
validateId(id);
|
|
10
|
+
const project = process.env.VYFT_PROJECT;
|
|
11
|
+
const prefix = project ? `${project}-` : "";
|
|
12
|
+
function volume(id, config = {}) {
|
|
13
|
+
validateId(id);
|
|
14
|
+
const fullId = `${prefix}${id}`;
|
|
15
|
+
return attachRuntime({ type: "volume", id: fullId, config }, swarmConfig);
|
|
16
|
+
}
|
|
17
|
+
function service(id, config) {
|
|
18
|
+
validateId(id);
|
|
19
|
+
if (config.route) {
|
|
41
20
|
validateRoute(config.route);
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
21
|
+
}
|
|
22
|
+
const fullId = `${prefix}${id}`;
|
|
23
|
+
const port = config.port || 3000;
|
|
24
|
+
return attachRuntime({
|
|
25
|
+
type: "service",
|
|
26
|
+
id: fullId,
|
|
27
|
+
config,
|
|
28
|
+
host: fullId,
|
|
29
|
+
port,
|
|
30
|
+
url: config.route
|
|
31
|
+
? `https://${config.route}`
|
|
32
|
+
: `http://${fullId}:${port}`,
|
|
33
|
+
}, swarmConfig);
|
|
34
|
+
}
|
|
35
|
+
function secret(id, config = {}) {
|
|
36
|
+
validateId(id);
|
|
37
|
+
const fullId = `${prefix}${id}`;
|
|
38
|
+
return attachRuntime({
|
|
39
|
+
type: "secret",
|
|
40
|
+
id: fullId,
|
|
41
|
+
config,
|
|
42
|
+
}, swarmConfig);
|
|
43
|
+
}
|
|
44
|
+
function site(id, config) {
|
|
45
|
+
validateId(id);
|
|
46
|
+
validateRoute(config.route);
|
|
47
|
+
const fullId = `${prefix}${id}`;
|
|
48
|
+
return attachRuntime({
|
|
49
|
+
type: "site",
|
|
50
|
+
id: fullId,
|
|
51
|
+
config,
|
|
52
|
+
url: `https://${config.route}`,
|
|
53
|
+
}, swarmConfig);
|
|
54
|
+
}
|
|
55
|
+
function postgres(id, config = {}) {
|
|
56
|
+
validateId(id);
|
|
57
|
+
const version = config.version ?? 18;
|
|
58
|
+
const mount = version >= 18 ? "/var/lib/postgresql" : "/var/lib/postgresql/data";
|
|
59
|
+
const password = secret(`${id}-password`, { length: 32 });
|
|
60
|
+
const vol = volume(`${id}-data`);
|
|
61
|
+
const svc = service(id, {
|
|
62
|
+
image: `postgres:${version}`,
|
|
63
|
+
port: 5432,
|
|
64
|
+
volumes: [{ volume: vol, mount }],
|
|
65
|
+
env: {
|
|
66
|
+
POSTGRES_PASSWORD: password,
|
|
67
|
+
},
|
|
68
|
+
healthCheck: {
|
|
69
|
+
command: ["pg_isready", "-U", "postgres"],
|
|
70
|
+
interval: "5s",
|
|
71
|
+
timeout: "5s",
|
|
72
|
+
retries: 5,
|
|
73
|
+
startPeriod: "10s",
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
return {
|
|
77
|
+
service: svc,
|
|
78
|
+
volume: vol,
|
|
79
|
+
password,
|
|
80
|
+
host: svc.host,
|
|
81
|
+
port: 5432,
|
|
82
|
+
connectionUrl: interpolate `postgres://postgres:${password}@${svc.host}:5432/postgres`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return { volume, service, secret, site, postgres };
|
|
50
86
|
}
|
package/dist/swarm/index.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
export { createFactories } from "./factories.js";
|
|
2
|
-
export type { DriverVolumeOptions, StorageDriver, SwarmConfig, SwarmServiceConfig, SwarmVolumeConfig, } from "./types.js";
|
|
2
|
+
export type { DriverVolumeOptions, Postgres, PostgresConfig, StorageDriver, SwarmConfig, SwarmServiceConfig, SwarmVolumeConfig, } from "./types.js";
|
|
3
3
|
import type { Secret, SecretConfig, Service, Site, SiteConfig, Volume } from "../resource.js";
|
|
4
|
-
import type { StorageDriver, SwarmConfig, SwarmServiceConfig, SwarmVolumeConfig } from "./types.js";
|
|
4
|
+
import type { Postgres, PostgresConfig, StorageDriver, SwarmConfig, SwarmServiceConfig, SwarmVolumeConfig } from "./types.js";
|
|
5
5
|
export declare function swarm<D extends StorageDriver = "local">(config?: SwarmConfig<D>): {
|
|
6
6
|
volume: (id: string, config?: SwarmVolumeConfig<D>) => Volume;
|
|
7
7
|
service: (id: string, config: SwarmServiceConfig) => Service;
|
|
8
8
|
secret: (id: string, config?: SecretConfig) => Secret;
|
|
9
9
|
site: (id: string, config: SiteConfig) => Site;
|
|
10
|
+
postgres: (id: string, config?: PostgresConfig) => Postgres;
|
|
10
11
|
};
|
package/dist/swarm/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { HealthCheckConfig, ResourceLimits, ServiceConfig, VolumeConfig } from "../resource.js";
|
|
1
|
+
import type { HealthCheckConfig, Interpolation, ResourceLimits, Secret, Service, ServiceConfig, Volume, VolumeConfig } from "../resource.js";
|
|
2
2
|
export interface DriverVolumeOptions {
|
|
3
3
|
local: {
|
|
4
4
|
size?: string;
|
|
@@ -23,3 +23,14 @@ export interface SwarmServiceConfig extends ServiceConfig {
|
|
|
23
23
|
resources?: ResourceLimits;
|
|
24
24
|
restartPolicy?: "none" | "on-failure" | "any";
|
|
25
25
|
}
|
|
26
|
+
export interface PostgresConfig {
|
|
27
|
+
version?: number;
|
|
28
|
+
}
|
|
29
|
+
export interface Postgres {
|
|
30
|
+
service: Service;
|
|
31
|
+
volume: Volume;
|
|
32
|
+
password: Secret;
|
|
33
|
+
host: string;
|
|
34
|
+
port: number;
|
|
35
|
+
connectionUrl: Interpolation;
|
|
36
|
+
}
|
package/package.json
CHANGED
|
@@ -8,11 +8,12 @@ COPY apps/api/package.json ./apps/api/
|
|
|
8
8
|
RUN pnpm install --frozen-lockfile
|
|
9
9
|
|
|
10
10
|
FROM deps AS build
|
|
11
|
+
COPY tsconfig.json ./
|
|
11
12
|
COPY apps/api/ ./apps/api/
|
|
12
13
|
RUN pnpm --filter {{name}}-api build
|
|
13
14
|
|
|
14
15
|
FROM build AS deploy
|
|
15
|
-
RUN pnpm --filter {{name}}-api --prod deploy
|
|
16
|
+
RUN pnpm --filter {{name}}-api --prod deploy /prod/api
|
|
16
17
|
|
|
17
18
|
FROM base
|
|
18
19
|
WORKDIR /app
|