puls-dev 0.2.0 → 0.2.2

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.
Files changed (64) hide show
  1. package/README.md +1 -1
  2. package/dist/core/config.d.ts +5 -0
  3. package/dist/providers/aws/api.d.ts +4 -0
  4. package/dist/providers/aws/api.js +4 -0
  5. package/dist/providers/aws/cloudwatch.d.ts +44 -0
  6. package/dist/providers/aws/cloudwatch.js +205 -0
  7. package/dist/providers/aws/cloudwatch.test.d.ts +1 -0
  8. package/dist/providers/aws/cloudwatch.test.js +224 -0
  9. package/dist/providers/aws/fargate.d.ts +2 -0
  10. package/dist/providers/aws/fargate.js +6 -0
  11. package/dist/providers/aws/iam.d.ts +52 -0
  12. package/dist/providers/aws/iam.js +307 -0
  13. package/dist/providers/aws/iam.test.d.ts +1 -0
  14. package/dist/providers/aws/iam.test.js +367 -0
  15. package/dist/providers/aws/index.d.ts +7 -0
  16. package/dist/providers/aws/index.js +7 -0
  17. package/dist/providers/aws/lambda.d.ts +3 -1
  18. package/dist/providers/aws/lambda.js +11 -2
  19. package/dist/providers/aws/rds.d.ts +1 -0
  20. package/dist/providers/aws/rds.js +4 -1
  21. package/dist/providers/aws/sns.d.ts +22 -0
  22. package/dist/providers/aws/sns.js +146 -0
  23. package/dist/providers/aws/sns.test.d.ts +1 -0
  24. package/dist/providers/aws/sns.test.js +162 -0
  25. package/dist/providers/firebase/appcheck.d.ts +15 -0
  26. package/dist/providers/firebase/appcheck.js +109 -0
  27. package/dist/providers/firebase/appcheck.test.d.ts +1 -0
  28. package/dist/providers/firebase/appcheck.test.js +141 -0
  29. package/dist/providers/firebase/index.d.ts +2 -0
  30. package/dist/providers/firebase/index.js +2 -0
  31. package/dist/providers/gcp/api.d.ts +10 -0
  32. package/dist/providers/gcp/api.js +111 -0
  33. package/dist/providers/gcp/clouddns.d.ts +37 -0
  34. package/dist/providers/gcp/clouddns.js +284 -0
  35. package/dist/providers/gcp/clouddns.test.d.ts +1 -0
  36. package/dist/providers/gcp/clouddns.test.js +259 -0
  37. package/dist/providers/gcp/cloudrun.d.ts +31 -0
  38. package/dist/providers/gcp/cloudrun.js +240 -0
  39. package/dist/providers/gcp/cloudrun.test.d.ts +1 -0
  40. package/dist/providers/gcp/cloudrun.test.js +281 -0
  41. package/dist/providers/gcp/cloudsql.d.ts +37 -0
  42. package/dist/providers/gcp/cloudsql.js +262 -0
  43. package/dist/providers/gcp/cloudsql.test.d.ts +1 -0
  44. package/dist/providers/gcp/cloudsql.test.js +295 -0
  45. package/dist/providers/gcp/iam.d.ts +38 -0
  46. package/dist/providers/gcp/iam.js +309 -0
  47. package/dist/providers/gcp/iam.test.d.ts +1 -0
  48. package/dist/providers/gcp/iam.test.js +305 -0
  49. package/dist/providers/gcp/index.d.ts +19 -0
  50. package/dist/providers/gcp/index.js +19 -0
  51. package/dist/providers/gcp/pubsub.d.ts +31 -0
  52. package/dist/providers/gcp/pubsub.js +227 -0
  53. package/dist/providers/gcp/pubsub.test.d.ts +1 -0
  54. package/dist/providers/gcp/pubsub.test.js +244 -0
  55. package/dist/providers/gcp/secrets.d.ts +21 -0
  56. package/dist/providers/gcp/secrets.js +187 -0
  57. package/dist/providers/gcp/secrets.test.d.ts +1 -0
  58. package/dist/providers/gcp/secrets.test.js +264 -0
  59. package/dist/providers/proxmox/vm.d.ts +2 -0
  60. package/dist/providers/proxmox/vm.js +35 -3
  61. package/dist/providers/proxmox/vm.test.d.ts +1 -0
  62. package/dist/providers/proxmox/vm.test.js +155 -0
  63. package/dist/types/aws.d.ts +11 -0
  64. package/package.json +32 -2
