puls-dev 0.2.1 → 0.2.3

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 (37) hide show
  1. package/README.md +13 -5
  2. package/dist/core/config.d.ts +5 -0
  3. package/dist/providers/firebase/appcheck.d.ts +15 -0
  4. package/dist/providers/firebase/appcheck.js +109 -0
  5. package/dist/providers/firebase/appcheck.test.d.ts +1 -0
  6. package/dist/providers/firebase/appcheck.test.js +141 -0
  7. package/dist/providers/firebase/index.d.ts +2 -0
  8. package/dist/providers/firebase/index.js +2 -0
  9. package/dist/providers/gcp/api.d.ts +10 -0
  10. package/dist/providers/gcp/api.js +111 -0
  11. package/dist/providers/gcp/clouddns.d.ts +37 -0
  12. package/dist/providers/gcp/clouddns.js +284 -0
  13. package/dist/providers/gcp/clouddns.test.d.ts +1 -0
  14. package/dist/providers/gcp/clouddns.test.js +259 -0
  15. package/dist/providers/gcp/cloudrun.d.ts +31 -0
  16. package/dist/providers/gcp/cloudrun.js +240 -0
  17. package/dist/providers/gcp/cloudrun.test.d.ts +1 -0
  18. package/dist/providers/gcp/cloudrun.test.js +281 -0
  19. package/dist/providers/gcp/cloudsql.d.ts +37 -0
  20. package/dist/providers/gcp/cloudsql.js +262 -0
  21. package/dist/providers/gcp/cloudsql.test.d.ts +1 -0
  22. package/dist/providers/gcp/cloudsql.test.js +295 -0
  23. package/dist/providers/gcp/iam.d.ts +38 -0
  24. package/dist/providers/gcp/iam.js +309 -0
  25. package/dist/providers/gcp/iam.test.d.ts +1 -0
  26. package/dist/providers/gcp/iam.test.js +305 -0
  27. package/dist/providers/gcp/index.d.ts +19 -0
  28. package/dist/providers/gcp/index.js +19 -0
  29. package/dist/providers/gcp/pubsub.d.ts +31 -0
  30. package/dist/providers/gcp/pubsub.js +227 -0
  31. package/dist/providers/gcp/pubsub.test.d.ts +1 -0
  32. package/dist/providers/gcp/pubsub.test.js +244 -0
  33. package/dist/providers/gcp/secrets.d.ts +21 -0
  34. package/dist/providers/gcp/secrets.js +187 -0
  35. package/dist/providers/gcp/secrets.test.d.ts +1 -0
  36. package/dist/providers/gcp/secrets.test.js +264 -0
  37. package/package.json +5 -1
