38 | case 6: {
39 | this.project = refParts[1];
40 | this.name = refParts[3];
41 | this.version = refParts[5];
42 | break;
43 | }
44 | // projects//secrets/
45 | case 4: {
46 | this.project = refParts[1];
47 | this.name = refParts[3];
48 | this.version = 'latest';
49 | break;
50 | }
51 | //
//
52 | case 3: {
53 | this.project = refParts[0];
54 | this.name = refParts[1];
55 | this.version = refParts[2];
56 | break;
57 | }
58 | // /
59 | case 2: {
60 | this.project = refParts[0];
61 | this.name = refParts[1];
62 | this.version = 'latest';
63 | break;
64 | }
65 | default: {
66 | throw new TypeError(
67 | `Failed to parse secret reference "${s}": unknown format. Secrets ` +
68 | `should be of the format "projects/p/secrets/s/versions/v".`,
69 | );
70 | }
71 | }
72 | }
73 |
74 | /**
75 | * Returns the full GCP self link.
76 | *
77 | * @returns String self link.
78 | */
79 | public selfLink(): string {
80 | return `projects/${this.project}/secrets/${this.name}/versions/${this.version}`;
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/src/util.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2020 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | import fs from 'fs';
18 | import { posix } from 'path';
19 | import * as path from 'path';
20 |
21 | import * as Archiver from 'archiver';
22 | import {
23 | parseGcloudIgnore,
24 | parseKVString,
25 | toPlatformPath,
26 | } from '@google-github-actions/actions-utils';
27 | import ignore from 'ignore';
28 |
29 | import { EventFilter, SecretEnvVar, SecretVolume } from './client';
30 | import { SecretName } from './secret';
31 |
32 | /**
33 | * OnZipEntryFunction is a function that is called for each entry in the
34 | * archive.
35 | */
36 | export type OnZipEntryFunction = (entry: Archiver.EntryData) => void;
37 |
38 | /**
39 | * ZipOptions is used as input to the zip function.
40 | */
41 | export type ZipOptions = {
42 | /**
43 | * onZipAddEntry is called when an entry is added to the archive.
44 | */
45 | onZipAddEntry?: OnZipEntryFunction;
46 |
47 | /**
48 | * onZipIgnoreEntry is called when an entry is ignored due to an ignore
49 | * specification.
50 | */
51 | onZipIgnoreEntry?: OnZipEntryFunction;
52 | };
53 |
54 | /**
55 | * Zip a directory.
56 | *
57 | * @param dirPath Directory to zip.
58 | * @param outputPath Path to output file.
59 | * @param opts Options with which to invoke the zip.
60 | * @returns filepath of the created zip file.
61 | */
62 | export async function zipDir(
63 | dirPath: string,
64 | outputPath: string,
65 | opts?: ZipOptions,
66 | ): Promise {
67 | // Check dirpath
68 | if (!fs.existsSync(dirPath)) {
69 | throw new Error(`Unable to find ${dirPath}`);
70 | }
71 |
72 | // Create output file stream
73 | const output = fs.createWriteStream(outputPath);
74 |
75 | // Process gcloudignore
76 | const ignoreFile = toPlatformPath(path.join(dirPath, '.gcloudignore'));
77 | const ignores = await parseGcloudIgnore(ignoreFile);
78 | const ignorer = ignore().add(ignores);
79 | const ignoreFn = (entry: Archiver.EntryData): false | Archiver.EntryData => {
80 | if (ignorer.ignores(entry.name)) {
81 | if (opts?.onZipIgnoreEntry) opts.onZipIgnoreEntry(entry);
82 | return false;
83 | }
84 | return entry;
85 | };
86 |
87 | return new Promise((resolve, reject) => {
88 | // Initialize archive
89 | const archive = Archiver.create('zip', { zlib: { level: 7 } });
90 | archive.on('entry', (entry) => {
91 | // For some reason, TypeScript complains if this guard is outside the
92 | // closure. It would be more performant just not create this listener, but
93 | // alas...
94 | if (opts?.onZipAddEntry) opts.onZipAddEntry(entry);
95 | });
96 | archive.on('warning', (err) => reject(err));
97 | archive.on('error', (err) => reject(err));
98 | output.on('finish', () => resolve(outputPath));
99 |
100 | // Pipe all archive data to be written
101 | archive.pipe(output);
102 |
103 | // Add files in dir to archive iff file not ignored
104 | archive.directory(dirPath, false, ignoreFn);
105 |
106 | // Finish writing files
107 | archive.finalize();
108 | });
109 | }
110 |
111 | /**
112 | * RealEntryData is an extended form of entry data.
113 | */
114 | type RealEntryData = Archiver.EntryData & {
115 | sourcePath?: string;
116 | type?: string;
117 | };
118 |
119 | /**
120 | * formatEntry formats the given entry data into a single-line string.
121 | * @returns string
122 | */
123 | export function formatEntry(entry: RealEntryData): string {
124 | const name = entry.name;
125 | const mode = entry.mode || '000';
126 | const sourcePath = entry.sourcePath || 'unknown';
127 | const type = (entry.type || 'unknown').toUpperCase()[0];
128 | return `[${type}] (${mode}) ${name} => ${sourcePath}`;
129 | }
130 |
131 | /**
132 | * stringToInt is a helper that converts the given string into an integer. If
133 | * the given string is empty, it returns undefined. If the string is not empty
134 | * and parseInt fails (returns NaN), it throws an error. Otherwise, it returns
135 | * the integer value.
136 | *
137 | * @param str String to parse as an int.
138 | * @returns Parsed integer or undefined if the input was the empty string.
139 | */
140 | export function stringToInt(str: string): number | undefined {
141 | str = (str || '').trim().replace(/[_,]/g, '');
142 | if (str === '') {
143 | return undefined;
144 | }
145 |
146 | const result = parseInt(str);
147 | if (isNaN(result)) {
148 | throw new Error(`input "${str}" is not a number`);
149 | }
150 | return result;
151 | }
152 |
153 | /**
154 | * parseEventTriggerFilters is a helper that parses the inputs into a list of event
155 | * filters.
156 | */
157 | export function parseEventTriggerFilters(val: string): EventFilter[] | undefined {
158 | const kv = parseKVString(val);
159 | if (kv === undefined) {
160 | return undefined;
161 | }
162 |
163 | const result: EventFilter[] = [];
164 | for (const [key, value] of Object.entries(kv)) {
165 | if (value.startsWith('PATTERN:')) {
166 | result.push({
167 | attribute: key,
168 | value: value.slice(8),
169 | operator: 'match-path-pattern',
170 | });
171 | } else {
172 | result.push({
173 | attribute: key,
174 | value: value,
175 | });
176 | }
177 | }
178 |
179 | return result;
180 | }
181 |
182 | /**
183 | * parseSecrets parses the input as environment variable and volume mounted
184 | * secrets.
185 | */
186 | export function parseSecrets(
187 | val: string,
188 | ): [SecretEnvVar[] | undefined, SecretVolume[] | undefined] {
189 | const kv = parseKVString(val);
190 | if (kv === undefined) {
191 | return [undefined, undefined];
192 | }
193 |
194 | const secretEnvVars: SecretEnvVar[] = [];
195 | const secretVolumes: SecretVolume[] = [];
196 | for (const [key, value] of Object.entries(kv)) {
197 | const secretRef = new SecretName(value);
198 |
199 | if (key.startsWith('/')) {
200 | // SecretVolume
201 | const mountPath = posix.dirname(key);
202 | const pth = posix.basename(key);
203 |
204 | secretVolumes.push({
205 | mountPath: mountPath,
206 | projectId: secretRef.project,
207 | secret: secretRef.name,
208 | versions: [
209 | {
210 | path: pth,
211 | version: secretRef.version,
212 | },
213 | ],
214 | });
215 | } else {
216 | // SecretEnvVar
217 | secretEnvVars.push({
218 | key: key,
219 | projectId: secretRef.project,
220 | secret: secretRef.name,
221 | version: secretRef.version,
222 | });
223 | }
224 | }
225 |
226 | return [secretEnvVars, secretVolumes];
227 | }
228 |
--------------------------------------------------------------------------------
/tests/client.test.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2024 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | import { test } from 'node:test';
18 | import assert from 'node:assert';
19 |
20 | import os from 'os';
21 | import path from 'path';
22 | import crypto from 'crypto';
23 |
24 | import { skipIfMissingEnv } from '@google-github-actions/actions-utils';
25 |
26 | import { CloudFunctionsClient, CloudFunction, Environment, IngressSettings } from '../src/client';
27 | import { SecretName } from '../src/secret';
28 | import { zipDir } from '../src/util';
29 |
30 | const { TEST_PROJECT_ID, TEST_SERVICE_ACCOUNT_EMAIL, TEST_SECRET_VERSION_NAME } = process.env;
31 | const TEST_LOCATION = 'us-central1';
32 | const TEST_SEED = crypto.randomBytes(12).toString('hex').toLowerCase();
33 | const TEST_SEED_UPPER = TEST_SEED.toUpperCase();
34 | const TEST_FUNCTION_NAME = `unit-${TEST_SEED}`;
35 |
36 | test(
37 | 'lifecycle',
38 | {
39 | concurrency: true,
40 | skip: skipIfMissingEnv('TEST_AUTHENTICATED'),
41 | },
42 | async (suite) => {
43 | // Always try to delete the function
44 | suite.after(async function () {
45 | try {
46 | const client = new CloudFunctionsClient({
47 | projectID: TEST_PROJECT_ID,
48 | location: TEST_LOCATION,
49 | });
50 |
51 | await client.delete(TEST_FUNCTION_NAME);
52 | } catch {
53 | // do nothing
54 | }
55 | });
56 |
57 | await suite.test('can create, read, update, and delete', async () => {
58 | const secret = new SecretName(TEST_SECRET_VERSION_NAME);
59 |
60 | const client = new CloudFunctionsClient({
61 | projectID: TEST_PROJECT_ID,
62 | location: TEST_LOCATION,
63 | });
64 |
65 | const outputPath = path.join(os.tmpdir(), crypto.randomBytes(12).toString('hex'));
66 | const zipPath = await zipDir('tests/test-node-func', outputPath);
67 |
68 | // Generate upload URL
69 | const sourceUploadResp = await client.generateUploadURL(
70 | `projects/${TEST_PROJECT_ID}/locations/${TEST_LOCATION}`,
71 | );
72 |
73 | // Upload source
74 | await client.uploadSource(sourceUploadResp.uploadUrl, zipPath);
75 |
76 | const cf: CloudFunction = {
77 | name: TEST_FUNCTION_NAME,
78 | description: 'test function',
79 | environment: Environment.GEN_2,
80 | labels: {
81 | [`label1-${TEST_SEED}`]: `value1_${TEST_SEED}`,
82 | [`label2-${TEST_SEED}`]: `value2_${TEST_SEED}`,
83 | },
84 |
85 | buildConfig: {
86 | runtime: 'nodejs22',
87 | entryPoint: 'helloWorld',
88 | source: {
89 | storageSource: sourceUploadResp.storageSource,
90 | },
91 | environmentVariables: {
92 | [`BUILD_ENV_KEY1_${TEST_SEED_UPPER}`]: `VALUE1_${TEST_SEED}`,
93 | [`BUILD_ENV_KEY2_${TEST_SEED_UPPER}`]: `VALUE2_${TEST_SEED}`,
94 | },
95 | },
96 |
97 | serviceConfig: {
98 | allTrafficOnLatestRevision: true,
99 | availableCpu: '1',
100 | availableMemory: '512Mi',
101 | environmentVariables: {
102 | [`SERVICE_ENV_KEY1_${TEST_SEED_UPPER}`]: `VALUE1_${TEST_SEED}`,
103 | [`SERVICE_ENV_KEY2_${TEST_SEED_UPPER}`]: `VALUE2_${TEST_SEED}`,
104 | },
105 | ingressSettings: IngressSettings.ALLOW_ALL,
106 | maxInstanceCount: 5,
107 | minInstanceCount: 2,
108 | secretEnvironmentVariables: [
109 | {
110 | key: `SECRET1_${TEST_SEED_UPPER}`,
111 | projectId: secret.project,
112 | secret: secret.name,
113 | version: secret.version,
114 | },
115 | ],
116 | secretVolumes: [
117 | {
118 | mountPath: `/etc/secrets/one_${TEST_SEED}`,
119 | projectId: secret.project,
120 | secret: secret.name,
121 | versions: [
122 | {
123 | path: 'value1',
124 | version: secret.version,
125 | },
126 | ],
127 | },
128 | ],
129 | serviceAccountEmail: TEST_SERVICE_ACCOUNT_EMAIL,
130 | timeoutSeconds: 300,
131 | },
132 | };
133 |
134 | // Create
135 | const createResp = await client.create(cf, {
136 | onDebug: (f) => {
137 | process.stdout.write('\n\n\n\n');
138 | process.stdout.write(f());
139 | process.stdout.write('\n\n\n\n');
140 | },
141 | });
142 | assert.ok(createResp?.url);
143 |
144 | // Read
145 | const getResp = await client.get(cf.name);
146 | assert.ok(getResp.name.endsWith(TEST_FUNCTION_NAME)); // The response is the fully-qualified name
147 | assert.deepStrictEqual(getResp.description, 'test function');
148 | assert.deepStrictEqual(getResp.labels, {
149 | [`label1-${TEST_SEED}`]: `value1_${TEST_SEED}`,
150 | [`label2-${TEST_SEED}`]: `value2_${TEST_SEED}`,
151 | });
152 | assert.deepStrictEqual(getResp.buildConfig.runtime, 'nodejs22');
153 | assert.deepStrictEqual(getResp.buildConfig.environmentVariables, {
154 | [`BUILD_ENV_KEY1_${TEST_SEED_UPPER}`]: `VALUE1_${TEST_SEED}`,
155 | [`BUILD_ENV_KEY2_${TEST_SEED_UPPER}`]: `VALUE2_${TEST_SEED}`,
156 | });
157 | assert.deepStrictEqual(getResp.buildConfig.entryPoint, 'helloWorld');
158 | assert.deepStrictEqual(getResp.serviceConfig.availableCpu, '1');
159 | assert.deepStrictEqual(getResp.serviceConfig.availableMemory, '512Mi');
160 | assert.deepStrictEqual(getResp.serviceConfig.environmentVariables, {
161 | LOG_EXECUTION_ID: 'true', // inserted by GCP
162 | [`SERVICE_ENV_KEY1_${TEST_SEED_UPPER}`]: `VALUE1_${TEST_SEED}`,
163 | [`SERVICE_ENV_KEY2_${TEST_SEED_UPPER}`]: `VALUE2_${TEST_SEED}`,
164 | });
165 | assert.deepStrictEqual(getResp.serviceConfig.ingressSettings, 'ALLOW_ALL');
166 | assert.deepStrictEqual(getResp.serviceConfig.maxInstanceCount, 5);
167 | assert.deepStrictEqual(getResp.serviceConfig.minInstanceCount, 2);
168 | assert.deepStrictEqual(getResp.serviceConfig.secretEnvironmentVariables, [
169 | {
170 | key: `SECRET1_${TEST_SEED_UPPER}`,
171 | projectId: secret.project,
172 | secret: secret.name,
173 | version: secret.version,
174 | },
175 | ]);
176 | assert.deepStrictEqual(getResp.serviceConfig.secretVolumes, [
177 | {
178 | mountPath: `/etc/secrets/one_${TEST_SEED}`,
179 | projectId: secret.project,
180 | secret: secret.name,
181 | versions: [
182 | {
183 | path: 'value1',
184 | version: secret.version,
185 | },
186 | ],
187 | },
188 | ]);
189 | assert.deepStrictEqual(getResp.serviceConfig.serviceAccountEmail, TEST_SERVICE_ACCOUNT_EMAIL);
190 | assert.deepStrictEqual(getResp.serviceConfig.timeoutSeconds, 300);
191 |
192 | // Update
193 | const sourceUploadUpdateResp = await client.generateUploadURL(
194 | `projects/${TEST_PROJECT_ID}/locations/${TEST_LOCATION}`,
195 | );
196 | await client.uploadSource(sourceUploadUpdateResp.uploadUrl, zipPath);
197 |
198 | const cf2: CloudFunction = {
199 | name: TEST_FUNCTION_NAME,
200 | description: 'test function2',
201 | labels: {
202 | [`label3-${TEST_SEED}`]: `value3_${TEST_SEED}`,
203 | [`label4-${TEST_SEED}`]: `value4_${TEST_SEED}`,
204 | },
205 |
206 | buildConfig: {
207 | runtime: 'nodejs20',
208 | entryPoint: 'helloWorld',
209 | source: {
210 | storageSource: sourceUploadResp.storageSource,
211 | },
212 | environmentVariables: {
213 | [`BUILD_ENV_KEY3_${TEST_SEED_UPPER}`]: `VALUE3_${TEST_SEED}`,
214 | [`BUILD_ENV_KEY4_${TEST_SEED_UPPER}`]: `VALUE4_${TEST_SEED}`,
215 | },
216 | },
217 |
218 | serviceConfig: {
219 | allTrafficOnLatestRevision: true,
220 | availableMemory: '1Gi',
221 | environmentVariables: {
222 | [`SERVICE_ENV_KEY3_${TEST_SEED_UPPER}`]: `VALUE3_${TEST_SEED}`,
223 | [`SERVICE_ENV_KEY4_${TEST_SEED_UPPER}`]: `VALUE4_${TEST_SEED}`,
224 | },
225 | ingressSettings: IngressSettings.ALLOW_INTERNAL_AND_GCLB,
226 | maxInstanceCount: 3,
227 | minInstanceCount: 1,
228 | secretEnvironmentVariables: [
229 | {
230 | key: `SECRET2_${TEST_SEED_UPPER}`,
231 | projectId: secret.project,
232 | secret: secret.name,
233 | version: secret.version,
234 | },
235 | ],
236 | secretVolumes: [
237 | {
238 | mountPath: `/etc/secrets/two_${TEST_SEED}`,
239 | projectId: secret.project,
240 | secret: secret.name,
241 | versions: [
242 | {
243 | path: 'value2',
244 | version: secret.version,
245 | },
246 | ],
247 | },
248 | ],
249 | serviceAccountEmail: TEST_SERVICE_ACCOUNT_EMAIL,
250 | timeoutSeconds: 30,
251 | },
252 | };
253 |
254 | const patchResp = await client.patch(cf2, {
255 | onDebug: (f) => {
256 | process.stdout.write('\n\n\n\n');
257 | process.stdout.write(f());
258 | process.stdout.write('\n\n\n\n');
259 | },
260 | });
261 | assert.ok(patchResp.name.endsWith(TEST_FUNCTION_NAME)); // The response is the fully-qualified name
262 | assert.deepStrictEqual(patchResp.description, 'test function2');
263 | assert.deepStrictEqual(patchResp.labels, {
264 | [`label3-${TEST_SEED}`]: `value3_${TEST_SEED}`,
265 | [`label4-${TEST_SEED}`]: `value4_${TEST_SEED}`,
266 | });
267 | assert.deepStrictEqual(patchResp.buildConfig.runtime, 'nodejs20');
268 | assert.deepStrictEqual(patchResp.buildConfig.entryPoint, 'helloWorld');
269 | assert.deepStrictEqual(patchResp.buildConfig.environmentVariables, {
270 | [`BUILD_ENV_KEY3_${TEST_SEED_UPPER}`]: `VALUE3_${TEST_SEED}`,
271 | [`BUILD_ENV_KEY4_${TEST_SEED_UPPER}`]: `VALUE4_${TEST_SEED}`,
272 | });
273 | assert.deepStrictEqual(patchResp.serviceConfig.availableMemory, '1Gi');
274 | assert.deepStrictEqual(patchResp.serviceConfig.environmentVariables, {
275 | LOG_EXECUTION_ID: 'true', // inserted by GCP
276 | [`SERVICE_ENV_KEY3_${TEST_SEED_UPPER}`]: `VALUE3_${TEST_SEED}`,
277 | [`SERVICE_ENV_KEY4_${TEST_SEED_UPPER}`]: `VALUE4_${TEST_SEED}`,
278 | });
279 | assert.deepStrictEqual(patchResp.serviceConfig.ingressSettings, 'ALLOW_INTERNAL_AND_GCLB');
280 | assert.deepStrictEqual(patchResp.serviceConfig.maxInstanceCount, 3);
281 | assert.deepStrictEqual(patchResp.serviceConfig.minInstanceCount, 1);
282 | assert.deepStrictEqual(patchResp.serviceConfig.secretEnvironmentVariables, [
283 | {
284 | key: `SECRET2_${TEST_SEED_UPPER}`,
285 | projectId: secret.project,
286 | secret: secret.name,
287 | version: secret.version,
288 | },
289 | ]);
290 | assert.deepStrictEqual(patchResp.serviceConfig.secretVolumes, [
291 | {
292 | mountPath: `/etc/secrets/two_${TEST_SEED}`,
293 | projectId: secret.project,
294 | secret: secret.name,
295 | versions: [
296 | {
297 | path: 'value2',
298 | version: secret.version,
299 | },
300 | ],
301 | },
302 | ]);
303 | assert.deepStrictEqual(
304 | patchResp.serviceConfig.serviceAccountEmail,
305 | TEST_SERVICE_ACCOUNT_EMAIL,
306 | );
307 | assert.deepStrictEqual(patchResp.serviceConfig.timeoutSeconds, 30);
308 |
309 | // Delete
310 | const deleteResp = await client.delete(createResp.name);
311 | assert.ok(deleteResp.done);
312 | });
313 | },
314 | );
315 |
316 | test('#getSafe', { concurrency: true }, async (suite) => {
317 | await suite.test('does not error on a 404', async (t) => {
318 | t.mock.method(CloudFunctionsClient.prototype, 'get', () => {
319 | throw new Error(`
320 | {
321 | "error": {
322 | "code": 404,
323 | "message": "Function my-function does not exist",
324 | "status": "NOT_FOUND"
325 | }
326 | }
327 | `);
328 | });
329 |
330 | const client = new CloudFunctionsClient();
331 | const result = await client.getSafe('projects/p/functions/f');
332 | assert.deepStrictEqual(result, null);
333 | });
334 |
335 | await suite.test('errors on a 403', async (t) => {
336 | t.mock.method(CloudFunctionsClient.prototype, 'get', () => {
337 | throw new Error(`
338 | {
339 | "error": {
340 | "code": 403,
341 | "message": "Permission denied",
342 | "status": "PERMISSION_DENIED"
343 | }
344 | }
345 | `);
346 | });
347 |
348 | const client = new CloudFunctionsClient();
349 | await assert.rejects(async () => {
350 | await client.getSafe('projects/p/functions/f');
351 | }, 'failed to lookup existing function');
352 | });
353 | });
354 |
355 | test('#fullResourceName', { concurrency: true }, async (suite) => {
356 | const cases = [
357 | {
358 | name: 'empty name',
359 | client: new CloudFunctionsClient(),
360 | input: '',
361 | error: 'name cannot be empty',
362 | },
363 | {
364 | name: 'empty name spaces',
365 | client: new CloudFunctionsClient(),
366 | input: ' ',
367 | error: 'name cannot be empty',
368 | },
369 | {
370 | name: 'client missing project id',
371 | client: new CloudFunctionsClient({ projectID: '' }),
372 | input: 'f',
373 | error: 'Failed to get project ID to build resource name',
374 | },
375 | {
376 | name: 'client missing location',
377 | client: new CloudFunctionsClient({ projectID: 'p', location: '' }),
378 | input: 'f',
379 | error: 'Failed to get location',
380 | },
381 | {
382 | name: 'invalid resource name',
383 | client: new CloudFunctionsClient(),
384 | input: 'projects/foo',
385 | error: 'Invalid resource name',
386 | },
387 | {
388 | name: 'full resource name',
389 | client: new CloudFunctionsClient(),
390 | input: 'projects/p/locations/l/functions/f',
391 | expected: 'projects/p/locations/l/functions/f',
392 | },
393 | {
394 | name: 'builds location',
395 | client: new CloudFunctionsClient({ projectID: 'p', location: 'l' }),
396 | input: 'f',
397 | expected: 'projects/p/locations/l/functions/f',
398 | },
399 | ];
400 |
401 | for await (const tc of cases) {
402 | await suite.test(tc.name, async () => {
403 | if (tc.expected) {
404 | const actual = tc.client.fullResourceName(tc.input);
405 | assert.deepStrictEqual(actual, tc.expected);
406 | } else if (tc.error) {
407 | assert.throws(() => {
408 | tc.client.fullResourceName(tc.input);
409 | }, new RegExp(tc.error));
410 | }
411 | });
412 | }
413 | });
414 |
415 | test('#parentFromName', { concurrency: true }, async (suite) => {
416 | const client = new CloudFunctionsClient();
417 |
418 | const cases = [
419 | {
420 | name: 'empty string',
421 | input: '',
422 | error: 'Invalid or missing name',
423 | },
424 | {
425 | name: 'not enough parts',
426 | input: 'foo/bar',
427 | error: 'Invalid or missing name',
428 | },
429 | {
430 | name: 'extracts parent',
431 | input: 'projects/p/locations/l/functions/f',
432 | expected: 'projects/p/locations/l',
433 | },
434 | ];
435 |
436 | for await (const tc of cases) {
437 | await suite.test(tc.name, async () => {
438 | if (tc.expected) {
439 | const actual = client.parentFromName(tc.input);
440 | assert.deepStrictEqual(actual, tc.expected);
441 | } else if (tc.error) {
442 | assert.throws(() => {
443 | client.parentFromName(tc.input);
444 | }, new RegExp(tc.error));
445 | }
446 | });
447 | }
448 | });
449 |
--------------------------------------------------------------------------------
/tests/secret.test.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2021 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | import { test } from 'node:test';
18 | import assert from 'node:assert';
19 |
20 | import { SecretName } from '../src/secret';
21 |
22 | test('SecretName', { concurrency: true }, async (suite) => {
23 | const cases = [
24 | {
25 | name: 'empty string',
26 | input: '',
27 | error: 'Missing secret name',
28 | },
29 | {
30 | name: 'null',
31 | input: null,
32 | error: 'Missing secret name',
33 | },
34 | {
35 | name: 'undefined',
36 | input: undefined,
37 | error: 'Missing secret name',
38 | },
39 | {
40 | name: 'bad resource name',
41 | input: 'projects/fruits/secrets/apple/versions/123/subversions/5',
42 | error: 'Failed to parse secret reference',
43 | },
44 | {
45 | name: 'bad resource name',
46 | input: 'projects/fruits/secrets/apple/banana/bacon/pants',
47 | error: 'Failed to parse secret reference',
48 | },
49 | {
50 | name: 'full resource name',
51 | input: 'projects/fruits/secrets/apple/versions/123',
52 | expected: {
53 | project: 'fruits',
54 | secret: 'apple',
55 | version: '123',
56 | },
57 | },
58 | {
59 | name: 'full resource name without version',
60 | input: 'projects/fruits/secrets/apple',
61 | expected: {
62 | project: 'fruits',
63 | secret: 'apple',
64 | version: 'latest',
65 | },
66 | },
67 | {
68 | name: 'short ref',
69 | input: 'fruits/apple/123',
70 | expected: {
71 | project: 'fruits',
72 | secret: 'apple',
73 | version: '123',
74 | },
75 | },
76 | {
77 | name: 'short ref without version',
78 | input: 'fruits/apple',
79 | expected: {
80 | project: 'fruits',
81 | secret: 'apple',
82 | version: 'latest',
83 | },
84 | },
85 | ];
86 |
87 | for await (const tc of cases) {
88 | await suite.test(tc.name, async () => {
89 | if (tc.error) {
90 | assert.throws(() => {
91 | new SecretName(tc.input);
92 | }, new RegExp(tc.error));
93 | } else {
94 | const secret = new SecretName(tc.input);
95 | assert.deepStrictEqual(secret.project, tc.expected?.project);
96 | assert.deepStrictEqual(secret.name, tc.expected?.secret);
97 | assert.deepStrictEqual(secret.version, tc.expected?.version);
98 | }
99 | });
100 | }
101 | });
102 |
--------------------------------------------------------------------------------
/tests/test-func-ignore-node/.gcloudignore:
--------------------------------------------------------------------------------
1 | bar/
2 |
--------------------------------------------------------------------------------
/tests/test-func-ignore-node/bar/bar.txt:
--------------------------------------------------------------------------------
1 | test
--------------------------------------------------------------------------------
/tests/test-func-ignore-node/bar/baz/baz.txt:
--------------------------------------------------------------------------------
1 | test
--------------------------------------------------------------------------------
/tests/test-func-ignore-node/foo/data.txt:
--------------------------------------------------------------------------------
1 | data
--------------------------------------------------------------------------------
/tests/test-func-ignore-node/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Responds to any HTTP request.
3 | *
4 | * @param {!express:Request} req HTTP request context.
5 | * @param {!express:Response} res HTTP response context.
6 | */
7 | exports.helloWorld = (req, res) => {
8 | let message = req.query.message || req.body.message || 'Hello World!!';
9 | res.status(200).send(message);
10 | };
11 |
--------------------------------------------------------------------------------
/tests/test-func-ignore-node/notIgnored.txt:
--------------------------------------------------------------------------------
1 | baz
2 |
--------------------------------------------------------------------------------
/tests/test-func-ignore-node/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sample-http",
3 | "version": "0.0.1"
4 | }
5 |
--------------------------------------------------------------------------------
/tests/test-func-ignore/.gcloudignore:
--------------------------------------------------------------------------------
1 | *.txt
2 | .gcloudignore
--------------------------------------------------------------------------------
/tests/test-func-ignore/ignore.txt:
--------------------------------------------------------------------------------
1 | foo
--------------------------------------------------------------------------------
/tests/test-func-ignore/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Responds to any HTTP request.
3 | *
4 | * @param {!express:Request} req HTTP request context.
5 | * @param {!express:Response} res HTTP response context.
6 | */
7 | exports.helloWorld = (req, res) => {
8 | let message = req.query.message || req.body.message || 'Hello World!!';
9 | res.status(200).send(message);
10 | };
11 |
--------------------------------------------------------------------------------
/tests/test-func-ignore/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sample-http",
3 | "version": "0.0.1"
4 | }
5 |
--------------------------------------------------------------------------------
/tests/test-node-func/.dotfile:
--------------------------------------------------------------------------------
1 | I exist!
2 |
--------------------------------------------------------------------------------
/tests/test-node-func/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Responds to any HTTP request.
3 | *
4 | * @param {!express:Request} req HTTP request context.
5 | * @param {!express:Response} res HTTP response context.
6 | */
7 |
8 | let fs = require('fs');
9 |
10 | exports.helloWorld = (req, res) => {
11 | // Still send a 200 so we get the response (gaxios and other libraries barf on
12 | // non-200)
13 | if (!fs.existsSync('.dotfile')) {
14 | res.status(200).send('Dotfile does not exist!');
15 | return;
16 | }
17 |
18 | let message = req.query.message || req.body.message || 'Hello World!!';
19 | res.status(200).send(message);
20 | };
21 |
--------------------------------------------------------------------------------
/tests/test-node-func/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sample-http",
3 | "version": "0.0.1"
4 | }
5 |
--------------------------------------------------------------------------------
/tests/util.test.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2024 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | import { test } from 'node:test';
18 | import assert from 'node:assert';
19 |
20 | import StreamZip from 'node-stream-zip';
21 | import { assertMembers, randomFilepath } from '@google-github-actions/actions-utils';
22 |
23 | import { parseEventTriggerFilters, stringToInt, zipDir } from '../src/util';
24 |
25 | test('#zipDir', { concurrency: true }, async (suite) => {
26 | const cases = [
27 | {
28 | name: 'throws an error if sourceDir does not exist',
29 | zipDir: '/not/a/real/path',
30 | expectedFiles: [],
31 | error: 'Unable to find',
32 | },
33 | {
34 | name: 'creates a zipfile with correct files without gcloudignore',
35 | zipDir: 'tests/test-node-func',
36 | expectedFiles: ['.dotfile', 'index.js', 'package.json'],
37 | },
38 | {
39 | name: 'creates a zipfile with correct files with simple gcloudignore',
40 | zipDir: 'tests/test-func-ignore',
41 | expectedFiles: ['index.js', 'package.json'],
42 | },
43 | {
44 | name: 'creates a zipfile with correct files with simple gcloudignore',
45 | zipDir: 'tests/test-func-ignore-node',
46 | expectedFiles: [
47 | '.gcloudignore',
48 | 'foo/data.txt',
49 | 'index.js',
50 | 'notIgnored.txt',
51 | 'package.json',
52 | ],
53 | },
54 | ];
55 |
56 | for await (const tc of cases) {
57 | await suite.test(tc.name, async () => {
58 | if (tc.error) {
59 | await assert.rejects(async () => {
60 | await zipDir(tc.zipDir, randomFilepath());
61 | }, new RegExp(tc.error));
62 | } else {
63 | const zf = await zipDir(tc.zipDir, randomFilepath());
64 | const filesInsideZip = await getFilesInZip(zf);
65 | assertMembers(filesInsideZip, tc.expectedFiles);
66 | }
67 | });
68 | }
69 | });
70 |
71 | test('#stringToInt', { concurrency: true }, async (suite) => {
72 | const cases = [
73 | {
74 | name: 'empty',
75 | input: '',
76 | expected: undefined,
77 | },
78 | {
79 | name: 'spaces',
80 | input: ' ',
81 | expected: undefined,
82 | },
83 | {
84 | name: 'digit',
85 | input: '1',
86 | expected: 1,
87 | },
88 | {
89 | name: 'multi-digit',
90 | input: '123',
91 | expected: 123,
92 | },
93 | {
94 | name: 'suffix',
95 | input: '100MB',
96 | expected: 100,
97 | },
98 | {
99 | name: 'comma',
100 | input: '1,000',
101 | expected: 1000,
102 | },
103 | {
104 | name: 'NaN',
105 | input: 'this is definitely not a number',
106 | error: 'input "this is definitely not a number" is not a number',
107 | },
108 | ];
109 |
110 | for await (const tc of cases) {
111 | await suite.test(tc.name, async () => {
112 | if (tc.error) {
113 | assert.throws(() => {
114 | stringToInt(tc.input);
115 | }, new RegExp(tc.error));
116 | } else {
117 | const actual = stringToInt(tc.input);
118 | assert.deepStrictEqual(actual, tc.expected);
119 | }
120 | });
121 | }
122 | });
123 |
124 | test('#parseEventTriggerFilters', { concurrency: true }, async (suite) => {
125 | const cases = [
126 | {
127 | name: 'empty',
128 | input: '',
129 | expected: undefined,
130 | },
131 | {
132 | name: 'braces',
133 | input: '{}',
134 | expected: [],
135 | },
136 | {
137 | name: 'braces',
138 | input: `
139 | type=google.cloud.audit.log.v1.written
140 | serviceName=compute.googleapis.com
141 | methodName=PATTERN:compute.instances.*
142 | `,
143 | expected: [
144 | {
145 | attribute: 'type',
146 | value: 'google.cloud.audit.log.v1.written',
147 | },
148 | {
149 | attribute: 'serviceName',
150 | value: 'compute.googleapis.com',
151 | },
152 | {
153 | attribute: 'methodName',
154 | value: 'compute.instances.*',
155 | operator: 'match-path-pattern',
156 | },
157 | ],
158 | },
159 | ];
160 |
161 | for await (const tc of cases) {
162 | await suite.test(tc.name, async () => {
163 | const actual = parseEventTriggerFilters(tc.input);
164 | assert.deepStrictEqual(actual, tc.expected);
165 | });
166 | }
167 | });
168 |
169 | /**
170 | *
171 | * @param zipFilePath path to zipfile
172 | * @returns list of files within zipfile
173 | */
174 | async function getFilesInZip(zipFilePath: string): Promise {
175 | const uzf = new StreamZip.async({ file: zipFilePath });
176 | const zipEntries = await uzf.entries();
177 | const filesInsideZip: string[] = [];
178 | for (const k in zipEntries) {
179 | if (zipEntries[k].isFile) {
180 | filesInsideZip.push(zipEntries[k].name);
181 | }
182 | }
183 | return filesInsideZip;
184 | }
185 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2020 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 | {
17 | "compilerOptions": {
18 | "alwaysStrict": true,
19 | "target": "es6",
20 | "module": "commonjs",
21 | "lib": [
22 | "es6"
23 | ],
24 | "outDir": "./dist",
25 | "rootDir": "./src",
26 | "strict": true,
27 | "noImplicitAny": true,
28 | "esModuleInterop": true
29 | },
30 | "exclude": ["node_modules/", "tests/"]
31 | }
32 |
--------------------------------------------------------------------------------