puls-dev 0.3.6 → 0.3.7
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 +11 -11
- package/dist/bin/install-shell.js +5 -6
- package/dist/bin/puls.js +10 -3
- package/dist/core/config.d.ts +4 -0
- package/dist/core/decorators.d.ts +4 -0
- package/dist/core/decorators.js +2 -0
- package/dist/core/parallel.test.js +4 -3
- package/dist/core/resource.d.ts +2 -1
- package/dist/core/resource.js +4 -2
- package/dist/core/stack.d.ts +4 -0
- package/dist/core/stack.js +8 -8
- package/dist/providers/aws/acm.test.d.ts +1 -0
- package/dist/providers/aws/acm.test.js +167 -0
- package/dist/providers/aws/cloudfront.test.d.ts +1 -0
- package/dist/providers/aws/cloudfront.test.js +170 -0
- package/dist/providers/aws/fargate.test.d.ts +1 -0
- package/dist/providers/aws/fargate.test.js +244 -0
- package/dist/providers/aws/rds.test.d.ts +1 -0
- package/dist/providers/aws/rds.test.js +219 -0
- package/dist/providers/aws/sqs.test.d.ts +1 -0
- package/dist/providers/aws/sqs.test.js +181 -0
- package/dist/providers/cloudflare/api.d.ts +15 -0
- package/dist/providers/cloudflare/api.js +199 -0
- package/dist/providers/cloudflare/index.d.ts +14 -0
- package/dist/providers/cloudflare/index.js +19 -0
- package/dist/providers/cloudflare/kv.d.ts +20 -0
- package/dist/providers/cloudflare/kv.js +69 -0
- package/dist/providers/cloudflare/kv.test.d.ts +1 -0
- package/dist/providers/cloudflare/kv.test.js +134 -0
- package/dist/providers/cloudflare/r2.d.ts +14 -0
- package/dist/providers/cloudflare/r2.js +57 -0
- package/dist/providers/cloudflare/r2.test.d.ts +1 -0
- package/dist/providers/cloudflare/r2.test.js +132 -0
- package/dist/providers/cloudflare/worker.d.ts +28 -0
- package/dist/providers/cloudflare/worker.js +172 -0
- package/dist/providers/cloudflare/worker.test.d.ts +1 -0
- package/dist/providers/cloudflare/worker.test.js +220 -0
- package/dist/providers/cloudflare/zone.d.ts +42 -0
- package/dist/providers/cloudflare/zone.js +280 -0
- package/dist/providers/cloudflare/zone.test.d.ts +1 -0
- package/dist/providers/cloudflare/zone.test.js +284 -0
- package/dist/providers/firebase/auth.test.d.ts +1 -0
- package/dist/providers/firebase/auth.test.js +145 -0
- package/dist/providers/firebase/hosting.test.js +7 -6
- package/dist/providers/firebase/storage.test.d.ts +1 -0
- package/dist/providers/firebase/storage.test.js +148 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# Pulsdev.io
|
|
2
2
|
|
|
3
|
-
**Intent-driven infrastructure-as-code. Describe what you want
|
|
3
|
+
**Intent-driven infrastructure-as-code. Describe what you want- Puls figures out create, update, or skip.**
|
|
4
4
|
|
|
5
5
|
[Live Documentation](https://pulsdev.io/) | [GitHub Actions](docs/github-actions.md) | Matrix|Gitter: **pulsdev.io** ([Join](https://matrix.to/#/#pulsdevio:gitter.im))
|
|
6
6
|
|
|
7
7
|
> [!IMPORTANT]
|
|
8
8
|
> **Active Pre-1.0 Development**
|
|
9
|
-
> `pulsdev.io` is under active development. APIs and features are evolving
|
|
9
|
+
> `pulsdev.io` is under active development. APIs and features are evolving- we welcome feedback, bug reports, and contributions!
|
|
10
10
|
|
|
11
11
|
```typescript
|
|
12
12
|
@Deploy({ proxmox: CONFIG.STAGING })
|
|
@@ -20,7 +20,7 @@ class GameInfra extends Stack {
|
|
|
20
20
|
}
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
No state files. No plan step. Runs against real APIs
|
|
23
|
+
No state files. No plan step. Runs against real APIs- idempotent by default.
|
|
24
24
|
|
|
25
25
|
---
|
|
26
26
|
|
|
@@ -34,7 +34,7 @@ Declare resource → Discovery fires immediately (async)
|
|
|
34
34
|
→ deploy() awaits discovery, diffs, acts
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
Running the same stack twice is always safe
|
|
37
|
+
Running the same stack twice is always safe- existing resources are detected and skipped or updated in place.
|
|
38
38
|
|
|
39
39
|
---
|
|
40
40
|
|
|
@@ -44,7 +44,7 @@ Running the same stack twice is always safe — existing resources are detected
|
|
|
44
44
|
npm install puls-dev
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
**One-time shell setup
|
|
47
|
+
**One-time shell setup**- so you never have to type `npx puls` again:
|
|
48
48
|
|
|
49
49
|
```bash
|
|
50
50
|
npx puls install-shell
|
|
@@ -57,7 +57,7 @@ This adds a `puls` launcher to `~/.puls/bin` and wires it into your shell config
|
|
|
57
57
|
## CLI
|
|
58
58
|
|
|
59
59
|
```bash
|
|
60
|
-
puls plan infra/stack.ts # dry-run
|
|
60
|
+
puls plan infra/stack.ts # dry-run- prints what would change, no API writes
|
|
61
61
|
puls deploy infra/stack.ts # apply the stack
|
|
62
62
|
puls destroy infra/stack.ts # tear down the stack
|
|
63
63
|
puls diff infra/stack.ts # compare declared intent vs live cloud state
|
|
@@ -67,7 +67,7 @@ puls install-shell # one-time shell setup
|
|
|
67
67
|
puls uninstall-shell # remove shell integration
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
-
Always run `plan` before `deploy
|
|
70
|
+
Always run `plan` before `deploy`- it activates dry-run mode automatically.
|
|
71
71
|
|
|
72
72
|
---
|
|
73
73
|
|
|
@@ -155,7 +155,7 @@ class StagingInfra extends Stack {
|
|
|
155
155
|
|
|
156
156
|
### Drift detection
|
|
157
157
|
|
|
158
|
-
`Stack.diff()` compares every declared resource against its live cloud state
|
|
158
|
+
`Stack.diff()` compares every declared resource against its live cloud state- no API writes, structured output:
|
|
159
159
|
|
|
160
160
|
```bash
|
|
161
161
|
puls diff infra/production.ts
|
|
@@ -211,7 +211,7 @@ class Infra extends Stack {
|
|
|
211
211
|
}
|
|
212
212
|
```
|
|
213
213
|
|
|
214
|
-
Outputs resolve lazily
|
|
214
|
+
Outputs resolve lazily- downstream resources unblock the moment their dependency finishes deploying.
|
|
215
215
|
|
|
216
216
|
### Dry run / plan
|
|
217
217
|
|
|
@@ -220,7 +220,7 @@ Outputs resolve lazily — downstream resources unblock the moment their depende
|
|
|
220
220
|
class MyStack extends Stack { ... }
|
|
221
221
|
```
|
|
222
222
|
|
|
223
|
-
Or via the CLI: `puls plan infra/stack.ts
|
|
223
|
+
Or via the CLI: `puls plan infra/stack.ts`- no config change required.
|
|
224
224
|
|
|
225
225
|
### Protected resources
|
|
226
226
|
|
|
@@ -240,7 +240,7 @@ db = Proxmox.VM("ix-db01")...; // Puls will refuse to modify or destroy this
|
|
|
240
240
|
| `@Destroy` | Tear down all resources in the stack |
|
|
241
241
|
| `@DryRun` | Shorthand for `@Deploy({ dryRun: true })` |
|
|
242
242
|
| `@Protected` | Block changes/destruction of that resource |
|
|
243
|
-
| `@Check` | Inventory query
|
|
243
|
+
| `@Check` | Inventory query- lists all live resources across providers |
|
|
244
244
|
|
|
245
245
|
---
|
|
246
246
|
|
|
@@ -95,11 +95,11 @@ export function uninstallShell() {
|
|
|
95
95
|
fs.rmdirSync(path.join(home, ".puls"));
|
|
96
96
|
}
|
|
97
97
|
catch {
|
|
98
|
-
// Non-empty dirs left behind (user may have other files)
|
|
98
|
+
// Non-empty dirs left behind (user may have other files)- that's fine
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
else {
|
|
102
|
-
console.log(` Launcher not found at ${launcherPath}
|
|
102
|
+
console.log(` Launcher not found at ${launcherPath}- nothing to remove.`);
|
|
103
103
|
}
|
|
104
104
|
// 2. Remove PATH line from shell config
|
|
105
105
|
const detected = detectShellConfig();
|
|
@@ -109,12 +109,12 @@ export function uninstallShell() {
|
|
|
109
109
|
}
|
|
110
110
|
const { configFile } = detected;
|
|
111
111
|
if (!fs.existsSync(configFile)) {
|
|
112
|
-
console.log(` Shell config not found at ${configFile}
|
|
112
|
+
console.log(` Shell config not found at ${configFile}- nothing to clean up.`);
|
|
113
113
|
return;
|
|
114
114
|
}
|
|
115
115
|
const content = fs.readFileSync(configFile, "utf8");
|
|
116
116
|
if (!content.includes(MARKER)) {
|
|
117
|
-
console.log(` Shell config at ${configFile} has no puls entry
|
|
117
|
+
console.log(` Shell config at ${configFile} has no puls entry- nothing to remove.`);
|
|
118
118
|
return;
|
|
119
119
|
}
|
|
120
120
|
// Remove the marker line and the PATH line that follows it
|
|
@@ -127,8 +127,7 @@ export function uninstallShell() {
|
|
|
127
127
|
return { out: acc.out, skip: false }; // skip the PATH line
|
|
128
128
|
return { out: [...acc.out, line], skip: false };
|
|
129
129
|
}, { out: [], skip: false })
|
|
130
|
-
.out
|
|
131
|
-
.join("\n")
|
|
130
|
+
.out.join("\n")
|
|
132
131
|
.replace(/\n{3,}/g, "\n\n"); // collapse triple+ blank lines
|
|
133
132
|
fs.writeFileSync(configFile, cleaned, "utf8");
|
|
134
133
|
console.log(`✅ Removed puls PATH entry from ${configFile}`);
|
package/dist/bin/puls.js
CHANGED
|
@@ -48,7 +48,7 @@ Options:
|
|
|
48
48
|
--help Print this help and exit
|
|
49
49
|
|
|
50
50
|
Examples:
|
|
51
|
-
npx puls install-shell # one-time setup
|
|
51
|
+
npx puls install-shell # one-time setup- then just use "puls" directly
|
|
52
52
|
puls plan infra/staging.ts
|
|
53
53
|
puls deploy infra/staging.ts --parallel
|
|
54
54
|
puls destroy infra/staging.ts
|
|
@@ -84,12 +84,19 @@ if (values.help || positionals.length === 0) {
|
|
|
84
84
|
process.exit(0);
|
|
85
85
|
}
|
|
86
86
|
const [command, userFile] = positionals;
|
|
87
|
-
const COMMANDS = [
|
|
87
|
+
const COMMANDS = [
|
|
88
|
+
"plan",
|
|
89
|
+
"deploy",
|
|
90
|
+
"destroy",
|
|
91
|
+
"diff",
|
|
92
|
+
"install-shell",
|
|
93
|
+
"uninstall-shell",
|
|
94
|
+
];
|
|
88
95
|
if (!COMMANDS.includes(command)) {
|
|
89
96
|
console.error(`Error: Unknown command "${command}". Run "puls --help" for usage.`);
|
|
90
97
|
process.exit(1);
|
|
91
98
|
}
|
|
92
|
-
// Shell management commands run directly
|
|
99
|
+
// Shell management commands run directly- no stack file needed
|
|
93
100
|
if (command === "install-shell") {
|
|
94
101
|
installShell();
|
|
95
102
|
process.exit(0);
|
package/dist/core/config.d.ts
CHANGED
|
@@ -19,6 +19,10 @@ type ProviderOpts = {
|
|
|
19
19
|
verifySsl?: boolean;
|
|
20
20
|
sshUser?: string;
|
|
21
21
|
};
|
|
22
|
+
cloudflare?: {
|
|
23
|
+
token: string;
|
|
24
|
+
accountId?: string;
|
|
25
|
+
};
|
|
22
26
|
};
|
|
23
27
|
export declare function Protected(target: any, propertyKey: string): void;
|
|
24
28
|
export declare function ForceConfigCheck(target: any, propertyKey: string): void;
|
package/dist/core/decorators.js
CHANGED
|
@@ -15,6 +15,8 @@ function applyConfig(opts) {
|
|
|
15
15
|
Config.set({ providers: { aws: { region: opts.region } } });
|
|
16
16
|
if (opts.proxmox)
|
|
17
17
|
Config.set({ providers: { proxmox: opts.proxmox } });
|
|
18
|
+
if (opts.cloudflare)
|
|
19
|
+
Config.set({ providers: { cloudflare: opts.cloudflare } });
|
|
18
20
|
if (opts.firebase) {
|
|
19
21
|
const sa = JSON.parse(readFileSync(opts.firebase, "utf8"));
|
|
20
22
|
Config.set({
|
|
@@ -201,15 +201,16 @@ describe("Parallel Resource Deployment Unit Tests", () => {
|
|
|
201
201
|
assert.strictEqual(logs.includes("start:r2"), false);
|
|
202
202
|
});
|
|
203
203
|
test("decorator option propagation sets configuration values", async () => {
|
|
204
|
-
// Clear parallel flag
|
|
205
204
|
Config.set({ parallel: false });
|
|
206
|
-
// We define a decorated simple stack
|
|
207
205
|
let SimpleDecoStack = class SimpleDecoStack extends Stack {
|
|
208
206
|
};
|
|
209
207
|
SimpleDecoStack = __decorate([
|
|
210
208
|
Deploy({ parallel: true })
|
|
211
209
|
], SimpleDecoStack);
|
|
212
|
-
// Verify decorator correctly updated global configuration to true
|
|
213
210
|
assert.strictEqual(Config.isParallelActive(), true);
|
|
211
|
+
// @Deploy fires an async stack.deploy() that escapes this test's scope.
|
|
212
|
+
// Waiting here lets it complete before the test runner serializes results,
|
|
213
|
+
// preventing IPC stream corruption on the way back to the parent process.
|
|
214
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
214
215
|
});
|
|
215
216
|
});
|
package/dist/core/resource.d.ts
CHANGED
|
@@ -29,7 +29,7 @@ export declare abstract class BaseBuilder {
|
|
|
29
29
|
* naming convention).
|
|
30
30
|
*
|
|
31
31
|
* Outputs that depend on live API response fields (e.g. `out.host`) won't be
|
|
32
|
-
* resolved automatically
|
|
32
|
+
* resolved automatically- chain `.adoptOutput(key, value)` for each one you
|
|
33
33
|
* need for cross-stack wiring.
|
|
34
34
|
*/
|
|
35
35
|
adoptId(id: string): this;
|
|
@@ -51,6 +51,7 @@ export declare abstract class BaseBuilder {
|
|
|
51
51
|
* (no field-level diff available).
|
|
52
52
|
*/
|
|
53
53
|
getDiff(_existing: any): FieldDiff[];
|
|
54
|
+
forceConfigCheck?(): void;
|
|
54
55
|
adoptOutput(key: string, value: any): this;
|
|
55
56
|
dryRun(enabled?: boolean): this;
|
|
56
57
|
beforeDeploy(callback: () => Promise<void> | void): this;
|
package/dist/core/resource.js
CHANGED
|
@@ -39,7 +39,7 @@ export class BaseBuilder {
|
|
|
39
39
|
* naming convention).
|
|
40
40
|
*
|
|
41
41
|
* Outputs that depend on live API response fields (e.g. `out.host`) won't be
|
|
42
|
-
* resolved automatically
|
|
42
|
+
* resolved automatically- chain `.adoptOutput(key, value)` for each one you
|
|
43
43
|
* need for cross-stack wiring.
|
|
44
44
|
*/
|
|
45
45
|
adoptId(id) {
|
|
@@ -178,7 +178,9 @@ export class BaseBuilder {
|
|
|
178
178
|
}
|
|
179
179
|
async destroy() {
|
|
180
180
|
const dryRun = this.isDryRunActive();
|
|
181
|
-
const adoptedSuffix = this._adoptedId
|
|
181
|
+
const adoptedSuffix = this._adoptedId
|
|
182
|
+
? ` [adopted id=${this._adoptedId}]`
|
|
183
|
+
: "";
|
|
182
184
|
console.log(`\n🗑️ Destroying "${this.name}"${adoptedSuffix}...`);
|
|
183
185
|
console.log(` ✅ [${dryRun ? "PLAN" : "OK"}] Resource "${this.name}" marked for destruction.`);
|
|
184
186
|
await this.destroySidecars();
|
package/dist/core/stack.d.ts
CHANGED
|
@@ -17,6 +17,10 @@ export declare abstract class Stack {
|
|
|
17
17
|
* }
|
|
18
18
|
*/
|
|
19
19
|
static from<T extends Stack>(cls: new (...args: any[]) => T, region?: string): T;
|
|
20
|
+
beforeDeploy?(): Promise<void> | void;
|
|
21
|
+
afterDeploy?(outputs: Record<string, any>): Promise<void> | void;
|
|
22
|
+
beforeDestroy?(): Promise<void> | void;
|
|
23
|
+
afterDestroy?(outputs: Record<string, any>): Promise<void> | void;
|
|
20
24
|
/**
|
|
21
25
|
* Compares every declared resource against its live cloud state without
|
|
22
26
|
* making any API writes. Returns a structured `StackDiff` and prints a
|
package/dist/core/stack.js
CHANGED
|
@@ -237,7 +237,7 @@ export class Stack {
|
|
|
237
237
|
return withRedactedConsole(secrets, async () => {
|
|
238
238
|
console.log(`\n🏗️ Deploying Stack: ${this.constructor.name}`);
|
|
239
239
|
// Stack-level beforeDeploy hook
|
|
240
|
-
if (
|
|
240
|
+
if (this.beforeDeploy) {
|
|
241
241
|
console.log(` ⚡ Running Stack-level beforeDeploy hook...`);
|
|
242
242
|
await this.beforeDeploy();
|
|
243
243
|
}
|
|
@@ -255,8 +255,8 @@ export class Stack {
|
|
|
255
255
|
if (isProtected)
|
|
256
256
|
val.protect();
|
|
257
257
|
const forceConfigCheck = Reflect.getMetadata("forceConfigCheck", this, prop);
|
|
258
|
-
if (forceConfigCheck
|
|
259
|
-
val.forceConfigCheck();
|
|
258
|
+
if (forceConfigCheck) {
|
|
259
|
+
val.forceConfigCheck?.();
|
|
260
260
|
}
|
|
261
261
|
}
|
|
262
262
|
else if (Array.isArray(val)) {
|
|
@@ -267,8 +267,8 @@ export class Stack {
|
|
|
267
267
|
resources.push({ prop, resource: item });
|
|
268
268
|
if (isProtected)
|
|
269
269
|
item.protect();
|
|
270
|
-
if (forceConfigCheck
|
|
271
|
-
item.forceConfigCheck();
|
|
270
|
+
if (forceConfigCheck) {
|
|
271
|
+
item.forceConfigCheck?.();
|
|
272
272
|
}
|
|
273
273
|
}
|
|
274
274
|
}
|
|
@@ -373,7 +373,7 @@ export class Stack {
|
|
|
373
373
|
}
|
|
374
374
|
printOutputs(this.constructor.name, outputs);
|
|
375
375
|
// Stack-level afterDeploy hook
|
|
376
|
-
if (
|
|
376
|
+
if (this.afterDeploy) {
|
|
377
377
|
console.log(` ⚡ Running Stack-level afterDeploy hook...`);
|
|
378
378
|
await this.afterDeploy(outputs);
|
|
379
379
|
}
|
|
@@ -395,7 +395,7 @@ export class Stack {
|
|
|
395
395
|
return withRedactedConsole(secrets, async () => {
|
|
396
396
|
console.log(`\n💥 Tearing down Stack: ${this.constructor.name}`);
|
|
397
397
|
// Stack-level beforeDestroy hook
|
|
398
|
-
if (
|
|
398
|
+
if (this.beforeDestroy) {
|
|
399
399
|
console.log(` ⚡ Running Stack-level beforeDestroy hook...`);
|
|
400
400
|
await this.beforeDestroy();
|
|
401
401
|
}
|
|
@@ -507,7 +507,7 @@ export class Stack {
|
|
|
507
507
|
}
|
|
508
508
|
printOutputs(this.constructor.name, outputs);
|
|
509
509
|
// Stack-level afterDestroy hook
|
|
510
|
-
if (
|
|
510
|
+
if (this.afterDestroy) {
|
|
511
511
|
console.log(` ⚡ Running Stack-level afterDestroy hook...`);
|
|
512
512
|
await this.afterDestroy(outputs);
|
|
513
513
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach, mock } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { ACMClient } from '@aws-sdk/client-acm';
|
|
4
|
+
import { Route53Client } from '@aws-sdk/client-route-53';
|
|
5
|
+
import { ACMCertificateBuilder } from './acm.js';
|
|
6
|
+
import { Config } from '../../core/config.js';
|
|
7
|
+
describe('ACMCertificateBuilder Unit Tests', () => {
|
|
8
|
+
let originalAcmSend;
|
|
9
|
+
let originalR53Send;
|
|
10
|
+
let acmCalls = [];
|
|
11
|
+
let r53Calls = [];
|
|
12
|
+
let mockAcmResponses = {};
|
|
13
|
+
let mockR53Responses = {};
|
|
14
|
+
function stubSend(calls, responses) {
|
|
15
|
+
return async function (command) {
|
|
16
|
+
const commandName = command.constructor.name;
|
|
17
|
+
calls.push({ commandName, input: command.input });
|
|
18
|
+
const handler = responses[commandName];
|
|
19
|
+
if (handler) {
|
|
20
|
+
if (typeof handler === 'function')
|
|
21
|
+
return handler(command.input);
|
|
22
|
+
if (handler instanceof Error)
|
|
23
|
+
throw handler;
|
|
24
|
+
return handler;
|
|
25
|
+
}
|
|
26
|
+
return {};
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
Config.set({ dryRun: false, providers: { aws: { region: 'us-east-1' } } });
|
|
31
|
+
acmCalls = [];
|
|
32
|
+
r53Calls = [];
|
|
33
|
+
mockAcmResponses = {};
|
|
34
|
+
mockR53Responses = {};
|
|
35
|
+
originalAcmSend = ACMClient.prototype.send;
|
|
36
|
+
originalR53Send = Route53Client.prototype.send;
|
|
37
|
+
ACMClient.prototype.send = stubSend(acmCalls, mockAcmResponses);
|
|
38
|
+
Route53Client.prototype.send = stubSend(r53Calls, mockR53Responses);
|
|
39
|
+
});
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
ACMClient.prototype.send = originalAcmSend;
|
|
42
|
+
Route53Client.prototype.send = originalR53Send;
|
|
43
|
+
mock.restoreAll();
|
|
44
|
+
});
|
|
45
|
+
test('returns null when no matching certificate is found', async () => {
|
|
46
|
+
mockAcmResponses['ListCertificatesCommand'] = { CertificateSummaryList: [] };
|
|
47
|
+
const builder = new ACMCertificateBuilder('example.com');
|
|
48
|
+
const result = await builder.discoveryPromise;
|
|
49
|
+
assert.strictEqual(result, null);
|
|
50
|
+
assert.strictEqual(acmCalls[0].commandName, 'ListCertificatesCommand');
|
|
51
|
+
});
|
|
52
|
+
test('discovers existing wildcard certificate by domain name', async () => {
|
|
53
|
+
mockAcmResponses['ListCertificatesCommand'] = {
|
|
54
|
+
CertificateSummaryList: [
|
|
55
|
+
{
|
|
56
|
+
DomainName: '*.example.com',
|
|
57
|
+
CertificateArn: 'arn:aws:acm:us-east-1:123:certificate/abc',
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
};
|
|
61
|
+
const builder = new ACMCertificateBuilder('example.com', true);
|
|
62
|
+
const result = await builder.discoveryPromise;
|
|
63
|
+
assert.ok(result);
|
|
64
|
+
assert.strictEqual(builder.resolvedArn, 'arn:aws:acm:us-east-1:123:certificate/abc');
|
|
65
|
+
});
|
|
66
|
+
test('returns existing cert without requesting a new one', async () => {
|
|
67
|
+
mockAcmResponses['ListCertificatesCommand'] = {
|
|
68
|
+
CertificateSummaryList: [
|
|
69
|
+
{
|
|
70
|
+
DomainName: '*.example.com',
|
|
71
|
+
CertificateArn: 'arn:aws:acm:us-east-1:123:certificate/abc',
|
|
72
|
+
Status: 'ISSUED',
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
};
|
|
76
|
+
const builder = new ACMCertificateBuilder('example.com');
|
|
77
|
+
const result = await builder.deploy();
|
|
78
|
+
assert.strictEqual(result.arn, 'arn:aws:acm:us-east-1:123:certificate/abc');
|
|
79
|
+
assert.ok(!acmCalls.some((c) => c.commandName === 'RequestCertificateCommand'));
|
|
80
|
+
});
|
|
81
|
+
test('performs dry-run without requesting a certificate', async () => {
|
|
82
|
+
Config.set({ dryRun: true, providers: { aws: { region: 'us-east-1' } } });
|
|
83
|
+
mockAcmResponses['ListCertificatesCommand'] = { CertificateSummaryList: [] };
|
|
84
|
+
const builder = new ACMCertificateBuilder('example.com');
|
|
85
|
+
const result = await builder.deploy();
|
|
86
|
+
assert.ok(result.arn.includes('DRYRUN'));
|
|
87
|
+
assert.ok(!acmCalls.some((c) => c.commandName === 'RequestCertificateCommand'));
|
|
88
|
+
});
|
|
89
|
+
test('requests wildcard cert and writes DNS validation records to Route53', async () => {
|
|
90
|
+
mockAcmResponses['ListCertificatesCommand'] = { CertificateSummaryList: [] };
|
|
91
|
+
mockAcmResponses['RequestCertificateCommand'] = {
|
|
92
|
+
CertificateArn: 'arn:aws:acm:us-east-1:123:certificate/new-cert',
|
|
93
|
+
};
|
|
94
|
+
let describeCallCount = 0;
|
|
95
|
+
mockAcmResponses['DescribeCertificateCommand'] = () => {
|
|
96
|
+
describeCallCount++;
|
|
97
|
+
if (describeCallCount === 1) {
|
|
98
|
+
// First call: validation records ready
|
|
99
|
+
return {
|
|
100
|
+
Certificate: {
|
|
101
|
+
Status: 'PENDING_VALIDATION',
|
|
102
|
+
DomainValidationOptions: [
|
|
103
|
+
{
|
|
104
|
+
ResourceRecord: {
|
|
105
|
+
Name: '_abc.example.com',
|
|
106
|
+
Value: '_xyz.acm-validations.aws.',
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
// Second call: ISSUED
|
|
114
|
+
return { Certificate: { Status: 'ISSUED' } };
|
|
115
|
+
};
|
|
116
|
+
mockR53Responses['ChangeResourceRecordSetsCommand'] = {};
|
|
117
|
+
// Fast-forward poll timers
|
|
118
|
+
mock.method(global, 'setTimeout', (fn) => fn());
|
|
119
|
+
const builder = new ACMCertificateBuilder('example.com', true);
|
|
120
|
+
builder.forZone({ zoneId: 'Z123456', zoneName: 'example.com' });
|
|
121
|
+
const result = await builder.deploy();
|
|
122
|
+
assert.strictEqual(result.arn, 'arn:aws:acm:us-east-1:123:certificate/new-cert');
|
|
123
|
+
const requestCall = acmCalls.find((c) => c.commandName === 'RequestCertificateCommand');
|
|
124
|
+
assert.ok(requestCall);
|
|
125
|
+
assert.strictEqual(requestCall.input.DomainName, '*.example.com');
|
|
126
|
+
assert.strictEqual(requestCall.input.ValidationMethod, 'DNS');
|
|
127
|
+
const r53Call = r53Calls.find((c) => c.commandName === 'ChangeResourceRecordSetsCommand');
|
|
128
|
+
assert.ok(r53Call);
|
|
129
|
+
assert.strictEqual(r53Call.input.HostedZoneId, 'Z123456');
|
|
130
|
+
const changes = r53Call.input.ChangeBatch.Changes;
|
|
131
|
+
assert.strictEqual(changes.length, 1);
|
|
132
|
+
assert.strictEqual(changes[0].Action, 'UPSERT');
|
|
133
|
+
assert.ok(changes[0].ResourceRecordSet.Name.includes('_abc'));
|
|
134
|
+
});
|
|
135
|
+
test('deduplicates validation CNAME records before writing to Route53', async () => {
|
|
136
|
+
mockAcmResponses['ListCertificatesCommand'] = { CertificateSummaryList: [] };
|
|
137
|
+
mockAcmResponses['RequestCertificateCommand'] = {
|
|
138
|
+
CertificateArn: 'arn:aws:acm:us-east-1:123:certificate/new-cert',
|
|
139
|
+
};
|
|
140
|
+
let describeCount = 0;
|
|
141
|
+
mockAcmResponses['DescribeCertificateCommand'] = () => {
|
|
142
|
+
describeCount++;
|
|
143
|
+
if (describeCount === 1) {
|
|
144
|
+
return {
|
|
145
|
+
Certificate: {
|
|
146
|
+
Status: 'PENDING_VALIDATION',
|
|
147
|
+
// wildcard + apex SANs produce the same CNAME - duplicated
|
|
148
|
+
DomainValidationOptions: [
|
|
149
|
+
{ ResourceRecord: { Name: '_dup.example.com', Value: '_val.aws.' } },
|
|
150
|
+
{ ResourceRecord: { Name: '_dup.example.com', Value: '_val.aws.' } },
|
|
151
|
+
],
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return { Certificate: { Status: 'ISSUED' } };
|
|
156
|
+
};
|
|
157
|
+
mockR53Responses['ChangeResourceRecordSetsCommand'] = {};
|
|
158
|
+
mock.method(global, 'setTimeout', (fn) => fn());
|
|
159
|
+
const builder = new ACMCertificateBuilder('example.com', true);
|
|
160
|
+
builder.forZone({ zoneId: 'Z123456', zoneName: 'example.com' });
|
|
161
|
+
await builder.deploy();
|
|
162
|
+
const r53Call = r53Calls.find((c) => c.commandName === 'ChangeResourceRecordSetsCommand');
|
|
163
|
+
assert.ok(r53Call);
|
|
164
|
+
// Should be deduplicated to 1 record
|
|
165
|
+
assert.strictEqual(r53Call.input.ChangeBatch.Changes.length, 1);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|