@geekmidas/cli 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/dist/{HostingerProvider-B9N-TKbp.mjs → HostingerProvider-402UdK89.mjs} +34 -1
- package/dist/HostingerProvider-402UdK89.mjs.map +1 -0
- package/dist/{HostingerProvider-DUV9-Tzg.cjs → HostingerProvider-BiXdHjiq.cjs} +34 -1
- package/dist/HostingerProvider-BiXdHjiq.cjs.map +1 -0
- package/dist/{Route53Provider-C8mS0zY6.mjs → Route53Provider-DbBo7Uz5.mjs} +53 -1
- package/dist/Route53Provider-DbBo7Uz5.mjs.map +1 -0
- package/dist/{Route53Provider-Bs7Arms9.cjs → Route53Provider-kfJ77LmL.cjs} +53 -1
- package/dist/Route53Provider-kfJ77LmL.cjs.map +1 -0
- package/dist/backup-provisioner-B5e-F6zX.cjs +164 -0
- package/dist/backup-provisioner-B5e-F6zX.cjs.map +1 -0
- package/dist/backup-provisioner-BIArpmTr.mjs +163 -0
- package/dist/backup-provisioner-BIArpmTr.mjs.map +1 -0
- package/dist/{config-ZQM1vBoz.cjs → config-6JHOwLCx.cjs} +30 -2
- package/dist/{config-ZQM1vBoz.cjs.map → config-6JHOwLCx.cjs.map} +1 -1
- package/dist/{config-DfCJ29PQ.mjs → config-DxASSNjr.mjs} +25 -3
- package/dist/{config-DfCJ29PQ.mjs.map → config-DxASSNjr.mjs.map} +1 -1
- package/dist/config.cjs +3 -2
- package/dist/config.d.cts +14 -2
- package/dist/config.d.cts.map +1 -1
- package/dist/config.d.mts +15 -3
- package/dist/config.d.mts.map +1 -1
- package/dist/config.mjs +3 -3
- package/dist/{dokploy-api-z0833e7r.mjs → dokploy-api-2ldYoN3i.mjs} +131 -1
- package/dist/dokploy-api-2ldYoN3i.mjs.map +1 -0
- package/dist/dokploy-api-C93pveuy.mjs +3 -0
- package/dist/dokploy-api-CbDh4o93.cjs +3 -0
- package/dist/{dokploy-api-CQvhV6Hd.cjs → dokploy-api-DLgvEQlr.cjs} +131 -1
- package/dist/dokploy-api-DLgvEQlr.cjs.map +1 -0
- package/dist/{index-C0SpUT9Y.d.mts → index-C-KxSGGK.d.mts} +133 -31
- package/dist/index-C-KxSGGK.d.mts.map +1 -0
- package/dist/{index-B58qjyBd.d.cts → index-Cyk2rTyj.d.cts} +132 -30
- package/dist/index-Cyk2rTyj.d.cts.map +1 -0
- package/dist/index.cjs +662 -152
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +626 -116
- package/dist/index.mjs.map +1 -1
- package/dist/{openapi-BcSjLfWq.mjs → openapi-BYlyAbH3.mjs} +6 -5
- package/dist/openapi-BYlyAbH3.mjs.map +1 -0
- package/dist/{openapi-D6Hcfov0.cjs → openapi-CnvwSRDU.cjs} +6 -5
- package/dist/openapi-CnvwSRDU.cjs.map +1 -0
- package/dist/openapi.cjs +3 -3
- package/dist/openapi.d.cts +1 -0
- package/dist/openapi.d.cts.map +1 -1
- package/dist/openapi.d.mts +2 -1
- package/dist/openapi.d.mts.map +1 -1
- package/dist/openapi.mjs +3 -3
- package/dist/{types-B9UZ7fOG.d.mts → types-CZg5iUgD.d.mts} +1 -1
- package/dist/{types-B9UZ7fOG.d.mts.map → types-CZg5iUgD.d.mts.map} +1 -1
- package/dist/workspace/index.cjs +1 -1
- package/dist/workspace/index.d.cts +1 -1
- package/dist/workspace/index.d.mts +2 -2
- package/dist/workspace/index.mjs +1 -1
- package/dist/{workspace-BW2iU37P.mjs → workspace-9IQIjwkQ.mjs} +20 -4
- package/dist/workspace-9IQIjwkQ.mjs.map +1 -0
- package/dist/{workspace-2Do2YcGZ.cjs → workspace-D2ocAlpl.cjs} +20 -4
- package/dist/workspace-D2ocAlpl.cjs.map +1 -0
- package/examples/cron-example.ts +6 -6
- package/examples/function-example.ts +1 -1
- package/package.json +6 -3
- package/src/config.ts +44 -0
- package/src/deploy/__tests__/backup-provisioner.spec.ts +428 -0
- package/src/deploy/__tests__/createDnsProvider.spec.ts +23 -0
- package/src/deploy/__tests__/env-resolver.spec.ts +1 -1
- package/src/deploy/__tests__/undeploy.spec.ts +758 -0
- package/src/deploy/backup-provisioner.ts +316 -0
- package/src/deploy/dns/DnsProvider.ts +39 -1
- package/src/deploy/dns/HostingerProvider.ts +74 -0
- package/src/deploy/dns/Route53Provider.ts +81 -0
- package/src/deploy/dns/index.ts +25 -0
- package/src/deploy/dokploy-api.ts +237 -0
- package/src/deploy/index.ts +71 -13
- package/src/deploy/state.ts +171 -0
- package/src/deploy/undeploy.ts +407 -0
- package/src/dev/__tests__/index.spec.ts +490 -0
- package/src/dev/index.ts +313 -18
- package/src/generators/FunctionGenerator.ts +1 -1
- package/src/generators/Generator.ts +4 -1
- package/src/init/__tests__/generators.spec.ts +167 -18
- package/src/init/__tests__/init.spec.ts +66 -3
- package/src/init/generators/auth.ts +6 -5
- package/src/init/generators/config.ts +49 -7
- package/src/init/generators/docker.ts +8 -8
- package/src/init/generators/index.ts +1 -0
- package/src/init/generators/models.ts +3 -5
- package/src/init/generators/package.ts +4 -0
- package/src/init/generators/test.ts +133 -0
- package/src/init/generators/ui.ts +13 -12
- package/src/init/generators/web.ts +9 -8
- package/src/init/index.ts +2 -0
- package/src/init/templates/api.ts +6 -6
- package/src/init/templates/minimal.ts +2 -2
- package/src/init/templates/worker.ts +2 -2
- package/src/init/versions.ts +3 -3
- package/src/openapi.ts +6 -2
- package/src/test/__tests__/__fixtures__/workspace.ts +104 -0
- package/src/test/__tests__/api.spec.ts +199 -0
- package/src/test/__tests__/auth.spec.ts +162 -0
- package/src/test/__tests__/index.spec.ts +323 -0
- package/src/test/__tests__/web.spec.ts +210 -0
- package/src/test/index.ts +165 -14
- package/src/workspace/__tests__/index.spec.ts +3 -0
- package/src/workspace/index.ts +4 -2
- package/src/workspace/schema.ts +26 -0
- package/src/workspace/types.ts +14 -37
- package/dist/HostingerProvider-B9N-TKbp.mjs.map +0 -1
- package/dist/HostingerProvider-DUV9-Tzg.cjs.map +0 -1
- package/dist/Route53Provider-Bs7Arms9.cjs.map +0 -1
- package/dist/Route53Provider-C8mS0zY6.mjs.map +0 -1
- package/dist/dokploy-api-CQvhV6Hd.cjs.map +0 -1
- package/dist/dokploy-api-CWc02yyg.cjs +0 -3
- package/dist/dokploy-api-DSJYNx88.mjs +0 -3
- package/dist/dokploy-api-z0833e7r.mjs.map +0 -1
- package/dist/index-B58qjyBd.d.cts.map +0 -1
- package/dist/index-C0SpUT9Y.d.mts.map +0 -1
- package/dist/openapi-BcSjLfWq.mjs.map +0 -1
- package/dist/openapi-D6Hcfov0.cjs.map +0 -1
- package/dist/workspace-2Do2YcGZ.cjs.map +0 -1
- package/dist/workspace-BW2iU37P.mjs.map +0 -1
package/examples/cron-example.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { c } from '@geekmidas/constructs/crons';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Example cron that generates a daily report at 9 AM UTC
|
|
5
5
|
*/
|
|
6
|
-
export const dailyReport =
|
|
6
|
+
export const dailyReport = c
|
|
7
7
|
.schedule('cron(0 9 * * ? *)')
|
|
8
8
|
.timeout(600000) // 10 minutes
|
|
9
|
-
.handle(async ({
|
|
9
|
+
.handle(async ({ logger }) => {
|
|
10
10
|
logger.info('Generating daily report');
|
|
11
11
|
|
|
12
12
|
const reportDate = new Date().toISOString().split('T')[0];
|
|
@@ -22,7 +22,7 @@ export const dailyReport = cron
|
|
|
22
22
|
],
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
-
logger.info('Daily report generated'
|
|
25
|
+
logger.info(reportData, 'Daily report generated');
|
|
26
26
|
|
|
27
27
|
return reportData;
|
|
28
28
|
});
|
|
@@ -30,10 +30,10 @@ export const dailyReport = cron
|
|
|
30
30
|
/**
|
|
31
31
|
* Example cron that runs every hour
|
|
32
32
|
*/
|
|
33
|
-
export const hourlyCleanup =
|
|
33
|
+
export const hourlyCleanup = c
|
|
34
34
|
.schedule('rate(1 hour)')
|
|
35
35
|
.timeout(300000) // 5 minutes
|
|
36
|
-
.handle(async ({
|
|
36
|
+
.handle(async ({ logger }) => {
|
|
37
37
|
logger.info('Running hourly cleanup');
|
|
38
38
|
|
|
39
39
|
// Cleanup logic here
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geekmidas/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "CLI tools for building Lambda handlers, server applications, and generating OpenAPI specs",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -40,7 +40,9 @@
|
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"@apidevtools/swagger-parser": "^10.1.0",
|
|
43
|
+
"@aws-sdk/client-iam": "~3.971.0",
|
|
43
44
|
"@aws-sdk/client-route-53": "~3.971.0",
|
|
45
|
+
"@aws-sdk/client-s3": "~3.971.0",
|
|
44
46
|
"@aws-sdk/client-ssm": "~3.971.0",
|
|
45
47
|
"@aws-sdk/credential-providers": "~3.971.0",
|
|
46
48
|
"chokidar": "~4.0.3",
|
|
@@ -53,9 +55,10 @@
|
|
|
53
55
|
"pg": "~8.17.1",
|
|
54
56
|
"prompts": "~2.4.2",
|
|
55
57
|
"tsx": "~4.20.3",
|
|
56
|
-
"
|
|
58
|
+
"yaml": "~2.8.2",
|
|
59
|
+
"@geekmidas/constructs": "~1.1.0",
|
|
60
|
+
"@geekmidas/envkit": "~1.0.2",
|
|
57
61
|
"@geekmidas/errors": "~1.0.0",
|
|
58
|
-
"@geekmidas/constructs": "~1.0.0",
|
|
59
62
|
"@geekmidas/logger": "~1.0.0",
|
|
60
63
|
"@geekmidas/schema": "~1.0.0"
|
|
61
64
|
},
|
package/src/config.ts
CHANGED
|
@@ -284,3 +284,47 @@ export async function loadAppConfig(
|
|
|
284
284
|
appRoot: join(workspaceRoot, app.path),
|
|
285
285
|
};
|
|
286
286
|
}
|
|
287
|
+
|
|
288
|
+
export interface WorkspaceAppInfo {
|
|
289
|
+
appName: string;
|
|
290
|
+
app: NormalizedAppConfig;
|
|
291
|
+
workspace: NormalizedWorkspace;
|
|
292
|
+
workspaceRoot: string;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Load workspace info for any app (frontend or backend).
|
|
297
|
+
* Unlike loadAppConfig, this does NOT require the app to have a gkm config
|
|
298
|
+
* (routes, entry, etc.), making it suitable for gkm exec/test from frontend apps.
|
|
299
|
+
*/
|
|
300
|
+
export async function loadWorkspaceAppInfo(
|
|
301
|
+
cwd: string = process.cwd(),
|
|
302
|
+
): Promise<WorkspaceAppInfo> {
|
|
303
|
+
const appName = getAppNameFromCwd(cwd);
|
|
304
|
+
|
|
305
|
+
if (!appName) {
|
|
306
|
+
throw new Error(
|
|
307
|
+
'Could not determine app name. Ensure package.json exists with a "name" field.',
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const { config, workspaceRoot } = await loadRawConfig(cwd);
|
|
312
|
+
const loadedConfig = processConfig(config, workspaceRoot);
|
|
313
|
+
|
|
314
|
+
const app = loadedConfig.workspace.apps[appName];
|
|
315
|
+
|
|
316
|
+
if (!app) {
|
|
317
|
+
const availableApps = Object.keys(loadedConfig.workspace.apps).join(', ');
|
|
318
|
+
throw new Error(
|
|
319
|
+
`App "${appName}" not found in workspace config. Available apps: ${availableApps}. ` +
|
|
320
|
+
`Ensure the package.json name matches the app key in gkm.config.ts.`,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
appName,
|
|
326
|
+
app,
|
|
327
|
+
workspace: loadedConfig.workspace,
|
|
328
|
+
workspaceRoot,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DeleteAccessKeyCommand,
|
|
3
|
+
DeleteUserCommand,
|
|
4
|
+
DeleteUserPolicyCommand,
|
|
5
|
+
GetUserCommand,
|
|
6
|
+
IAMClient,
|
|
7
|
+
ListAccessKeysCommand,
|
|
8
|
+
} from '@aws-sdk/client-iam';
|
|
9
|
+
import {
|
|
10
|
+
DeleteBucketCommand,
|
|
11
|
+
DeleteObjectsCommand,
|
|
12
|
+
ListObjectsV2Command,
|
|
13
|
+
S3Client,
|
|
14
|
+
} from '@aws-sdk/client-s3';
|
|
15
|
+
import { HttpResponse, http } from 'msw';
|
|
16
|
+
import { setupServer } from 'msw/node';
|
|
17
|
+
import {
|
|
18
|
+
afterAll,
|
|
19
|
+
afterEach,
|
|
20
|
+
beforeAll,
|
|
21
|
+
beforeEach,
|
|
22
|
+
describe,
|
|
23
|
+
expect,
|
|
24
|
+
it,
|
|
25
|
+
} from 'vitest';
|
|
26
|
+
import {
|
|
27
|
+
type ProvisionBackupOptions,
|
|
28
|
+
provisionBackupDestination,
|
|
29
|
+
} from '../backup-provisioner';
|
|
30
|
+
import { DokployApi } from '../dokploy-api';
|
|
31
|
+
import type { BackupState } from '../state';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Backup Provisioner Tests
|
|
35
|
+
*
|
|
36
|
+
* These tests require LocalStack to be running with S3 and IAM enabled.
|
|
37
|
+
* Run: docker compose up -d localstack
|
|
38
|
+
*/
|
|
39
|
+
describe('backup-provisioner', () => {
|
|
40
|
+
const LOCALSTACK_ENDPOINT = 'http://localhost:4566';
|
|
41
|
+
const DOKPLOY_BASE_URL = 'https://dokploy.example.com';
|
|
42
|
+
|
|
43
|
+
let s3Client: S3Client;
|
|
44
|
+
let iamClient: IAMClient;
|
|
45
|
+
let dokployApi: DokployApi;
|
|
46
|
+
|
|
47
|
+
const server = setupServer();
|
|
48
|
+
const logs: string[] = [];
|
|
49
|
+
const logger = { log: (msg: string) => logs.push(msg) };
|
|
50
|
+
|
|
51
|
+
// Track created resources for cleanup
|
|
52
|
+
const createdBuckets: string[] = [];
|
|
53
|
+
const createdUsers: string[] = [];
|
|
54
|
+
|
|
55
|
+
beforeAll(() => {
|
|
56
|
+
process.env.AWS_ACCESS_KEY_ID = 'test';
|
|
57
|
+
process.env.AWS_SECRET_ACCESS_KEY = 'test';
|
|
58
|
+
server.listen({ onUnhandledRequest: 'bypass' });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
logs.length = 0;
|
|
63
|
+
|
|
64
|
+
s3Client = new S3Client({
|
|
65
|
+
region: 'us-east-1',
|
|
66
|
+
endpoint: LOCALSTACK_ENDPOINT,
|
|
67
|
+
forcePathStyle: true,
|
|
68
|
+
credentials: {
|
|
69
|
+
accessKeyId: 'test',
|
|
70
|
+
secretAccessKey: 'test',
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
iamClient = new IAMClient({
|
|
75
|
+
region: 'us-east-1',
|
|
76
|
+
endpoint: LOCALSTACK_ENDPOINT,
|
|
77
|
+
credentials: {
|
|
78
|
+
accessKeyId: 'test',
|
|
79
|
+
secretAccessKey: 'test',
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
dokployApi = new DokployApi({
|
|
84
|
+
baseUrl: DOKPLOY_BASE_URL,
|
|
85
|
+
token: 'test-api-token',
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
afterEach(async () => {
|
|
90
|
+
server.resetHandlers();
|
|
91
|
+
|
|
92
|
+
// Cleanup created buckets
|
|
93
|
+
for (const bucket of createdBuckets) {
|
|
94
|
+
try {
|
|
95
|
+
// First delete all objects in the bucket
|
|
96
|
+
const objects = await s3Client.send(
|
|
97
|
+
new ListObjectsV2Command({ Bucket: bucket }),
|
|
98
|
+
);
|
|
99
|
+
if (objects.Contents?.length) {
|
|
100
|
+
await s3Client.send(
|
|
101
|
+
new DeleteObjectsCommand({
|
|
102
|
+
Bucket: bucket,
|
|
103
|
+
Delete: {
|
|
104
|
+
Objects: objects.Contents.map((o) => ({ Key: o.Key })),
|
|
105
|
+
},
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
await s3Client.send(new DeleteBucketCommand({ Bucket: bucket }));
|
|
110
|
+
} catch {
|
|
111
|
+
// Ignore cleanup errors
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
createdBuckets.length = 0;
|
|
115
|
+
|
|
116
|
+
// Cleanup created IAM users
|
|
117
|
+
for (const userName of createdUsers) {
|
|
118
|
+
try {
|
|
119
|
+
// Delete access keys first
|
|
120
|
+
const keys = await iamClient.send(
|
|
121
|
+
new ListAccessKeysCommand({ UserName: userName }),
|
|
122
|
+
);
|
|
123
|
+
for (const key of keys.AccessKeyMetadata ?? []) {
|
|
124
|
+
await iamClient.send(
|
|
125
|
+
new DeleteAccessKeyCommand({
|
|
126
|
+
UserName: userName,
|
|
127
|
+
AccessKeyId: key.AccessKeyId,
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
// Delete user policy
|
|
132
|
+
await iamClient.send(
|
|
133
|
+
new DeleteUserPolicyCommand({
|
|
134
|
+
UserName: userName,
|
|
135
|
+
PolicyName: 'DokployBackupAccess',
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
// Delete user
|
|
139
|
+
await iamClient.send(new DeleteUserCommand({ UserName: userName }));
|
|
140
|
+
} catch {
|
|
141
|
+
// Ignore cleanup errors
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
createdUsers.length = 0;
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
afterAll(() => {
|
|
148
|
+
server.close();
|
|
149
|
+
s3Client.destroy();
|
|
150
|
+
iamClient.destroy();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Helper to create provision options with LocalStack clients
|
|
155
|
+
*/
|
|
156
|
+
function createOptions(
|
|
157
|
+
overrides: Partial<ProvisionBackupOptions> = {},
|
|
158
|
+
): ProvisionBackupOptions {
|
|
159
|
+
return {
|
|
160
|
+
api: dokployApi,
|
|
161
|
+
projectId: 'proj_test123',
|
|
162
|
+
projectName: 'test-project',
|
|
163
|
+
stage: 'production',
|
|
164
|
+
config: {
|
|
165
|
+
type: 's3',
|
|
166
|
+
region: 'us-east-1',
|
|
167
|
+
},
|
|
168
|
+
logger,
|
|
169
|
+
awsEndpoint: LOCALSTACK_ENDPOINT,
|
|
170
|
+
...overrides,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Setup mock Dokploy API responses
|
|
176
|
+
*/
|
|
177
|
+
function setupDokployMocks(options: {
|
|
178
|
+
existingDestination?: { destinationId: string; name: string };
|
|
179
|
+
createDestinationId?: string;
|
|
180
|
+
}) {
|
|
181
|
+
const handlers = [
|
|
182
|
+
// List destinations
|
|
183
|
+
http.get(`${DOKPLOY_BASE_URL}/api/destination.all`, () => {
|
|
184
|
+
if (options.existingDestination) {
|
|
185
|
+
return HttpResponse.json([options.existingDestination]);
|
|
186
|
+
}
|
|
187
|
+
return HttpResponse.json([]);
|
|
188
|
+
}),
|
|
189
|
+
|
|
190
|
+
// Get destination
|
|
191
|
+
http.get(`${DOKPLOY_BASE_URL}/api/destination.one`, ({ request }) => {
|
|
192
|
+
const url = new URL(request.url);
|
|
193
|
+
const destId = url.searchParams.get('destinationId');
|
|
194
|
+
if (
|
|
195
|
+
options.existingDestination &&
|
|
196
|
+
destId === options.existingDestination.destinationId
|
|
197
|
+
) {
|
|
198
|
+
return HttpResponse.json(options.existingDestination);
|
|
199
|
+
}
|
|
200
|
+
return HttpResponse.json({ message: 'Not found' }, { status: 404 });
|
|
201
|
+
}),
|
|
202
|
+
|
|
203
|
+
// Create destination
|
|
204
|
+
http.post(`${DOKPLOY_BASE_URL}/api/destination.create`, () => {
|
|
205
|
+
return HttpResponse.json({
|
|
206
|
+
destinationId: options.createDestinationId ?? 'dest_new123',
|
|
207
|
+
name: 'test-project-production-s3',
|
|
208
|
+
});
|
|
209
|
+
}),
|
|
210
|
+
|
|
211
|
+
// Test destination connection
|
|
212
|
+
http.post(`${DOKPLOY_BASE_URL}/api/destination.testConnection`, () => {
|
|
213
|
+
return HttpResponse.json({ success: true });
|
|
214
|
+
}),
|
|
215
|
+
];
|
|
216
|
+
|
|
217
|
+
server.use(...handlers);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
describe('provisionBackupDestination', () => {
|
|
221
|
+
it('should create S3 bucket with unique name', async () => {
|
|
222
|
+
setupDokployMocks({ createDestinationId: 'dest_123' });
|
|
223
|
+
|
|
224
|
+
const options = createOptions();
|
|
225
|
+
const result = await provisionBackupDestination(options);
|
|
226
|
+
|
|
227
|
+
// Track for cleanup
|
|
228
|
+
createdBuckets.push(result.bucketName);
|
|
229
|
+
createdUsers.push(result.iamUserName);
|
|
230
|
+
|
|
231
|
+
expect(result.bucketName).toMatch(/^test-project-production-backups-/);
|
|
232
|
+
expect(result.bucketArn).toBe(`arn:aws:s3:::${result.bucketName}`);
|
|
233
|
+
expect(logs.some((l) => l.includes('Creating S3 bucket'))).toBe(true);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should create IAM user with correct name', async () => {
|
|
237
|
+
setupDokployMocks({ createDestinationId: 'dest_123' });
|
|
238
|
+
|
|
239
|
+
const options = createOptions();
|
|
240
|
+
const result = await provisionBackupDestination(options);
|
|
241
|
+
|
|
242
|
+
// Track for cleanup
|
|
243
|
+
createdBuckets.push(result.bucketName);
|
|
244
|
+
createdUsers.push(result.iamUserName);
|
|
245
|
+
|
|
246
|
+
expect(result.iamUserName).toBe('dokploy-backup-test-project-production');
|
|
247
|
+
|
|
248
|
+
// Verify user exists in IAM
|
|
249
|
+
const userResponse = await iamClient.send(
|
|
250
|
+
new GetUserCommand({ UserName: result.iamUserName }),
|
|
251
|
+
);
|
|
252
|
+
expect(userResponse.User?.UserName).toBe(result.iamUserName);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('should create IAM access key', async () => {
|
|
256
|
+
setupDokployMocks({ createDestinationId: 'dest_123' });
|
|
257
|
+
|
|
258
|
+
const options = createOptions();
|
|
259
|
+
const result = await provisionBackupDestination(options);
|
|
260
|
+
|
|
261
|
+
// Track for cleanup
|
|
262
|
+
createdBuckets.push(result.bucketName);
|
|
263
|
+
createdUsers.push(result.iamUserName);
|
|
264
|
+
|
|
265
|
+
expect(result.iamAccessKeyId).toBeDefined();
|
|
266
|
+
expect(result.iamSecretAccessKey).toBeDefined();
|
|
267
|
+
expect(result.iamAccessKeyId.length).toBeGreaterThan(0);
|
|
268
|
+
expect(result.iamSecretAccessKey.length).toBeGreaterThan(0);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('should create Dokploy destination', async () => {
|
|
272
|
+
setupDokployMocks({ createDestinationId: 'dest_created' });
|
|
273
|
+
|
|
274
|
+
const options = createOptions();
|
|
275
|
+
const result = await provisionBackupDestination(options);
|
|
276
|
+
|
|
277
|
+
// Track for cleanup
|
|
278
|
+
createdBuckets.push(result.bucketName);
|
|
279
|
+
createdUsers.push(result.iamUserName);
|
|
280
|
+
|
|
281
|
+
expect(result.destinationId).toBe('dest_created');
|
|
282
|
+
expect(logs.some((l) => l.includes('Dokploy destination created'))).toBe(
|
|
283
|
+
true,
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('should reuse existing state when destination still exists', async () => {
|
|
288
|
+
const existingState: BackupState = {
|
|
289
|
+
bucketName: 'existing-bucket',
|
|
290
|
+
bucketArn: 'arn:aws:s3:::existing-bucket',
|
|
291
|
+
iamUserName: 'existing-user',
|
|
292
|
+
iamAccessKeyId: 'AKIA123',
|
|
293
|
+
iamSecretAccessKey: 'secret123',
|
|
294
|
+
destinationId: 'dest_existing',
|
|
295
|
+
region: 'us-east-1',
|
|
296
|
+
createdAt: '2024-01-01T00:00:00.000Z',
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
setupDokployMocks({
|
|
300
|
+
existingDestination: {
|
|
301
|
+
destinationId: 'dest_existing',
|
|
302
|
+
name: 'existing',
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
const options = createOptions({ existingState });
|
|
307
|
+
const result = await provisionBackupDestination(options);
|
|
308
|
+
|
|
309
|
+
// Should return existing state without creating new resources
|
|
310
|
+
expect(result).toEqual(existingState);
|
|
311
|
+
expect(
|
|
312
|
+
logs.some((l) => l.includes('Using existing backup destination')),
|
|
313
|
+
).toBe(true);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('should recreate destination if existing one not found', async () => {
|
|
317
|
+
const existingState: BackupState = {
|
|
318
|
+
bucketName: 'existing-bucket',
|
|
319
|
+
bucketArn: 'arn:aws:s3:::existing-bucket',
|
|
320
|
+
iamUserName: 'dokploy-backup-test-project-production',
|
|
321
|
+
iamAccessKeyId: 'AKIA123',
|
|
322
|
+
iamSecretAccessKey: 'secret123',
|
|
323
|
+
destinationId: 'dest_deleted',
|
|
324
|
+
region: 'us-east-1',
|
|
325
|
+
createdAt: '2024-01-01T00:00:00.000Z',
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
// Mock: destination.one returns 404, meaning destination was deleted
|
|
329
|
+
setupDokployMocks({ createDestinationId: 'dest_new' });
|
|
330
|
+
|
|
331
|
+
const options = createOptions({ existingState });
|
|
332
|
+
const result = await provisionBackupDestination(options);
|
|
333
|
+
|
|
334
|
+
// Track for cleanup (uses existing bucket name from state)
|
|
335
|
+
createdBuckets.push(result.bucketName);
|
|
336
|
+
createdUsers.push(result.iamUserName);
|
|
337
|
+
|
|
338
|
+
// Should reuse bucket name from state
|
|
339
|
+
expect(result.bucketName).toBe('existing-bucket');
|
|
340
|
+
// Should reuse IAM user name
|
|
341
|
+
expect(result.iamUserName).toBe('dokploy-backup-test-project-production');
|
|
342
|
+
// Should reuse credentials from state
|
|
343
|
+
expect(result.iamAccessKeyId).toBe('AKIA123');
|
|
344
|
+
// Should create new destination
|
|
345
|
+
expect(result.destinationId).toBe('dest_new');
|
|
346
|
+
expect(
|
|
347
|
+
logs.some((l) => l.includes('Existing destination not found')),
|
|
348
|
+
).toBe(true);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('should sanitize project name for AWS resources', async () => {
|
|
352
|
+
setupDokployMocks({ createDestinationId: 'dest_123' });
|
|
353
|
+
|
|
354
|
+
const options = createOptions({
|
|
355
|
+
projectName: 'My Project_Name!@#',
|
|
356
|
+
});
|
|
357
|
+
const result = await provisionBackupDestination(options);
|
|
358
|
+
|
|
359
|
+
// Track for cleanup
|
|
360
|
+
createdBuckets.push(result.bucketName);
|
|
361
|
+
createdUsers.push(result.iamUserName);
|
|
362
|
+
|
|
363
|
+
// Should be lowercase with only alphanumeric and hyphens
|
|
364
|
+
// 'My Project_Name!@#' -> 'my-project-name----' (space, underscore, !, @, # all become hyphens)
|
|
365
|
+
expect(result.bucketName).toMatch(
|
|
366
|
+
/^my-project-name----production-backups-/,
|
|
367
|
+
);
|
|
368
|
+
expect(result.iamUserName).toBe(
|
|
369
|
+
'dokploy-backup-my-project-name----production',
|
|
370
|
+
);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('should return complete BackupState', async () => {
|
|
374
|
+
setupDokployMocks({ createDestinationId: 'dest_complete' });
|
|
375
|
+
|
|
376
|
+
const options = createOptions();
|
|
377
|
+
const result = await provisionBackupDestination(options);
|
|
378
|
+
|
|
379
|
+
// Track for cleanup
|
|
380
|
+
createdBuckets.push(result.bucketName);
|
|
381
|
+
createdUsers.push(result.iamUserName);
|
|
382
|
+
|
|
383
|
+
expect(result).toMatchObject({
|
|
384
|
+
bucketName: expect.stringMatching(/^test-project-production-backups-/),
|
|
385
|
+
bucketArn: expect.stringMatching(/^arn:aws:s3:::/),
|
|
386
|
+
iamUserName: 'dokploy-backup-test-project-production',
|
|
387
|
+
iamAccessKeyId: expect.any(String),
|
|
388
|
+
iamSecretAccessKey: expect.any(String),
|
|
389
|
+
destinationId: 'dest_complete',
|
|
390
|
+
region: 'us-east-1',
|
|
391
|
+
createdAt: expect.any(String),
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('should handle connection test failure gracefully', async () => {
|
|
396
|
+
server.use(
|
|
397
|
+
http.get(`${DOKPLOY_BASE_URL}/api/destination.all`, () => {
|
|
398
|
+
return HttpResponse.json([]);
|
|
399
|
+
}),
|
|
400
|
+
http.post(`${DOKPLOY_BASE_URL}/api/destination.create`, () => {
|
|
401
|
+
return HttpResponse.json({
|
|
402
|
+
destinationId: 'dest_123',
|
|
403
|
+
name: 'test',
|
|
404
|
+
});
|
|
405
|
+
}),
|
|
406
|
+
http.post(`${DOKPLOY_BASE_URL}/api/destination.testConnection`, () => {
|
|
407
|
+
return HttpResponse.json(
|
|
408
|
+
{ message: 'Connection failed' },
|
|
409
|
+
{ status: 500 },
|
|
410
|
+
);
|
|
411
|
+
}),
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
const options = createOptions();
|
|
415
|
+
const result = await provisionBackupDestination(options);
|
|
416
|
+
|
|
417
|
+
// Track for cleanup
|
|
418
|
+
createdBuckets.push(result.bucketName);
|
|
419
|
+
createdUsers.push(result.iamUserName);
|
|
420
|
+
|
|
421
|
+
// Should still succeed but log warning
|
|
422
|
+
expect(result.destinationId).toBe('dest_123');
|
|
423
|
+
expect(
|
|
424
|
+
logs.some((l) => l.includes('Warning: Could not verify destination')),
|
|
425
|
+
).toBe(true);
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import {
|
|
3
3
|
createDnsProvider,
|
|
4
|
+
type DeleteDnsRecord,
|
|
5
|
+
type DeleteResult,
|
|
4
6
|
type DnsProvider,
|
|
5
7
|
type DnsRecord,
|
|
6
8
|
isDnsProvider,
|
|
@@ -14,6 +16,7 @@ describe('isDnsProvider', () => {
|
|
|
14
16
|
name: 'test',
|
|
15
17
|
getRecords: async () => [],
|
|
16
18
|
upsertRecords: async () => [],
|
|
19
|
+
deleteRecords: async () => [],
|
|
17
20
|
};
|
|
18
21
|
expect(isDnsProvider(provider)).toBe(true);
|
|
19
22
|
});
|
|
@@ -126,6 +129,16 @@ describe('createDnsProvider', () => {
|
|
|
126
129
|
async upsertRecords(): Promise<UpsertResult[]> {
|
|
127
130
|
return [];
|
|
128
131
|
},
|
|
132
|
+
async deleteRecords(
|
|
133
|
+
_domain: string,
|
|
134
|
+
records: DeleteDnsRecord[],
|
|
135
|
+
): Promise<DeleteResult[]> {
|
|
136
|
+
return records.map((r) => ({
|
|
137
|
+
record: r,
|
|
138
|
+
deleted: true,
|
|
139
|
+
notFound: false,
|
|
140
|
+
}));
|
|
141
|
+
},
|
|
129
142
|
};
|
|
130
143
|
|
|
131
144
|
const provider = await createDnsProvider({
|
|
@@ -157,6 +170,16 @@ describe('createDnsProvider', () => {
|
|
|
157
170
|
unchanged: false,
|
|
158
171
|
}));
|
|
159
172
|
},
|
|
173
|
+
async deleteRecords(
|
|
174
|
+
_domain: string,
|
|
175
|
+
records: DeleteDnsRecord[],
|
|
176
|
+
): Promise<DeleteResult[]> {
|
|
177
|
+
return records.map((r) => ({
|
|
178
|
+
record: r,
|
|
179
|
+
deleted: true,
|
|
180
|
+
notFound: false,
|
|
181
|
+
}));
|
|
182
|
+
},
|
|
160
183
|
};
|
|
161
184
|
|
|
162
185
|
const provider = await createDnsProvider({
|
|
@@ -843,7 +843,7 @@ describe('Docker build arg extraction', () => {
|
|
|
843
843
|
// DATABASE_URL is missing (no postgres config)
|
|
844
844
|
expect(missing).toContain('DATABASE_URL');
|
|
845
845
|
|
|
846
|
-
const {
|
|
846
|
+
const { publicUrlArgNames } = extractBuildArgs(resolved);
|
|
847
847
|
|
|
848
848
|
// Only NEXT_PUBLIC_* should be build args
|
|
849
849
|
expect(publicUrlArgNames).toHaveLength(3);
|