@geekmidas/cli 1.5.0 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -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-BYn5yUt5.cjs} +2 -2
- package/dist/{config-ZQM1vBoz.cjs.map → config-BYn5yUt5.cjs.map} +1 -1
- package/dist/{config-DfCJ29PQ.mjs → config-dLNQIvDR.mjs} +2 -2
- package/dist/{config-DfCJ29PQ.mjs.map → config-dLNQIvDR.mjs.map} +1 -1
- package/dist/config.cjs +2 -2
- package/dist/config.d.cts +1 -1
- package/dist/config.d.mts +2 -2
- package/dist/config.mjs +2 -2
- 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-B58qjyBd.d.cts → index-Ba21_lNt.d.cts} +131 -29
- package/dist/index-Ba21_lNt.d.cts.map +1 -0
- package/dist/{index-C0SpUT9Y.d.mts → index-Bj5VNxEL.d.mts} +132 -30
- package/dist/index-Bj5VNxEL.d.mts.map +1 -0
- package/dist/index.cjs +119 -25
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +119 -25
- package/dist/index.mjs.map +1 -1
- package/dist/{openapi-BcSjLfWq.mjs → openapi-CMTyaIJJ.mjs} +2 -2
- package/dist/{openapi-BcSjLfWq.mjs.map → openapi-CMTyaIJJ.mjs.map} +1 -1
- package/dist/{openapi-D6Hcfov0.cjs → openapi-CqblwJZ4.cjs} +2 -2
- package/dist/{openapi-D6Hcfov0.cjs.map → openapi-CqblwJZ4.cjs.map} +1 -1
- package/dist/openapi.cjs +3 -3
- package/dist/openapi.d.mts +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-2Do2YcGZ.cjs → workspace-DIMnYaYt.cjs} +16 -2
- package/dist/{workspace-2Do2YcGZ.cjs.map → workspace-DIMnYaYt.cjs.map} +1 -1
- package/dist/{workspace-BW2iU37P.mjs → workspace-Dy8k7Wru.mjs} +16 -2
- package/dist/{workspace-BW2iU37P.mjs.map → workspace-Dy8k7Wru.mjs.map} +1 -1
- package/examples/cron-example.ts +6 -6
- package/examples/function-example.ts +1 -1
- package/package.json +7 -5
- 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/generators/FunctionGenerator.ts +1 -1
- package/src/init/versions.ts +2 -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
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backup Destination Provisioner
|
|
3
|
+
*
|
|
4
|
+
* Creates AWS resources (S3 bucket, IAM user, access keys) and configures
|
|
5
|
+
* Dokploy backup destinations for database backups.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
CreateAccessKeyCommand,
|
|
10
|
+
CreateUserCommand,
|
|
11
|
+
GetUserCommand,
|
|
12
|
+
IAMClient,
|
|
13
|
+
type IAMClientConfig,
|
|
14
|
+
PutUserPolicyCommand,
|
|
15
|
+
} from '@aws-sdk/client-iam';
|
|
16
|
+
import {
|
|
17
|
+
type BucketLocationConstraint,
|
|
18
|
+
CreateBucketCommand,
|
|
19
|
+
HeadBucketCommand,
|
|
20
|
+
PutBucketVersioningCommand,
|
|
21
|
+
S3Client,
|
|
22
|
+
type S3ClientConfig,
|
|
23
|
+
} from '@aws-sdk/client-s3';
|
|
24
|
+
import type { BackupsConfig } from '../workspace/types.js';
|
|
25
|
+
import type { DokployApi } from './dokploy-api.js';
|
|
26
|
+
import type { BackupState } from './state.js';
|
|
27
|
+
|
|
28
|
+
export interface ProvisionBackupOptions {
|
|
29
|
+
/** Dokploy API client */
|
|
30
|
+
api: DokployApi;
|
|
31
|
+
/** Dokploy project ID */
|
|
32
|
+
projectId: string;
|
|
33
|
+
/** Workspace name (used for resource naming) */
|
|
34
|
+
projectName: string;
|
|
35
|
+
/** Deploy stage (e.g., 'production', 'staging') */
|
|
36
|
+
stage: string;
|
|
37
|
+
/** Backup configuration */
|
|
38
|
+
config: BackupsConfig;
|
|
39
|
+
/** Existing backup state (if any) */
|
|
40
|
+
existingState?: BackupState;
|
|
41
|
+
/** Logger for progress output */
|
|
42
|
+
logger: { log: (msg: string) => void };
|
|
43
|
+
/** AWS endpoint override (for testing with LocalStack) */
|
|
44
|
+
awsEndpoint?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Generate a random suffix for unique resource names
|
|
49
|
+
*/
|
|
50
|
+
function randomSuffix(): string {
|
|
51
|
+
return Math.random().toString(36).substring(2, 8);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Sanitize a name for AWS resources (lowercase alphanumeric and hyphens)
|
|
56
|
+
*/
|
|
57
|
+
function sanitizeName(name: string): string {
|
|
58
|
+
return name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Create AWS clients with optional profile credentials
|
|
63
|
+
*/
|
|
64
|
+
async function createAwsClients(
|
|
65
|
+
region: string,
|
|
66
|
+
profile?: string,
|
|
67
|
+
endpoint?: string,
|
|
68
|
+
): Promise<{ s3: S3Client; iam: IAMClient }> {
|
|
69
|
+
const config: S3ClientConfig & IAMClientConfig = { region };
|
|
70
|
+
|
|
71
|
+
if (profile) {
|
|
72
|
+
const { fromIni } = await import('@aws-sdk/credential-providers');
|
|
73
|
+
config.credentials = fromIni({ profile });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Support custom endpoint for testing (e.g., LocalStack)
|
|
77
|
+
if (endpoint) {
|
|
78
|
+
config.endpoint = endpoint;
|
|
79
|
+
(config as S3ClientConfig).forcePathStyle = true;
|
|
80
|
+
// Use test credentials when endpoint is specified
|
|
81
|
+
config.credentials = {
|
|
82
|
+
accessKeyId: 'test',
|
|
83
|
+
secretAccessKey: 'test',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
s3: new S3Client(config),
|
|
89
|
+
iam: new IAMClient(config),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Check if an S3 bucket exists
|
|
95
|
+
*/
|
|
96
|
+
async function bucketExists(
|
|
97
|
+
s3: S3Client,
|
|
98
|
+
bucketName: string,
|
|
99
|
+
): Promise<boolean> {
|
|
100
|
+
try {
|
|
101
|
+
await s3.send(new HeadBucketCommand({ Bucket: bucketName }));
|
|
102
|
+
return true;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if ((error as { name?: string }).name === 'NotFound') {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
// 403 means bucket exists but we don't have access
|
|
108
|
+
if (
|
|
109
|
+
(error as { $metadata?: { httpStatusCode?: number } }).$metadata
|
|
110
|
+
?.httpStatusCode === 403
|
|
111
|
+
) {
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Check if an IAM user exists
|
|
120
|
+
*/
|
|
121
|
+
async function userExists(iam: IAMClient, userName: string): Promise<boolean> {
|
|
122
|
+
try {
|
|
123
|
+
await iam.send(new GetUserCommand({ UserName: userName }));
|
|
124
|
+
return true;
|
|
125
|
+
} catch (error) {
|
|
126
|
+
const errorName = (error as { name?: string }).name;
|
|
127
|
+
// AWS returns 'NoSuchEntity', LocalStack returns 'NoSuchEntityException'
|
|
128
|
+
if (errorName === 'NoSuchEntity' || errorName === 'NoSuchEntityException') {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Provision backup destination for a deployment.
|
|
137
|
+
*
|
|
138
|
+
* Creates AWS resources (S3 bucket, IAM user) and Dokploy destination if needed.
|
|
139
|
+
* Reuses existing resources from state when possible.
|
|
140
|
+
*/
|
|
141
|
+
export async function provisionBackupDestination(
|
|
142
|
+
options: ProvisionBackupOptions,
|
|
143
|
+
): Promise<BackupState> {
|
|
144
|
+
const {
|
|
145
|
+
api,
|
|
146
|
+
projectName,
|
|
147
|
+
stage,
|
|
148
|
+
config,
|
|
149
|
+
existingState,
|
|
150
|
+
logger,
|
|
151
|
+
awsEndpoint,
|
|
152
|
+
} = options;
|
|
153
|
+
|
|
154
|
+
// If we have existing state, verify the Dokploy destination still exists
|
|
155
|
+
if (existingState?.destinationId) {
|
|
156
|
+
try {
|
|
157
|
+
await api.getDestination(existingState.destinationId);
|
|
158
|
+
logger.log(' Using existing backup destination');
|
|
159
|
+
return existingState;
|
|
160
|
+
} catch {
|
|
161
|
+
logger.log(' Existing destination not found, recreating...');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Create AWS clients
|
|
166
|
+
const aws = await createAwsClients(
|
|
167
|
+
config.region,
|
|
168
|
+
config.profile,
|
|
169
|
+
awsEndpoint,
|
|
170
|
+
);
|
|
171
|
+
const sanitizedProject = sanitizeName(projectName);
|
|
172
|
+
|
|
173
|
+
// 1. Create or verify S3 bucket
|
|
174
|
+
const bucketName =
|
|
175
|
+
existingState?.bucketName ??
|
|
176
|
+
`${sanitizedProject}-${stage}-backups-${randomSuffix()}`;
|
|
177
|
+
|
|
178
|
+
const bucketAlreadyExists = await bucketExists(aws.s3, bucketName);
|
|
179
|
+
if (!bucketAlreadyExists) {
|
|
180
|
+
logger.log(` Creating S3 bucket: ${bucketName}`);
|
|
181
|
+
|
|
182
|
+
// CreateBucket needs LocationConstraint for non-us-east-1 regions
|
|
183
|
+
const createBucketParams: {
|
|
184
|
+
Bucket: string;
|
|
185
|
+
CreateBucketConfiguration?: {
|
|
186
|
+
LocationConstraint: BucketLocationConstraint;
|
|
187
|
+
};
|
|
188
|
+
} = {
|
|
189
|
+
Bucket: bucketName,
|
|
190
|
+
};
|
|
191
|
+
if (config.region !== 'us-east-1') {
|
|
192
|
+
createBucketParams.CreateBucketConfiguration = {
|
|
193
|
+
LocationConstraint: config.region as BucketLocationConstraint,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
await aws.s3.send(new CreateBucketCommand(createBucketParams));
|
|
198
|
+
|
|
199
|
+
// Enable versioning for backup integrity
|
|
200
|
+
await aws.s3.send(
|
|
201
|
+
new PutBucketVersioningCommand({
|
|
202
|
+
Bucket: bucketName,
|
|
203
|
+
VersioningConfiguration: { Status: 'Enabled' },
|
|
204
|
+
}),
|
|
205
|
+
);
|
|
206
|
+
} else {
|
|
207
|
+
logger.log(` Using existing S3 bucket: ${bucketName}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// 2. Create or verify IAM user
|
|
211
|
+
const iamUserName =
|
|
212
|
+
existingState?.iamUserName ?? `dokploy-backup-${sanitizedProject}-${stage}`;
|
|
213
|
+
|
|
214
|
+
const iamUserAlreadyExists = await userExists(aws.iam, iamUserName);
|
|
215
|
+
if (!iamUserAlreadyExists) {
|
|
216
|
+
logger.log(` Creating IAM user: ${iamUserName}`);
|
|
217
|
+
await aws.iam.send(new CreateUserCommand({ UserName: iamUserName }));
|
|
218
|
+
} else {
|
|
219
|
+
logger.log(` Using existing IAM user: ${iamUserName}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 3. Attach bucket policy to IAM user
|
|
223
|
+
const policyDocument = {
|
|
224
|
+
Version: '2012-10-17',
|
|
225
|
+
Statement: [
|
|
226
|
+
{
|
|
227
|
+
Effect: 'Allow',
|
|
228
|
+
Action: [
|
|
229
|
+
's3:GetObject',
|
|
230
|
+
's3:PutObject',
|
|
231
|
+
's3:DeleteObject',
|
|
232
|
+
's3:ListBucket',
|
|
233
|
+
's3:GetBucketLocation',
|
|
234
|
+
],
|
|
235
|
+
Resource: [
|
|
236
|
+
`arn:aws:s3:::${bucketName}`,
|
|
237
|
+
`arn:aws:s3:::${bucketName}/*`,
|
|
238
|
+
],
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
logger.log(' Updating IAM policy');
|
|
244
|
+
await aws.iam.send(
|
|
245
|
+
new PutUserPolicyCommand({
|
|
246
|
+
UserName: iamUserName,
|
|
247
|
+
PolicyName: 'DokployBackupAccess',
|
|
248
|
+
PolicyDocument: JSON.stringify(policyDocument),
|
|
249
|
+
}),
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
// 4. Create access key (or reuse existing if state has it and destination needs recreation)
|
|
253
|
+
let accessKeyId: string;
|
|
254
|
+
let secretAccessKey: string;
|
|
255
|
+
|
|
256
|
+
if (existingState?.iamAccessKeyId && existingState?.iamSecretAccessKey) {
|
|
257
|
+
// Reuse existing credentials
|
|
258
|
+
logger.log(' Using existing IAM access key');
|
|
259
|
+
accessKeyId = existingState.iamAccessKeyId;
|
|
260
|
+
secretAccessKey = existingState.iamSecretAccessKey;
|
|
261
|
+
} else {
|
|
262
|
+
// Create new access key
|
|
263
|
+
logger.log(' Creating IAM access key');
|
|
264
|
+
const accessKeyResult = await aws.iam.send(
|
|
265
|
+
new CreateAccessKeyCommand({ UserName: iamUserName }),
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
if (!accessKeyResult.AccessKey) {
|
|
269
|
+
throw new Error('Failed to create IAM access key');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
accessKeyId = accessKeyResult.AccessKey.AccessKeyId!;
|
|
273
|
+
secretAccessKey = accessKeyResult.AccessKey.SecretAccessKey!;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// 5. Create Dokploy destination
|
|
277
|
+
const destinationName = `${sanitizedProject}-${stage}-s3`;
|
|
278
|
+
logger.log(` Creating Dokploy destination: ${destinationName}`);
|
|
279
|
+
|
|
280
|
+
const { destination, created } = await api.findOrCreateDestination(
|
|
281
|
+
destinationName,
|
|
282
|
+
{
|
|
283
|
+
accessKey: accessKeyId,
|
|
284
|
+
secretAccessKey: secretAccessKey,
|
|
285
|
+
bucket: bucketName,
|
|
286
|
+
region: config.region,
|
|
287
|
+
},
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
if (created) {
|
|
291
|
+
logger.log(' ✓ Dokploy destination created');
|
|
292
|
+
} else {
|
|
293
|
+
logger.log(' ✓ Using existing Dokploy destination');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// 6. Test connection
|
|
297
|
+
try {
|
|
298
|
+
await api.testDestinationConnection(destination.destinationId);
|
|
299
|
+
logger.log(' ✓ Destination connection verified');
|
|
300
|
+
} catch (error) {
|
|
301
|
+
logger.log(
|
|
302
|
+
` ⚠ Warning: Could not verify destination connection: ${error}`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
bucketName,
|
|
308
|
+
bucketArn: `arn:aws:s3:::${bucketName}`,
|
|
309
|
+
iamUserName,
|
|
310
|
+
iamAccessKeyId: accessKeyId,
|
|
311
|
+
iamSecretAccessKey: secretAccessKey,
|
|
312
|
+
destinationId: destination.destinationId,
|
|
313
|
+
region: config.region,
|
|
314
|
+
createdAt: existingState?.createdAt ?? new Date().toISOString(),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
@@ -48,12 +48,37 @@ export type UpsertResult = z.infer<typeof UpsertResultSchema>;
|
|
|
48
48
|
// DNS Provider Interface
|
|
49
49
|
// =============================================================================
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* A record to delete from DNS.
|
|
53
|
+
*/
|
|
54
|
+
export interface DeleteDnsRecord {
|
|
55
|
+
/** Record name/subdomain (e.g., 'api' or '@' for root) */
|
|
56
|
+
name: string;
|
|
57
|
+
/** Record type (A, CNAME, etc.) */
|
|
58
|
+
type: DnsRecordType;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Result of a delete operation.
|
|
63
|
+
*/
|
|
64
|
+
export interface DeleteResult {
|
|
65
|
+
/** The record that was requested for deletion */
|
|
66
|
+
record: DeleteDnsRecord;
|
|
67
|
+
/** Whether the record was deleted */
|
|
68
|
+
deleted: boolean;
|
|
69
|
+
/** Whether the record was not found (already deleted) */
|
|
70
|
+
notFound: boolean;
|
|
71
|
+
/** Error message if deletion failed */
|
|
72
|
+
error?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
51
75
|
/**
|
|
52
76
|
* Interface for DNS providers.
|
|
53
77
|
*
|
|
54
78
|
* Implementations must handle:
|
|
55
79
|
* - Getting all records for a domain
|
|
56
80
|
* - Creating or updating records for a domain
|
|
81
|
+
* - Deleting records from a domain
|
|
57
82
|
*/
|
|
58
83
|
export interface DnsProvider {
|
|
59
84
|
/** Provider name for logging */
|
|
@@ -78,6 +103,18 @@ export interface DnsProvider {
|
|
|
78
103
|
domain: string,
|
|
79
104
|
records: UpsertDnsRecord[],
|
|
80
105
|
): Promise<UpsertResult[]>;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Delete DNS records.
|
|
109
|
+
*
|
|
110
|
+
* @param domain - Root domain (e.g., 'example.com')
|
|
111
|
+
* @param records - Records to delete
|
|
112
|
+
* @returns Results of the delete operations
|
|
113
|
+
*/
|
|
114
|
+
deleteRecords(
|
|
115
|
+
domain: string,
|
|
116
|
+
records: DeleteDnsRecord[],
|
|
117
|
+
): Promise<DeleteResult[]>;
|
|
81
118
|
}
|
|
82
119
|
|
|
83
120
|
// =============================================================================
|
|
@@ -105,7 +142,8 @@ export function isDnsProvider(value: unknown): value is DnsProvider {
|
|
|
105
142
|
value !== null &&
|
|
106
143
|
typeof (value as DnsProvider).name === 'string' &&
|
|
107
144
|
typeof (value as DnsProvider).getRecords === 'function' &&
|
|
108
|
-
typeof (value as DnsProvider).upsertRecords === 'function'
|
|
145
|
+
typeof (value as DnsProvider).upsertRecords === 'function' &&
|
|
146
|
+
typeof (value as DnsProvider).deleteRecords === 'function'
|
|
109
147
|
);
|
|
110
148
|
}
|
|
111
149
|
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
import { getHostingerToken } from '../../auth/credentials';
|
|
8
8
|
import type {
|
|
9
|
+
DeleteDnsRecord,
|
|
10
|
+
DeleteResult,
|
|
9
11
|
DnsProvider,
|
|
10
12
|
DnsRecord,
|
|
11
13
|
UpsertDnsRecord,
|
|
@@ -97,4 +99,76 @@ export class HostingerProvider implements DnsProvider {
|
|
|
97
99
|
|
|
98
100
|
return results;
|
|
99
101
|
}
|
|
102
|
+
|
|
103
|
+
async deleteRecords(
|
|
104
|
+
domain: string,
|
|
105
|
+
records: DeleteDnsRecord[],
|
|
106
|
+
): Promise<DeleteResult[]> {
|
|
107
|
+
const api = await this.getApi();
|
|
108
|
+
const results: DeleteResult[] = [];
|
|
109
|
+
|
|
110
|
+
// Get existing records to check what exists
|
|
111
|
+
const existingRecords = await api.getRecords(domain);
|
|
112
|
+
|
|
113
|
+
// Filter to only records that exist
|
|
114
|
+
const recordsToDelete = records.filter((record) =>
|
|
115
|
+
existingRecords.some(
|
|
116
|
+
(r) => r.name === record.name && r.type === record.type,
|
|
117
|
+
),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// Delete existing records
|
|
121
|
+
if (recordsToDelete.length > 0) {
|
|
122
|
+
try {
|
|
123
|
+
await api.deleteRecords(
|
|
124
|
+
domain,
|
|
125
|
+
recordsToDelete.map((r) => ({
|
|
126
|
+
name: r.name,
|
|
127
|
+
type: r.type as
|
|
128
|
+
| 'A'
|
|
129
|
+
| 'AAAA'
|
|
130
|
+
| 'CNAME'
|
|
131
|
+
| 'MX'
|
|
132
|
+
| 'TXT'
|
|
133
|
+
| 'SRV'
|
|
134
|
+
| 'CAA',
|
|
135
|
+
})),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
for (const record of recordsToDelete) {
|
|
139
|
+
results.push({
|
|
140
|
+
record,
|
|
141
|
+
deleted: true,
|
|
142
|
+
notFound: false,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
} catch (error) {
|
|
146
|
+
// If batch delete fails, report error for all records
|
|
147
|
+
for (const record of recordsToDelete) {
|
|
148
|
+
results.push({
|
|
149
|
+
record,
|
|
150
|
+
deleted: false,
|
|
151
|
+
notFound: false,
|
|
152
|
+
error: String(error),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Mark non-existent records as not found
|
|
159
|
+
for (const record of records) {
|
|
160
|
+
const existing = existingRecords.find(
|
|
161
|
+
(r) => r.name === record.name && r.type === record.type,
|
|
162
|
+
);
|
|
163
|
+
if (!existing) {
|
|
164
|
+
results.push({
|
|
165
|
+
record,
|
|
166
|
+
deleted: false,
|
|
167
|
+
notFound: true,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return results;
|
|
173
|
+
}
|
|
100
174
|
}
|
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
} from '@aws-sdk/client-route-53';
|
|
14
14
|
import { fromIni } from '@aws-sdk/credential-providers';
|
|
15
15
|
import type {
|
|
16
|
+
DeleteDnsRecord,
|
|
17
|
+
DeleteResult,
|
|
16
18
|
DnsProvider,
|
|
17
19
|
DnsRecord,
|
|
18
20
|
DnsRecordType,
|
|
@@ -256,4 +258,83 @@ export class Route53Provider implements DnsProvider {
|
|
|
256
258
|
|
|
257
259
|
return results;
|
|
258
260
|
}
|
|
261
|
+
|
|
262
|
+
async deleteRecords(
|
|
263
|
+
domain: string,
|
|
264
|
+
records: DeleteDnsRecord[],
|
|
265
|
+
): Promise<DeleteResult[]> {
|
|
266
|
+
const zoneId = await this.getHostedZoneId(domain);
|
|
267
|
+
const results: DeleteResult[] = [];
|
|
268
|
+
|
|
269
|
+
// Get existing records to find the ones to delete
|
|
270
|
+
const existingRecords = await this.getRecords(domain);
|
|
271
|
+
|
|
272
|
+
// Process records in batches (Route53 allows max 1000 changes per request)
|
|
273
|
+
const batchSize = 100;
|
|
274
|
+
for (let i = 0; i < records.length; i += batchSize) {
|
|
275
|
+
const batch = records.slice(i, i + batchSize);
|
|
276
|
+
const changes = [];
|
|
277
|
+
|
|
278
|
+
for (const record of batch) {
|
|
279
|
+
const existing = existingRecords.find(
|
|
280
|
+
(r) => r.name === record.name && r.type === record.type,
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
if (!existing) {
|
|
284
|
+
// Record doesn't exist - already deleted
|
|
285
|
+
results.push({
|
|
286
|
+
record,
|
|
287
|
+
deleted: false,
|
|
288
|
+
notFound: true,
|
|
289
|
+
});
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Build full record name
|
|
294
|
+
const recordName =
|
|
295
|
+
record.name === '@' ? domain : `${record.name}.${domain}`;
|
|
296
|
+
|
|
297
|
+
changes.push({
|
|
298
|
+
Action: 'DELETE' as const,
|
|
299
|
+
ResourceRecordSet: {
|
|
300
|
+
Name: recordName,
|
|
301
|
+
Type: record.type,
|
|
302
|
+
TTL: existing.ttl,
|
|
303
|
+
ResourceRecords: existing.values.map((v) => ({ Value: v })),
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
results.push({
|
|
308
|
+
record,
|
|
309
|
+
deleted: true,
|
|
310
|
+
notFound: false,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Execute batch if there are changes
|
|
315
|
+
if (changes.length > 0) {
|
|
316
|
+
try {
|
|
317
|
+
const command = new ChangeResourceRecordSetsCommand({
|
|
318
|
+
HostedZoneId: zoneId,
|
|
319
|
+
ChangeBatch: {
|
|
320
|
+
Comment: 'Delete by gkm undeploy',
|
|
321
|
+
Changes: changes,
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
await this.client.send(command);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
// Mark all records in this batch as failed
|
|
328
|
+
for (const result of results.slice(-changes.length)) {
|
|
329
|
+
if (result.deleted) {
|
|
330
|
+
result.deleted = false;
|
|
331
|
+
result.error = String(error);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return results;
|
|
339
|
+
}
|
|
259
340
|
}
|
package/src/deploy/dns/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
import {
|
|
13
13
|
type DokployStageState,
|
|
14
14
|
isDnsVerified,
|
|
15
|
+
setDnsRecord,
|
|
15
16
|
setDnsVerification,
|
|
16
17
|
} from '../state';
|
|
17
18
|
import {
|
|
@@ -381,11 +382,17 @@ export async function createDnsRecords(
|
|
|
381
382
|
* Supports both legacy single-domain format and new multi-domain format:
|
|
382
383
|
* - Legacy: { provider: 'hostinger', domain: 'example.com' }
|
|
383
384
|
* - Multi: { 'example.com': { provider: 'hostinger' }, 'example.dev': { provider: 'route53' } }
|
|
385
|
+
*
|
|
386
|
+
* @param appHostnames - Map of app names to hostnames
|
|
387
|
+
* @param dnsConfig - DNS configuration (legacy or multi-domain)
|
|
388
|
+
* @param dokployEndpoint - Dokploy server endpoint to resolve IP from
|
|
389
|
+
* @param state - Optional state to save created records for later deletion
|
|
384
390
|
*/
|
|
385
391
|
export async function orchestrateDns(
|
|
386
392
|
appHostnames: Map<string, string>, // appName -> hostname
|
|
387
393
|
dnsConfig: DnsConfig | undefined,
|
|
388
394
|
dokployEndpoint: string,
|
|
395
|
+
state?: DokployStageState,
|
|
389
396
|
): Promise<DnsCreationResult | null> {
|
|
390
397
|
if (!dnsConfig) {
|
|
391
398
|
return null;
|
|
@@ -475,6 +482,24 @@ export async function orchestrateDns(
|
|
|
475
482
|
hasFailures = true;
|
|
476
483
|
}
|
|
477
484
|
|
|
485
|
+
// Save created/existing records to state for later deletion during undeploy
|
|
486
|
+
if (state) {
|
|
487
|
+
for (const record of domainRecords) {
|
|
488
|
+
if (record.created || record.existed) {
|
|
489
|
+
setDnsRecord(state, {
|
|
490
|
+
domain: rootDomain,
|
|
491
|
+
name: record.subdomain,
|
|
492
|
+
type: record.type,
|
|
493
|
+
value: record.value,
|
|
494
|
+
ttl:
|
|
495
|
+
'ttl' in providerConfig && providerConfig.ttl
|
|
496
|
+
? providerConfig.ttl
|
|
497
|
+
: 300,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
478
503
|
// Print summary table for this domain
|
|
479
504
|
printDnsRecordsTable(domainRecords, rootDomain);
|
|
480
505
|
|