Skip to content

Commit

Permalink
Add support for mounting secrets (#222)
Browse files Browse the repository at this point in the history
  • Loading branch information
sethvargo authored Dec 9, 2021
1 parent b5b3e18 commit 0fe069f
Show file tree
Hide file tree
Showing 8 changed files with 314 additions and 8 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,8 @@ jobs:
env_vars_file: './tests/env-var-files/test.good.yaml'
build_environment_variables: 'FOO=bar, ZIP=zap'
build_environment_variables_file: './tests/env-var-files/test.good.yaml'
secret_environment_variables: 'FOO=${{ secrets.DEPLOY_CF_SECRET_VERSION_REF }},BAR=${{ secrets.DEPLOY_CF_SECRET_REF }}'
secret_volumes: '/etc/secrets/foo=${{ secrets.DEPLOY_CF_SECRET_VERSION_REF }}'
min_instances: 2
max_instances: 5
timeout: 300
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,35 @@ steps:

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

- `secret_environment_variables`: (Optional) List of key-value pairs to set as
environment variables at runtime of the format "KEY1=SECRET_VERSION_REF" where
SECRET_VERSION_REF is a full resource name of a Google Secret Manager secret
of the format "projects/p/secrets/s/versions/v". If the project is omitted, it
will be inferred from the Cloud Function project ID. If the version is
omitted, it will default to "latest".

For example, this mounts version 5 of the `api-key` secret into `$API_KEY`
inside the function's runtime:

```yaml
secret_environment_variables: 'API_KEY=projects/my-project/secrets/api-key/versions/5'
```

- `secret_volumes`: (Optional) List of key-value pairs to mount as volumes at
runtime of the format "PATH=SECRET_VERSION_REF" where PATH is the mount path
inside the container (e.g. "/etc/secrets/my-secret") and SECRET_VERSION_REF is
a full resource name of a Google Secret Manager secret of the format
"projects/p/secrets/s/versions/v". If the project is omitted, it will be
inferred from the Cloud Function project ID. If the version is omitted, it
will default to "latest".

For example, this mounts the latest value of the `api-key` secret at
`/etc/secrets/api-key` inside the function's filesystem:

```yaml
secret_volumes: '/etc/secrets/api-key=projects/my-project/secrets/api-key'
```

- `service_account_email`: (Optional) The email address of the IAM service account associated with the function at runtime.

- `timeout`: (Optional) The function execution timeout in seconds. Defaults to 60.
Expand Down
21 changes: 21 additions & 0 deletions action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,27 @@ inputs:
The ingress settings for the function, controlling what traffic can reach it.
required: false

secret_environment_variables:
description: |-
List of key-value pairs to set as environment variables at runtime of the
format "KEY1=SECRET_VERSION_REF" where SECRET_VERSION_REF is a full
resource name of a Google Secret Manager secret of the format
"projects/p/secrets/s/versions/v". If the project is omitted, it will be
inferred from the Cloud Function project ID. If the version is omitted, it
will default to "latest"
required: false

secret_volumes:
description: |-
List of key-value pairs to mount as volumes at runtime of the format
"PATH=SECRET_VERSION_REF" where PATH is the mount path inside the
container (e.g. "/etc/secrets/my-secret") and SECRET_VERSION_REF is a full
resource name of a Google Secret Manager secret of the format
"projects/p/secrets/s/versions/v". If the project is omitted, it will be
inferred from the Cloud Function project ID. If the version is omitted, it
will default to "latest"
required: false

service_account_email:
description: |-
The email address of the IAM service account associated with the function at runtime.
Expand Down
2 changes: 1 addition & 1 deletion dist/index.js

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ export type CloudFunction = {
maxInstances?: number;
minInstances?: number;
network?: string;
secretEnvironmentVariables?: SecretEnvVar[];
secretVolumes?: SecretVolume[];
serviceAccountEmail?: string;
sourceToken?: string;
timeout?: string;
Expand All @@ -103,6 +105,23 @@ export type CloudFunction = {
eventTrigger?: EventTrigger;
};

export type SecretEnvVar = {
key: string;
projectId: string;
secret: string;
version: string;
};

export type SecretVolume = {
mountPath: string;
projectId: string;
secret: string;
versions: {
path: string;
version: string;
}[];
};

export type CloudFunctionResponse = CloudFunction & {
status: string;
updateTime: string;
Expand Down
66 changes: 59 additions & 7 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
* limitations under the License.
*/

import { posix } from 'path';

import {
getBooleanInput,
getInput,
Expand All @@ -24,7 +26,13 @@ import {
} from '@actions/core';
import { ExternalAccountClientOptions } from 'google-auth-library';

import { CloudFunctionsClient, CloudFunction } from './client';
import {
CloudFunction,
CloudFunctionsClient,
SecretEnvVar,
SecretVolume,
} from './client';
import { SecretName } from './secret';
import {
errorMessage,
isServiceAccountKey,
Expand Down Expand Up @@ -72,6 +80,11 @@ async function run(): Promise<void> {
);
const buildWorkerPool = presence(getInput('build_worker_pool'));

const secretEnvVars = parseKVString(
getInput('secret_environment_variables'),
);
const secretVols = parseKVString(getInput('secret_volumes'));

const dockerRepository = presence(getInput('docker_repository'));
const kmsKeyName = presence(getInput('kms_key_name'));

Expand Down Expand Up @@ -127,19 +140,56 @@ async function run(): Promise<void> {
);
}

// Build environment variables.
const buildEnvironmentVariables = parseKVStringAndFile(
buildEnvVars,
buildEnvVarsFile,
);
const environmentVariables = parseKVStringAndFile(envVars, envVarsFile);

// Build secret environment variables.
const secretEnvironmentVariables: SecretEnvVar[] = [];
if (secretEnvVars) {
for (const [key, value] of Object.entries(secretEnvVars)) {
const secretRef = new SecretName(value);
secretEnvironmentVariables.push({
key: key,
projectId: secretRef.project,
secret: secretRef.name,
version: secretRef.version,
});
}
}

// Build secret volumes.
const secretVolumes: SecretVolume[] = [];
if (secretVols) {
for (const [key, value] of Object.entries(secretVols)) {
const mountPath = posix.dirname(key);
const pth = posix.basename(key);

const secretRef = new SecretName(value);
secretVolumes.push({
mountPath: mountPath,
projectId: secretRef.project,
secret: secretRef.name,
versions: [
{
path: pth,
version: secretRef.version,
},
],
});
}
}

// Create Cloud Functions client
const client = new CloudFunctionsClient({
projectID: projectID,
location: region,
credentials: credentialsJSON,
});

const buildEnvironmentVariables = parseKVStringAndFile(
buildEnvVars,
buildEnvVarsFile,
);
const environmentVariables = parseKVStringAndFile(envVars, envVarsFile);

// Create Function definition
const cf: CloudFunction = {
name: name,
Expand All @@ -156,6 +206,8 @@ async function run(): Promise<void> {
labels: labels,
maxInstances: maxInstances ? +maxInstances : undefined,
minInstances: minInstances ? +minInstances : undefined,
secretEnvironmentVariables: secretEnvironmentVariables,
secretVolumes: secretVolumes,
serviceAccountEmail: serviceAccountEmail,
timeout: `${timeout}s`,
vpcConnector: vpcConnector,
Expand Down
82 changes: 82 additions & 0 deletions src/secret.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

/**
* Parses a string into a Google Secret Manager reference.
*
* @param s String reference to parse
* @returns Reference
*/
export class SecretName {
// project, name, and version are the secret ref
readonly project: string;
readonly name: string;
readonly version: string;

constructor(s: string | null | undefined) {
s = (s || '').trim();
if (!s) {
throw new Error(`Missing secret name`);
}

const refParts = s.split('/');
switch (refParts.length) {
// projects/<p>/secrets/<s>/versions/<v>
case 6: {
this.project = refParts[1];
this.name = refParts[3];
this.version = refParts[5];
break;
}
// projects/<p>/secrets/<s>
case 4: {
this.project = refParts[1];
this.name = refParts[3];
this.version = 'latest';
break;
}
// <p>/<s>/<v>
case 3: {
this.project = refParts[0];
this.name = refParts[1];
this.version = refParts[2];
break;
}
// <p>/<s>
case 2: {
this.project = refParts[0];
this.name = refParts[1];
this.version = 'latest';
break;
}
default: {
throw new TypeError(
`Failed to parse secret reference "${s}": unknown format. Secrets ` +
`should be of the format "projects/p/secrets/s/versions/v".`,
);
}
}
}

/**
* Returns the full GCP self link.
*
* @returns String self link.
*/
public selfLink(): string {
return `projects/${this.project}/secrets/${this.name}/versions/${this.version}`;
}
}
101 changes: 101 additions & 0 deletions tests/secret.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { expect } from 'chai';
import 'mocha';

import { SecretName } from '../src/secret';

describe('SecretName', function () {
const cases = [
{
name: 'empty string',
input: '',
error: 'Missing secret name',
},
{
name: 'null',
input: null,
error: 'Missing secret name',
},
{
name: 'undefined',
input: undefined,
error: 'Missing secret name',
},
{
name: 'bad resource name',
input: 'projects/fruits/secrets/apple/versions/123/subversions/5',
error: 'Failed to parse secret reference',
},
{
name: 'bad resource name',
input: 'projects/fruits/secrets/apple/banana/bacon/pants',
error: 'Failed to parse secret reference',
},
{
name: 'full resource name',
input: 'projects/fruits/secrets/apple/versions/123',
expected: {
project: 'fruits',
secret: 'apple',
version: '123',
},
},
{
name: 'full resource name without version',
input: 'projects/fruits/secrets/apple',
expected: {
project: 'fruits',
secret: 'apple',
version: 'latest',
},
},
{
name: 'short ref',
input: 'fruits/apple/123',
expected: {
project: 'fruits',
secret: 'apple',
version: '123',
},
},
{
name: 'short ref without version',
input: 'fruits/apple',
expected: {
project: 'fruits',
secret: 'apple',
version: 'latest',
},
},
];

cases.forEach((tc) => {
it(tc.name, async () => {
if (tc.expected) {
const secret = new SecretName(tc.input);
expect(secret.project).to.eq(tc.expected.project);
expect(secret.name).to.eq(tc.expected.secret);
expect(secret.version).to.eq(tc.expected.version);
} else if (tc.error) {
expect(() => {
new SecretName(tc.input);
}).to.throw(tc.error);
}
});
});
});

0 comments on commit 0fe069f

Please sign in to comment.