@@ -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 {};
@@ -0,0 +1,264 @@
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 { GCPSecretBuilder } from "./secrets.js";
5
+ import { GCPCloudRunBuilder } from "./cloudrun.js";
6
+ import { Config } from "../../core/config.js";
7
+ describe("GCPSecretBuilder Unit Tests", () => {
8
+ let originalFetch;
9
+ let fetchCalls = [];
10
+ let mockResponses = {};
11
+ beforeEach(() => {
12
+ Config.set({
13
+ dryRun: false,
14
+ providers: {
15
+ gcp: {
16
+ projectId: "my-gcp-project",
17
+ serviceAccountPath: "/fake/sa.json",
18
+ region: "us-central1",
19
+ },
20
+ },
21
+ });
22
+ originalFetch = globalThis.fetch;
23
+ fetchCalls = [];
24
+ mockResponses = {};
25
+ globalThis.fetch = async (input, init) => {
26
+ const url = String(input);
27
+ const method = init?.method ?? "GET";
28
+ let body;
29
+ if (init?.body) {
30
+ if (typeof init.body === "string") {
31
+ try {
32
+ body = JSON.parse(init.body);
33
+ }
34
+ catch {
35
+ body = init.body;
36
+ }
37
+ }
38
+ else {
39
+ body = "[Binary/Buffer Body]";
40
+ }
41
+ }
42
+ const headers = init?.headers;
43
+ fetchCalls.push({ url, method, body, headers });
44
+ const matchKey = Object.keys(mockResponses)
45
+ .filter((key) => {
46
+ const [mMethod, mPath] = key.split(" ");
47
+ return method === mMethod && url.includes(mPath);
48
+ })
49
+ .sort((a, b) => b.split(" ")[1].length - a.split(" ")[1].length)[0];
50
+ if (matchKey) {
51
+ const resp = mockResponses[matchKey];
52
+ return {
53
+ ok: resp.status >= 200 && resp.status < 300,
54
+ status: resp.status,
55
+ json: async () => resp.body,
56
+ text: async () => JSON.stringify(resp.body),
57
+ };
58
+ }
59
+ return {
60
+ ok: false,
61
+ status: 404,
62
+ json: async () => ({ message: `Endpoint not mocked: ${method} ${url}` }),
63
+ text: async () => `Endpoint not mocked: ${method} ${url}`,
64
+ };
65
+ };
66
+ mock.method(GoogleAuth.prototype, "getClient", async () => {
67
+ return {
68
+ getAccessToken: async () => ({ token: "fake-gcp-token" }),
69
+ };
70
+ });
71
+ });
72
+ afterEach(() => {
73
+ globalThis.fetch = originalFetch;
74
+ mock.restoreAll();
75
+ });
76
+ test("fluent builder api sets properties and resolves values correctly", async () => {
77
+ mockResponses["GET /secrets/fluent-sec"] = {
78
+ status: 200,
79
+ body: { name: "projects/my-gcp-project/secrets/fluent-sec" },
80
+ };
81
+ mockResponses["GET /secrets/fluent-sec/versions/latest:access"] = {
82
+ status: 200,
83
+ body: { payload: { data: Buffer.from("super-secret-text").toString("base64") } },
84
+ };
85
+ const builder = new GCPSecretBuilder("fluent-sec")
86
+ .plainText("super-secret-text");
87
+ assert.strictEqual(builder._value, "super-secret-text");
88
+ const val = await builder.awaitValue();
89
+ assert.strictEqual(val, "super-secret-text");
90
+ assert.strictEqual(builder.resolvedValue, "super-secret-text");
91
+ });
92
+ test("keyValue helper serializes object correctly", () => {
93
+ const builder = new GCPSecretBuilder("kv-sec")
94
+ .keyValue({ apiKey: "12345", dbPass: "secret" });
95
+ assert.strictEqual(builder._value, JSON.stringify({ apiKey: "12345", dbPass: "secret" }));
96
+ });
97
+ test("runs in dry-run mode safely and logs plans", async () => {
98
+ Config.set({
99
+ dryRun: true,
100
+ providers: {
101
+ gcp: {
102
+ projectId: "my-gcp-project",
103
+ serviceAccountPath: "/fake/sa.json",
104
+ },
105
+ },
106
+ });
107
+ mockResponses["GET /secrets/dry-run-sec"] = {
108
+ status: 404,
109
+ body: { message: "Not found" },
110
+ };
111
+ const builder = new GCPSecretBuilder("dry-run-sec")
112
+ .plainText("my-planned-value");
113
+ const result = await builder.deploy();
114
+ assert.strictEqual(result.name, "dry-run-sec");
115
+ assert.strictEqual(result.value, "my-planned-value");
116
+ // No write calls should be sent in dry-run mode
117
+ const writeCalls = fetchCalls.filter((c) => c.method !== "GET");
118
+ assert.strictEqual(writeCalls.length, 0);
119
+ });
120
+ test("creates a new secret when missing", async () => {
121
+ // 1. Mock GET returned 404 (discovery metadata)
122
+ mockResponses["GET /secrets/new-sec"] = {
123
+ status: 404,
124
+ body: { message: "Not found" },
125
+ };
126
+ // 2. Mock POST (create secret container)
127
+ mockResponses["POST /secrets?secretId=new-sec"] = {
128
+ status: 200,
129
+ body: { name: "projects/my-gcp-project/secrets/new-sec" },
130
+ };
131
+ // 3. Mock POST (add version)
132
+ mockResponses["POST /secrets/new-sec:addVersion"] = {
133
+ status: 200,
134
+ body: { name: "projects/my-gcp-project/secrets/new-sec/versions/1" },
135
+ };
136
+ const builder = new GCPSecretBuilder("new-sec")
137
+ .plainText("my-new-secret-value");
138
+ const result = await builder.deploy();
139
+ assert.strictEqual(result.name, "new-sec");
140
+ assert.strictEqual(result.value, "my-new-secret-value");
141
+ assert.strictEqual(result.arn, "projects/my-gcp-project/secrets/new-sec");
142
+ // Verify correct calls were made
143
+ const postSecret = fetchCalls.find((c) => c.method === "POST" && c.url.includes("secretId=new-sec"));
144
+ assert.ok(postSecret);
145
+ const postVersion = fetchCalls.find((c) => c.method === "POST" && c.url.includes(":addVersion"));
146
+ assert.ok(postVersion);
147
+ const expectedBase64 = Buffer.from("my-new-secret-value").toString("base64");
148
+ assert.strictEqual(postVersion.body.payload.data, expectedBase64);
149
+ });
150
+ test("updates an existing secret if value differs", async () => {
151
+ // 1. Mock GET (discovery) returns existing secret
152
+ mockResponses["GET /secrets/existing-sec"] = {
153
+ status: 200,
154
+ body: { name: "projects/my-gcp-project/secrets/existing-sec" },
155
+ };
156
+ // 2. Mock GET (access latest version) returns old value
157
+ mockResponses["GET /secrets/existing-sec/versions/latest:access"] = {
158
+ status: 200,
159
+ body: { payload: { data: Buffer.from("old-value").toString("base64") } },
160
+ };
161
+ // 3. Mock POST (add version) for updated value
162
+ mockResponses["POST /secrets/existing-sec:addVersion"] = {
163
+ status: 200,
164
+ body: { name: "projects/my-gcp-project/secrets/existing-sec/versions/2" },
165
+ };
166
+ const builder = new GCPSecretBuilder("existing-sec")
167
+ .plainText("new-value"); // Value changed!
168
+ const result = await builder.deploy();
169
+ assert.strictEqual(result.name, "existing-sec");
170
+ assert.strictEqual(result.value, "new-value");
171
+ // Verify addVersion was called
172
+ const postVersion = fetchCalls.filter((c) => c.method === "POST" && c.url.includes(":addVersion"));
173
+ assert.strictEqual(postVersion.length, 1);
174
+ assert.strictEqual(postVersion[0].body.payload.data, Buffer.from("new-value").toString("base64"));
175
+ });
176
+ test("skips updating secret if value is identical", async () => {
177
+ // 1. Mock GET (discovery) returns existing secret
178
+ mockResponses["GET /secrets/identical-sec"] = {
179
+ status: 200,
180
+ body: { name: "projects/my-gcp-project/secrets/identical-sec" },
181
+ };
182
+ // 2. Mock GET (access latest version) returns same value
183
+ mockResponses["GET /secrets/identical-sec/versions/latest:access"] = {
184
+ status: 200,
185
+ body: { payload: { data: Buffer.from("same-value").toString("base64") } },
186
+ };
187
+ const builder = new GCPSecretBuilder("identical-sec")
188
+ .plainText("same-value");
189
+ const result = await builder.deploy();
190
+ assert.strictEqual(result.name, "identical-sec");
191
+ assert.strictEqual(result.value, "same-value");
192
+ // Verify NO write calls (POST or DELETE) occurred
193
+ const writeCalls = fetchCalls.filter((c) => c.method !== "GET");
194
+ assert.strictEqual(writeCalls.length, 0);
195
+ });
196
+ test("destroys an existing secret successfully", async () => {
197
+ // 1. Mock GET (discovery on destroy) returns existing secret
198
+ mockResponses["GET /secrets/to-delete-sec"] = {
199
+ status: 200,
200
+ body: { name: "projects/my-gcp-project/secrets/to-delete-sec" },
201
+ };
202
+ // 2. Mock DELETE
203
+ mockResponses["DELETE /secrets/to-delete-sec"] = {
204
+ status: 200,
205
+ body: {},
206
+ };
207
+ const builder = new GCPSecretBuilder("to-delete-sec");
208
+ const result = await builder.destroy();
209
+ assert.deepStrictEqual(result, { destroyed: "to-delete-sec" });
210
+ // Verify DELETE was called
211
+ const deleteCalls = fetchCalls.filter((c) => c.method === "DELETE");
212
+ assert.strictEqual(deleteCalls.length, 1);
213
+ assert.strictEqual(deleteCalls[0].url.includes("/secrets/to-delete-sec"), true);
214
+ });
215
+ test("injects secret into Cloud Run microservice environment variables", async () => {
216
+ // 1. Stateful mock for GET /secrets/db-pass: returns 404 then 200
217
+ let secCallCount = 0;
218
+ mockResponses["GET /secrets/db-pass"] = {
219
+ get status() {
220
+ secCallCount++;
221
+ return secCallCount === 1 ? 404 : 200;
222
+ },
223
+ get body() {
224
+ if (secCallCount === 1)
225
+ return { message: "Not found" };
226
+ return { name: "projects/my-gcp-project/secrets/db-pass" };
227
+ },
228
+ };
229
+ mockResponses["GET /secrets/db-pass/versions/latest:access"] = {
230
+ status: 200,
231
+ body: { payload: { data: Buffer.from("mypassword").toString("base64") } },
232
+ };
233
+ mockResponses["POST /secrets?secretId=db-pass"] = { status: 200, body: {} };
234
+ mockResponses["POST /secrets/db-pass:addVersion"] = { status: 200, body: {} };
235
+ // Cloud Run mocks
236
+ mockResponses["GET /services/web-app"] = {
237
+ status: 404,
238
+ body: { message: "Not found" },
239
+ };
240
+ mockResponses["POST /services?serviceId=web-app"] = {
241
+ status: 200,
242
+ body: { name: "projects/my-gcp-project/locations/us-central1/services/web-app", uri: "https://web-app.run.app" },
243
+ };
244
+ mockResponses["POST /services/web-app:setIamPolicy"] = { status: 200, body: {} };
245
+ const secret = new GCPSecretBuilder("db-pass").plainText("mypassword");
246
+ const app = new GCPCloudRunBuilder("web-app")
247
+ .image("gcr.io/my-proj/my-image:latest")
248
+ .env({
249
+ NODE_ENV: "production",
250
+ DATABASE_PASSWORD: secret, // Wired directly!
251
+ });
252
+ // Deploy secret first, which populates the resolvedValue
253
+ await secret.deploy();
254
+ // Deploy Cloud Run app
255
+ await app.deploy();
256
+ // Verify Cloud Run creation request body resolved the secret variable
257
+ const runPost = fetchCalls.find((c) => c.method === "POST" && c.url.includes("serviceId=web-app"));
258
+ assert.ok(runPost);
259
+ const envs = runPost.body.template.containers[0].env;
260
+ const dbPassEnv = envs.find((e) => e.name === "DATABASE_PASSWORD");
261
+ assert.ok(dbPassEnv);
262
+ assert.strictEqual(dbPassEnv.value, "mypassword"); // Successfully resolved!
263
+ });
264
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "puls-dev",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Intent-driven infrastructure-as-code with eager discovery and no state files.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -26,6 +26,10 @@
26
26
  "./firebase": {
27
27
  "types": "./dist/providers/firebase/index.d.ts",
28
28
  "default": "./dist/providers/firebase/index.js"
29
+ },
30
+ "./gcp": {
31
+ "types": "./dist/providers/gcp/index.d.ts",
32
+ "default": "./dist/providers/gcp/index.js"
29
33
  }
30
34
  },
31
35
  "files": [