puls-dev 0.2.1 ā 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.
- package/dist/core/config.d.ts +5 -0
- package/dist/providers/firebase/appcheck.d.ts +15 -0
- package/dist/providers/firebase/appcheck.js +109 -0
- package/dist/providers/firebase/appcheck.test.d.ts +1 -0
- package/dist/providers/firebase/appcheck.test.js +141 -0
- package/dist/providers/firebase/index.d.ts +2 -0
- package/dist/providers/firebase/index.js +2 -0
- package/dist/providers/gcp/api.d.ts +10 -0
- package/dist/providers/gcp/api.js +111 -0
- package/dist/providers/gcp/clouddns.d.ts +37 -0
- package/dist/providers/gcp/clouddns.js +284 -0
- package/dist/providers/gcp/clouddns.test.d.ts +1 -0
- package/dist/providers/gcp/clouddns.test.js +259 -0
- package/dist/providers/gcp/cloudrun.d.ts +31 -0
- package/dist/providers/gcp/cloudrun.js +240 -0
- package/dist/providers/gcp/cloudrun.test.d.ts +1 -0
- package/dist/providers/gcp/cloudrun.test.js +281 -0
- package/dist/providers/gcp/cloudsql.d.ts +37 -0
- package/dist/providers/gcp/cloudsql.js +262 -0
- package/dist/providers/gcp/cloudsql.test.d.ts +1 -0
- package/dist/providers/gcp/cloudsql.test.js +295 -0
- package/dist/providers/gcp/iam.d.ts +38 -0
- package/dist/providers/gcp/iam.js +309 -0
- package/dist/providers/gcp/iam.test.d.ts +1 -0
- package/dist/providers/gcp/iam.test.js +305 -0
- package/dist/providers/gcp/index.d.ts +19 -0
- package/dist/providers/gcp/index.js +19 -0
- package/dist/providers/gcp/pubsub.d.ts +31 -0
- package/dist/providers/gcp/pubsub.js +227 -0
- package/dist/providers/gcp/pubsub.test.d.ts +1 -0
- package/dist/providers/gcp/pubsub.test.js +244 -0
- package/dist/providers/gcp/secrets.d.ts +21 -0
- package/dist/providers/gcp/secrets.js +187 -0
- package/dist/providers/gcp/secrets.test.d.ts +1 -0
- package/dist/providers/gcp/secrets.test.js +264 -0
- 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.
|
|
3
|
+
"version": "0.2.2",
|
|
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": [
|