Skip to content

Commit 0fe069f

Browse files
authored
Add support for mounting secrets (#222)
1 parent b5b3e18 commit 0fe069f

File tree

8 files changed

+314
-8
lines changed

8 files changed

+314
-8
lines changed

.github/workflows/integration.yml

+2
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,8 @@ jobs:
223223
env_vars_file: './tests/env-var-files/test.good.yaml'
224224
build_environment_variables: 'FOO=bar, ZIP=zap'
225225
build_environment_variables_file: './tests/env-var-files/test.good.yaml'
226+
secret_environment_variables: 'FOO=${{ secrets.DEPLOY_CF_SECRET_VERSION_REF }},BAR=${{ secrets.DEPLOY_CF_SECRET_REF }}'
227+
secret_volumes: '/etc/secrets/foo=${{ secrets.DEPLOY_CF_SECRET_VERSION_REF }}'
226228
min_instances: 2
227229
max_instances: 5
228230
timeout: 300

README.md

+29
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,35 @@ steps:
8181

8282
- `ingress_settings`: (Optional) The ingress settings for the function, controlling what traffic can reach it.
8383

84+
- `secret_environment_variables`: (Optional) List of key-value pairs to set as
85+
environment variables at runtime of the format "KEY1=SECRET_VERSION_REF" where
86+
SECRET_VERSION_REF is a full resource name of a Google Secret Manager secret
87+
of the format "projects/p/secrets/s/versions/v". If the project is omitted, it
88+
will be inferred from the Cloud Function project ID. If the version is
89+
omitted, it will default to "latest".
90+
91+
For example, this mounts version 5 of the `api-key` secret into `$API_KEY`
92+
inside the function's runtime:
93+
94+
```yaml
95+
secret_environment_variables: 'API_KEY=projects/my-project/secrets/api-key/versions/5'
96+
```
97+
98+
- `secret_volumes`: (Optional) List of key-value pairs to mount as volumes at
99+
runtime of the format "PATH=SECRET_VERSION_REF" where PATH is the mount path
100+
inside the container (e.g. "/etc/secrets/my-secret") and SECRET_VERSION_REF is
101+
a full resource name of a Google Secret Manager secret of the format
102+
"projects/p/secrets/s/versions/v". If the project is omitted, it will be
103+
inferred from the Cloud Function project ID. If the version is omitted, it
104+
will default to "latest".
105+
106+
For example, this mounts the latest value of the `api-key` secret at
107+
`/etc/secrets/api-key` inside the function's filesystem:
108+
109+
```yaml
110+
secret_volumes: '/etc/secrets/api-key=projects/my-project/secrets/api-key'
111+
```
112+
84113
- `service_account_email`: (Optional) The email address of the IAM service account associated with the function at runtime.
85114

86115
- `timeout`: (Optional) The function execution timeout in seconds. Defaults to 60.

action.yaml

+21
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,27 @@ inputs:
9898
The ingress settings for the function, controlling what traffic can reach it.
9999
required: false
100100

101+
secret_environment_variables:
102+
description: |-
103+
List of key-value pairs to set as environment variables at runtime of the
104+
format "KEY1=SECRET_VERSION_REF" where SECRET_VERSION_REF is a full
105+
resource name of a Google Secret Manager secret of the format
106+
"projects/p/secrets/s/versions/v". If the project is omitted, it will be
107+
inferred from the Cloud Function project ID. If the version is omitted, it
108+
will default to "latest"
109+
required: false
110+
111+
secret_volumes:
112+
description: |-
113+
List of key-value pairs to mount as volumes at runtime of the format
114+
"PATH=SECRET_VERSION_REF" where PATH is the mount path inside the
115+
container (e.g. "/etc/secrets/my-secret") and SECRET_VERSION_REF is a full
116+
resource name of a Google Secret Manager secret of the format
117+
"projects/p/secrets/s/versions/v". If the project is omitted, it will be
118+
inferred from the Cloud Function project ID. If the version is omitted, it
119+
will default to "latest"
120+
required: false
121+
101122
service_account_email:
102123
description: |-
103124
The email address of the IAM service account associated with the function at runtime.

dist/index.js

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/client.ts

+19
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ export type CloudFunction = {
8787
maxInstances?: number;
8888
minInstances?: number;
8989
network?: string;
90+
secretEnvironmentVariables?: SecretEnvVar[];
91+
secretVolumes?: SecretVolume[];
9092
serviceAccountEmail?: string;
9193
sourceToken?: string;
9294
timeout?: string;
@@ -103,6 +105,23 @@ export type CloudFunction = {
103105
eventTrigger?: EventTrigger;
104106
};
105107

108+
export type SecretEnvVar = {
109+
key: string;
110+
projectId: string;
111+
secret: string;
112+
version: string;
113+
};
114+
115+
export type SecretVolume = {
116+
mountPath: string;
117+
projectId: string;
118+
secret: string;
119+
versions: {
120+
path: string;
121+
version: string;
122+
}[];
123+
};
124+
106125
export type CloudFunctionResponse = CloudFunction & {
107126
status: string;
108127
updateTime: string;

src/main.ts

+59-7
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
* limitations under the License.
1515
*/
1616

17+
import { posix } from 'path';
18+
1719
import {
1820
getBooleanInput,
1921
getInput,
@@ -24,7 +26,13 @@ import {
2426
} from '@actions/core';
2527
import { ExternalAccountClientOptions } from 'google-auth-library';
2628

27-
import { CloudFunctionsClient, CloudFunction } from './client';
29+
import {
30+
CloudFunction,
31+
CloudFunctionsClient,
32+
SecretEnvVar,
33+
SecretVolume,
34+
} from './client';
35+
import { SecretName } from './secret';
2836
import {
2937
errorMessage,
3038
isServiceAccountKey,
@@ -72,6 +80,11 @@ async function run(): Promise<void> {
7280
);
7381
const buildWorkerPool = presence(getInput('build_worker_pool'));
7482

83+
const secretEnvVars = parseKVString(
84+
getInput('secret_environment_variables'),
85+
);
86+
const secretVols = parseKVString(getInput('secret_volumes'));
87+
7588
const dockerRepository = presence(getInput('docker_repository'));
7689
const kmsKeyName = presence(getInput('kms_key_name'));
7790

@@ -127,19 +140,56 @@ async function run(): Promise<void> {
127140
);
128141
}
129142

143+
// Build environment variables.
144+
const buildEnvironmentVariables = parseKVStringAndFile(
145+
buildEnvVars,
146+
buildEnvVarsFile,
147+
);
148+
const environmentVariables = parseKVStringAndFile(envVars, envVarsFile);
149+
150+
// Build secret environment variables.
151+
const secretEnvironmentVariables: SecretEnvVar[] = [];
152+
if (secretEnvVars) {
153+
for (const [key, value] of Object.entries(secretEnvVars)) {
154+
const secretRef = new SecretName(value);
155+
secretEnvironmentVariables.push({
156+
key: key,
157+
projectId: secretRef.project,
158+
secret: secretRef.name,
159+
version: secretRef.version,
160+
});
161+
}
162+
}
163+
164+
// Build secret volumes.
165+
const secretVolumes: SecretVolume[] = [];
166+
if (secretVols) {
167+
for (const [key, value] of Object.entries(secretVols)) {
168+
const mountPath = posix.dirname(key);
169+
const pth = posix.basename(key);
170+
171+
const secretRef = new SecretName(value);
172+
secretVolumes.push({
173+
mountPath: mountPath,
174+
projectId: secretRef.project,
175+
secret: secretRef.name,
176+
versions: [
177+
{
178+
path: pth,
179+
version: secretRef.version,
180+
},
181+
],
182+
});
183+
}
184+
}
185+
130186
// Create Cloud Functions client
131187
const client = new CloudFunctionsClient({
132188
projectID: projectID,
133189
location: region,
134190
credentials: credentialsJSON,
135191
});
136192

137-
const buildEnvironmentVariables = parseKVStringAndFile(
138-
buildEnvVars,
139-
buildEnvVarsFile,
140-
);
141-
const environmentVariables = parseKVStringAndFile(envVars, envVarsFile);
142-
143193
// Create Function definition
144194
const cf: CloudFunction = {
145195
name: name,
@@ -156,6 +206,8 @@ async function run(): Promise<void> {
156206
labels: labels,
157207
maxInstances: maxInstances ? +maxInstances : undefined,
158208
minInstances: minInstances ? +minInstances : undefined,
209+
secretEnvironmentVariables: secretEnvironmentVariables,
210+
secretVolumes: secretVolumes,
159211
serviceAccountEmail: serviceAccountEmail,
160212
timeout: `${timeout}s`,
161213
vpcConnector: vpcConnector,

src/secret.ts

+82
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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+
/**
18+
* Parses a string into a Google Secret Manager reference.
19+
*
20+
* @param s String reference to parse
21+
* @returns Reference
22+
*/
23+
export class SecretName {
24+
// project, name, and version are the secret ref
25+
readonly project: string;
26+
readonly name: string;
27+
readonly version: string;
28+
29+
constructor(s: string | null | undefined) {
30+
s = (s || '').trim();
31+
if (!s) {
32+
throw new Error(`Missing secret name`);
33+
}
34+
35+
const refParts = s.split('/');
36+
switch (refParts.length) {
37+
// projects/<p>/secrets/<s>/versions/<v>
38+
case 6: {
39+
this.project = refParts[1];
40+
this.name = refParts[3];
41+
this.version = refParts[5];
42+
break;
43+
}
44+
// projects/<p>/secrets/<s>
45+
case 4: {
46+
this.project = refParts[1];
47+
this.name = refParts[3];
48+
this.version = 'latest';
49+
break;
50+
}
51+
// <p>/<s>/<v>
52+
case 3: {
53+
this.project = refParts[0];
54+
this.name = refParts[1];
55+
this.version = refParts[2];
56+
break;
57+
}
58+
// <p>/<s>
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+
}

tests/secret.test.ts

+101
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
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 { expect } from 'chai';
18+
import 'mocha';
19+
20+
import { SecretName } from '../src/secret';
21+
22+
describe('SecretName', function () {
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+
cases.forEach((tc) => {
88+
it(tc.name, async () => {
89+
if (tc.expected) {
90+
const secret = new SecretName(tc.input);
91+
expect(secret.project).to.eq(tc.expected.project);
92+
expect(secret.name).to.eq(tc.expected.secret);
93+
expect(secret.version).to.eq(tc.expected.version);
94+
} else if (tc.error) {
95+
expect(() => {
96+
new SecretName(tc.input);
97+
}).to.throw(tc.error);
98+
}
99+
});
100+
});
101+
});

0 commit comments

Comments
 (0)