@@ -0,0 +1,244 @@
1
+ import { test, describe, beforeEach, afterEach, mock } from "node:test";
2
+ import assert from "node:assert";
3
+ import { GoogleAuth } from "google-auth-library";
4
+ import { GCPPubSubTopicBuilder, GCPPubSubSubscriptionBuilder } from "./pubsub.js";
5
+ import { Config } from "../../core/config.js";
6
+ describe("GCPPubSub Unit Tests", () => {
7
+ let originalFetch;
8
+ let fetchCalls = [];
9
+ let mockResponses = {};
10
+ beforeEach(() => {
11
+ Config.set({
12
+ dryRun: false,
13
+ providers: {
14
+ gcp: {
15
+ projectId: "my-gcp-project",
16
+ serviceAccountPath: "/fake/sa.json",
17
+ },
18
+ },
19
+ });
20
+ originalFetch = globalThis.fetch;
21
+ fetchCalls = [];
22
+ mockResponses = {};
23
+ globalThis.fetch = async (input, init) => {
24
+ const url = String(input);
25
+ const method = init?.method ?? "GET";
26
+ let body;
27
+ if (init?.body) {
28
+ if (typeof init.body === "string") {
29
+ try {
30
+ body = JSON.parse(init.body);
31
+ }
32
+ catch {
33
+ body = init.body;
34
+ }
35
+ }
36
+ else {
37
+ body = "[Binary/Buffer Body]";
38
+ }
39
+ }
40
+ const headers = init?.headers;
41
+ fetchCalls.push({ url, method, body, headers });
42
+ const matchKey = Object.keys(mockResponses)
43
+ .filter((key) => {
44
+ const [mMethod, mPath] = key.split(" ");
45
+ return method === mMethod && url.includes(mPath);
46
+ })
47
+ .sort((a, b) => b.split(" ")[1].length - a.split(" ")[1].length)[0];
48
+ if (matchKey) {
49
+ const resp = mockResponses[matchKey];
50
+ return {
51
+ ok: resp.status >= 200 && resp.status < 300,
52
+ status: resp.status,
53
+ json: async () => resp.body,
54
+ text: async () => JSON.stringify(resp.body),
55
+ };
56
+ }
57
+ return {
58
+ ok: false,
59
+ status: 404,
60
+ json: async () => ({ message: `Endpoint not mocked: ${method} ${url}` }),
61
+ text: async () => `Endpoint not mocked: ${method} ${url}`,
62
+ };
63
+ };
64
+ mock.method(GoogleAuth.prototype, "getClient", async () => {
65
+ return {
66
+ getAccessToken: async () => ({ token: "fake-gcp-token" }),
67
+ };
68
+ });
69
+ });
70
+ afterEach(() => {
71
+ globalThis.fetch = originalFetch;
72
+ mock.restoreAll();
73
+ });
74
+ describe("GCPPubSubTopicBuilder", () => {
75
+ test("runs in dry-run mode safely and logs plans", async () => {
76
+ Config.set({
77
+ dryRun: true,
78
+ });
79
+ mockResponses["GET /topics/dryrun-topic"] = {
80
+ status: 404,
81
+ body: {},
82
+ };
83
+ const builder = new GCPPubSubTopicBuilder("dryrun-topic");
84
+ const result = await builder.deploy();
85
+ assert.strictEqual(result.name, "dryrun-topic");
86
+ assert.strictEqual(result.topicName, "projects/my-gcp-project/topics/dryrun-topic");
87
+ const writeCalls = fetchCalls.filter((c) => c.method !== "GET");
88
+ assert.strictEqual(writeCalls.length, 0);
89
+ });
90
+ test("creates a new topic when missing", async () => {
91
+ mockResponses["GET /topics/new-topic"] = {
92
+ status: 404,
93
+ body: {},
94
+ };
95
+ mockResponses["PUT /topics/new-topic"] = {
96
+ status: 200,
97
+ body: { name: "projects/my-gcp-project/topics/new-topic" },
98
+ };
99
+ const builder = new GCPPubSubTopicBuilder("new-topic");
100
+ const result = await builder.deploy();
101
+ assert.strictEqual(result.name, "new-topic");
102
+ assert.strictEqual(result.topicName, "projects/my-gcp-project/topics/new-topic");
103
+ const putCall = fetchCalls.find((c) => c.method === "PUT" && c.url.includes("/topics/new-topic"));
104
+ assert.ok(putCall);
105
+ });
106
+ test("skips creation if topic already exists", async () => {
107
+ mockResponses["GET /topics/exist-topic"] = {
108
+ status: 200,
109
+ body: { name: "projects/my-gcp-project/topics/exist-topic" },
110
+ };
111
+ const builder = new GCPPubSubTopicBuilder("exist-topic");
112
+ const result = await builder.deploy();
113
+ assert.strictEqual(result.name, "exist-topic");
114
+ assert.strictEqual(result.topicName, "projects/my-gcp-project/topics/exist-topic");
115
+ const writeCalls = fetchCalls.filter((c) => c.method !== "GET");
116
+ assert.strictEqual(writeCalls.length, 0);
117
+ });
118
+ test("destroys an existing topic successfully", async () => {
119
+ mockResponses["GET /topics/delete-topic"] = {
120
+ status: 200,
121
+ body: { name: "projects/my-gcp-project/topics/delete-topic" },
122
+ };
123
+ mockResponses["DELETE /topics/delete-topic"] = {
124
+ status: 200,
125
+ body: {},
126
+ };
127
+ const builder = new GCPPubSubTopicBuilder("delete-topic");
128
+ const result = await builder.destroy();
129
+ assert.deepStrictEqual(result, { destroyed: "delete-topic" });
130
+ const deleteCall = fetchCalls.find((c) => c.method === "DELETE" && c.url.includes("/topics/delete-topic"));
131
+ assert.ok(deleteCall);
132
+ });
133
+ });
134
+ describe("GCPPubSubSubscriptionBuilder", () => {
135
+ test("fluent builder api sets properties and binds topic references", () => {
136
+ const topic = new GCPPubSubTopicBuilder("my-t");
137
+ topic.resolvedTopicName = "projects/my-gcp-project/topics/my-t";
138
+ const builder = new GCPPubSubSubscriptionBuilder("my-sub")
139
+ .topic(topic)
140
+ .pushEndpoint("https://my-endpoint.com/push")
141
+ .ackDeadline(30);
142
+ assert.strictEqual(builder._topic, topic);
143
+ assert.strictEqual(builder._pushEndpoint, "https://my-endpoint.com/push");
144
+ assert.strictEqual(builder._ackDeadlineSeconds, 30);
145
+ });
146
+ test("creates pull subscription when missing", async () => {
147
+ mockResponses["GET /subscriptions/new-pull-sub"] = {
148
+ status: 404,
149
+ body: {},
150
+ };
151
+ mockResponses["PUT /subscriptions/new-pull-sub"] = {
152
+ status: 200,
153
+ body: { name: "projects/my-gcp-project/subscriptions/new-pull-sub" },
154
+ };
155
+ const builder = new GCPPubSubSubscriptionBuilder("new-pull-sub")
156
+ .topic("my-t")
157
+ .ackDeadline(20);
158
+ const result = await builder.deploy();
159
+ assert.strictEqual(result.name, "new-pull-sub");
160
+ assert.strictEqual(result.subscriptionName, "projects/my-gcp-project/subscriptions/new-pull-sub");
161
+ const putCall = fetchCalls.find((c) => c.method === "PUT" && c.url.includes("/subscriptions/new-pull-sub"));
162
+ assert.ok(putCall);
163
+ assert.strictEqual(putCall.body.topic, "projects/my-gcp-project/topics/my-t");
164
+ assert.deepStrictEqual(putCall.body.pushConfig, {});
165
+ assert.strictEqual(putCall.body.ackDeadlineSeconds, 20);
166
+ });
167
+ test("creates push subscription when missing", async () => {
168
+ mockResponses["GET /subscriptions/new-push-sub"] = {
169
+ status: 404,
170
+ body: {},
171
+ };
172
+ mockResponses["PUT /subscriptions/new-push-sub"] = {
173
+ status: 200,
174
+ body: { name: "projects/my-gcp-project/subscriptions/new-push-sub" },
175
+ };
176
+ const builder = new GCPPubSubSubscriptionBuilder("new-push-sub")
177
+ .topic("my-t")
178
+ .pushEndpoint("https://my-app.run.app/push");
179
+ const result = await builder.deploy();
180
+ assert.strictEqual(result.name, "new-push-sub");
181
+ const putCall = fetchCalls.find((c) => c.method === "PUT" && c.url.includes("/subscriptions/new-push-sub"));
182
+ assert.ok(putCall);
183
+ assert.strictEqual(putCall.body.pushConfig.pushEndpoint, "https://my-app.run.app/push");
184
+ });
185
+ test("patches subscription if endpoints differ", async () => {
186
+ mockResponses["GET /subscriptions/existing-sub"] = {
187
+ status: 200,
188
+ body: {
189
+ name: "projects/my-gcp-project/subscriptions/existing-sub",
190
+ topic: "projects/my-gcp-project/topics/my-t",
191
+ pushConfig: { pushEndpoint: "https://old-endpoint.com" },
192
+ ackDeadlineSeconds: 10,
193
+ },
194
+ };
195
+ mockResponses["PATCH /subscriptions/existing-sub"] = {
196
+ status: 200,
197
+ body: { name: "projects/my-gcp-project/subscriptions/existing-sub" },
198
+ };
199
+ const builder = new GCPPubSubSubscriptionBuilder("existing-sub")
200
+ .topic("my-t")
201
+ .pushEndpoint("https://new-endpoint.com"); // endpoint changed!
202
+ const result = await builder.deploy();
203
+ assert.strictEqual(result.name, "existing-sub");
204
+ const patchCall = fetchCalls.find((c) => c.method === "PATCH" && c.url.includes("/subscriptions/existing-sub"));
205
+ assert.ok(patchCall);
206
+ assert.strictEqual(patchCall.body.subscription.pushConfig.pushEndpoint, "https://new-endpoint.com");
207
+ assert.strictEqual(patchCall.body.updateMask, "topic,pushConfig,ackDeadlineSeconds");
208
+ });
209
+ test("skips patching if identical", async () => {
210
+ mockResponses["GET /subscriptions/ident-sub"] = {
211
+ status: 200,
212
+ body: {
213
+ name: "projects/my-gcp-project/subscriptions/ident-sub",
214
+ topic: "projects/my-gcp-project/topics/my-t",
215
+ pushConfig: { pushEndpoint: "https://endpoint.com" },
216
+ ackDeadlineSeconds: 20,
217
+ },
218
+ };
219
+ const builder = new GCPPubSubSubscriptionBuilder("ident-sub")
220
+ .topic("my-t")
221
+ .pushEndpoint("https://endpoint.com")
222
+ .ackDeadline(20);
223
+ const result = await builder.deploy();
224
+ assert.strictEqual(result.name, "ident-sub");
225
+ const writeCalls = fetchCalls.filter((c) => c.method !== "GET");
226
+ assert.strictEqual(writeCalls.length, 0);
227
+ });
228
+ test("destroys an existing subscription successfully", async () => {
229
+ mockResponses["GET /subscriptions/delete-sub"] = {
230
+ status: 200,
231
+ body: { name: "projects/my-gcp-project/subscriptions/delete-sub" },
232
+ };
233
+ mockResponses["DELETE /subscriptions/delete-sub"] = {
234
+ status: 200,
235
+ body: {},
236
+ };
237
+ const builder = new GCPPubSubSubscriptionBuilder("delete-sub");
238
+ const result = await builder.destroy();
239
+ assert.deepStrictEqual(result, { destroyed: "delete-sub" });
240
+ const deleteCall = fetchCalls.find((c) => c.method === "DELETE" && c.url.includes("/subscriptions/delete-sub"));
241
+ assert.ok(deleteCall);
242
+ });
243
+ });
244
+ });
@@ -0,0 +1,21 @@
1
+ import { BaseBuilder } from "../../core/resource.js";
2
+ export declare class GCPSecretBuilder extends BaseBuilder {
3
+ private _value?;
4
+ resolvedValue: string | null;
5
+ resolvedArn: string | null;
6
+ constructor(secretId: string);
7
+ private fetchSecret;
8
+ awaitValue(): Promise<string | null>;
9
+ plainText(v: string): this;
10
+ keyValue(obj: object): this;
11
+ deploy(): Promise<{
12
+ name: string;
13
+ arn: string | null;
14
+ value: string | null;
15
+ }>;
16
+ destroy(): Promise<{
17
+ destroyed: string;
18
+ }>;
19
+ private discoverSecretMetadata;
20
+ }
21
+ export declare function resolveGCPEnvVars(env: Record<string, string | GCPSecretBuilder>, isDryRun?: boolean): Promise<Record<string, string>>;
@@ -0,0 +1,187 @@
1
+ import { BaseBuilder } from "../../core/resource.js";
2
+ import { gcpFetch, getProjectId } from "./api.js";
3
+ const SECRET_BASE = "https://secretmanager.googleapis.com";
4
+ export class GCPSecretBuilder extends BaseBuilder {
5
+ _value;
6
+ resolvedValue = null;
7
+ resolvedArn = null;
8
+ constructor(secretId) {
9
+ super(secretId);
10
+ this.discoveryPromise = this.fetchSecret(secretId);
11
+ }
12
+ async fetchSecret(secretId) {
13
+ try {
14
+ const project = getProjectId();
15
+ // 1. Fetch metadata first to see if secret exists
16
+ const secret = await gcpFetch(SECRET_BASE, `/v1/projects/${project}/secrets/${secretId}`);
17
+ this.resolvedArn = secret.name ?? null;
18
+ // 2. Fetch the latest secret value payload
19
+ try {
20
+ const payload = await gcpFetch(SECRET_BASE, `/v1/projects/${project}/secrets/${secretId}/versions/latest:access`);
21
+ if (payload.payload?.data) {
22
+ this.resolvedValue = Buffer.from(payload.payload.data, "base64").toString("utf8");
23
+ }
24
+ }
25
+ catch (err) {
26
+ // If version access fails (e.g. no versions created yet), keep resolvedValue as null
27
+ }
28
+ return secret;
29
+ }
30
+ catch (e) {
31
+ if (e.message?.includes("404") ||
32
+ e.message?.includes("403") ||
33
+ e.message?.includes("credentials not configured")) {
34
+ return null;
35
+ }
36
+ throw e;
37
+ }
38
+ }
39
+ async awaitValue() {
40
+ await this.discoveryPromise;
41
+ return this.resolvedValue;
42
+ }
43
+ plainText(v) {
44
+ this._value = v;
45
+ return this;
46
+ }
47
+ keyValue(obj) {
48
+ this._value = JSON.stringify(obj);
49
+ return this;
50
+ }
51
+ async deploy() {
52
+ const dryRun = this.isDryRunActive();
53
+ const project = getProjectId();
54
+ const secretId = this.name;
55
+ const existing = await this.discoveryPromise;
56
+ console.log(`\nšŸ” Finalizing GCP Secret "${secretId}"...`);
57
+ if (dryRun) {
58
+ if (existing) {
59
+ console.log(` āœ… Secret "${secretId}" exists`);
60
+ if (this.resolvedValue !== null) {
61
+ console.log(` šŸ’¬ Value: ${this.resolvedValue}`);
62
+ }
63
+ if (this._value) {
64
+ console.log(` šŸ“ [PLAN] Update secret value`);
65
+ }
66
+ }
67
+ else {
68
+ console.log(` šŸ“ [PLAN] Create secret "${secretId}"`);
69
+ }
70
+ // Populate planned value for other builders to resolve during dry-run
71
+ this.resolvedValue = this._value ?? null;
72
+ return {
73
+ name: secretId,
74
+ arn: this.resolvedArn,
75
+ value: this.resolvedValue,
76
+ };
77
+ }
78
+ if (!existing) {
79
+ if (!this._value) {
80
+ console.log(` āš ļø Secret "${secretId}" does not exist - add .plainText() or .keyValue() to create it`);
81
+ return { name: secretId, arn: null, value: null };
82
+ }
83
+ // Create secret container
84
+ const secret = await gcpFetch(SECRET_BASE, `/v1/projects/${project}/secrets?secretId=${secretId}`, {
85
+ method: "POST",
86
+ body: JSON.stringify({
87
+ replication: {
88
+ automatic: {},
89
+ },
90
+ }),
91
+ });
92
+ this.resolvedArn = secret.name ?? null;
93
+ // Add secret version payload
94
+ const base64Data = Buffer.from(this._value, "utf8").toString("base64");
95
+ await gcpFetch(SECRET_BASE, `/v1/projects/${project}/secrets/${secretId}:addVersion`, {
96
+ method: "POST",
97
+ body: JSON.stringify({
98
+ payload: {
99
+ data: base64Data,
100
+ },
101
+ }),
102
+ });
103
+ this.resolvedValue = this._value;
104
+ console.log(`šŸš€ Created secret "${secretId}"`);
105
+ }
106
+ else {
107
+ console.log(` āœ… Secret "${secretId}" exists`);
108
+ if (this.resolvedValue !== null) {
109
+ console.log(` šŸ’¬ Value: ${this.resolvedValue}`);
110
+ }
111
+ if (this._value && this._value !== this.resolvedValue) {
112
+ const base64Data = Buffer.from(this._value, "utf8").toString("base64");
113
+ await gcpFetch(SECRET_BASE, `/v1/projects/${project}/secrets/${secretId}:addVersion`, {
114
+ method: "POST",
115
+ body: JSON.stringify({
116
+ payload: {
117
+ data: base64Data,
118
+ },
119
+ }),
120
+ });
121
+ this.resolvedValue = this._value;
122
+ console.log(` āœ… Updated secret value`);
123
+ }
124
+ }
125
+ await this.deploySidecars();
126
+ return {
127
+ name: secretId,
128
+ arn: this.resolvedArn,
129
+ value: this.resolvedValue,
130
+ };
131
+ }
132
+ async destroy() {
133
+ const dryRun = this.isDryRunActive();
134
+ const project = getProjectId();
135
+ const secretId = this.name;
136
+ console.log(`\nšŸ—‘ļø Destroying GCP Secret "${secretId}"...`);
137
+ const existing = await this.discoverSecretMetadata();
138
+ if (!existing) {
139
+ console.log(` āœ… Secret "${secretId}" does not exist - nothing to do.`);
140
+ return { destroyed: secretId };
141
+ }
142
+ if (dryRun) {
143
+ console.log(` šŸ“ [PLAN] Delete secret "${secretId}"`);
144
+ return { destroyed: secretId };
145
+ }
146
+ await gcpFetch(SECRET_BASE, `/v1/projects/${project}/secrets/${secretId}`, {
147
+ method: "DELETE",
148
+ });
149
+ console.log(` āœ… Secret "${secretId}" deleted.`);
150
+ await this.destroySidecars();
151
+ return { destroyed: secretId };
152
+ }
153
+ async discoverSecretMetadata() {
154
+ try {
155
+ const project = getProjectId();
156
+ return await gcpFetch(SECRET_BASE, `/v1/projects/${project}/secrets/${this.name}`);
157
+ }
158
+ catch (e) {
159
+ if (e.message?.includes("404") ||
160
+ e.message?.includes("403") ||
161
+ e.message?.includes("credentials not configured")) {
162
+ return null;
163
+ }
164
+ throw e;
165
+ }
166
+ }
167
+ }
168
+ export async function resolveGCPEnvVars(env, isDryRun = false) {
169
+ const resolved = {};
170
+ for (const [k, v] of Object.entries(env)) {
171
+ if (v instanceof GCPSecretBuilder) {
172
+ await v.awaitValue();
173
+ let val = v.resolvedValue;
174
+ if (val === null && isDryRun) {
175
+ val = v._value ?? "DRYRUN_SECRET";
176
+ }
177
+ if (val === null) {
178
+ throw new Error(`Secret "${v.name}" has no value - create it first or call .plainText()/.keyValue() in the stack`);
179
+ }
180
+ resolved[k] = val;
181
+ }
182
+ else {
183
+ resolved[k] = v;
184
+ }
185
+ }
186
+ return resolved;
187
+ }
@@ -0,0 +1 @@
1
+ export {};