Cloud Run Action (#117)

* Cloud Run Action Drafted

* Cloud Run action

* refactor

* rename

* Add metadata and tests

* Update Readme and required fields

* Update e2e tests

* Update tests

* Add log messages

* fix typo in obj field

* Update YAML

* Update Env Var names

* Update workflow tests

* Update tests

* Update testing

* Update tests

* Update workflow

* Update workflow dir

* fix typo

* test output

* Update integration tests

* add workdir

* set up auth for service request

* Add check for empty array

* Wait for URL

* fix test

* Update for PR comments
This commit is contained in:
Averi Kitsch 2020-07-30 12:59:17 -07:00 committed by GitHub
parent 79f381996b
commit a244a889d6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 329019 additions and 0 deletions

177
.github/workflows/deploy-cloudrun-it.yml vendored Normal file
View file

@ -0,0 +1,177 @@
name: deploy-cloudrun Integration
on:
push:
paths:
- 'deploy-cloudrun/**'
pull_request:
paths:
- 'deploy-cloudrun/**'
jobs:
gcloud:
name: with setup-gcloud
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: ./setup-gcloud # Set up ADC to make authenticated request to service
with:
service_account_email: ${{ secrets.DEPLOY_CLOUDRUN_SA_EMAIL }}
service_account_key: ${{ secrets.DEPLOY_CLOUDRUN_SA_KEY_B64 }}
export_default_credentials: true
- id: deploy
uses: ./deploy-cloudrun
env:
GCLOUD_PROJECT: ${{ secrets.DEPLOY_CLOUDRUN_PROJECT_ID }}
with:
image: gcr.io/cloudrun/hello
service: test-gcloud
- uses: actions/setup-node@master
with:
node-version: 12.x
- run: npm install
working-directory: ./deploy-cloudrun
- name: integration tests
run: npm run e2e-tests
working-directory: ./deploy-cloudrun
env:
URL: ${{ steps.deploy.outputs.url }}
b64_json:
name: with base64 json creds
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- id: deploy
uses: ./deploy-cloudrun
with:
credentials: ${{ secrets.DEPLOY_CLOUDRUN_SA_KEY_B64 }}
image: gcr.io/cloudrun/hello
service: test-b64
- uses: actions/setup-node@master
with:
node-version: 12.x
- run: npm install
working-directory: ./deploy-cloudrun
- uses: ./setup-gcloud # Set up ADC to make authenticated request to service
with:
service_account_email: ${{ secrets.DEPLOY_CLOUDRUN_SA_EMAIL }}
service_account_key: ${{ secrets.DEPLOY_CLOUDRUN_SA_KEY_B64 }}
export_default_credentials: true
- name: integration tests
run: npm run e2e-tests
working-directory: ./deploy-cloudrun
env:
URL: ${{ steps.deploy.outputs.url }}
GCLOUD_PROJECT: ${{ secrets.DEPLOY_CLOUDRUN_PROJECT_ID }}
json:
name: with json creds
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- id: deploy
uses: ./deploy-cloudrun
with:
credentials: ${{ secrets.DEPLOY_CLOUDRUN_SA_KEY_JSON }}
image: gcr.io/cloudrun/hello
service: test-json
- uses: actions/setup-node@master
with:
node-version: 12.x
- run: npm install
working-directory: ./deploy-cloudrun
- uses: ./setup-gcloud # Set up ADC to make authenticated request to service
with:
service_account_email: ${{ secrets.DEPLOY_CLOUDRUN_SA_EMAIL }}
service_account_key: ${{ secrets.DEPLOY_CLOUDRUN_SA_KEY_B64 }}
export_default_credentials: true
- name: integration tests
run: npm run e2e-tests
working-directory: ./deploy-cloudrun
env:
URL: ${{ steps.deploy.outputs.url }}
GCLOUD_PROJECT: ${{ secrets.DEPLOY_CLOUDRUN_PROJECT_ID }}
envVars:
name: with Env Vars
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- id: deploy
uses: ./deploy-cloudrun
with:
credentials: ${{ secrets.DEPLOY_CLOUDRUN_SA_KEY_JSON }}
image: gcr.io/cloudrun/hello
service: test-envvars
env_vars:
- uses: actions/setup-node@master
with:
node-version: 12.x
- run: npm install
working-directory: ./deploy-cloudrun
- uses: ./setup-gcloud # Set up ADC to make authenticated request to service
with:
service_account_email: ${{ secrets.DEPLOY_CLOUDRUN_SA_EMAIL }}
service_account_key: ${{ secrets.DEPLOY_CLOUDRUN_SA_KEY_B64 }}
export_default_credentials: true
- name: integration tests
run: npm run e2e-tests
working-directory: ./deploy-cloudrun
env:
URL: ${{ steps.deploy.outputs.url }}
GCLOUD_PROJECT: ${{ secrets.DEPLOY_CLOUDRUN_PROJECT_ID }}
yaml:
name: with YAML metadata
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- id: deploy
uses: ./deploy-cloudrun
with:
credentials: ${{ secrets.DEPLOY_CLOUDRUN_SA_KEY_JSON }}
metadata: ./deploy-cloudrun/tests/unit/service.basic.yaml
- uses: actions/setup-node@master
with:
node-version: 12.x
- run: npm install
working-directory: ./deploy-cloudrun
- uses: ./setup-gcloud # Set up ADC to make authenticated request to service
with:
service_account_email: ${{ secrets.DEPLOY_CLOUDRUN_SA_EMAIL }}
service_account_key: ${{ secrets.DEPLOY_CLOUDRUN_SA_KEY_B64 }}
export_default_credentials: true
- name: integration tests
run: npm run e2e-tests
working-directory: ./deploy-cloudrun
env:
URL: ${{ steps.deploy.outputs.url }}
GCLOUD_PROJECT: ${{ secrets.DEPLOY_CLOUDRUN_PROJECT_ID }}
metadata:
name: with full YAML metada
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- id: deploy
uses: ./deploy-cloudrun
with:
credentials: ${{ secrets.DEPLOY_CLOUDRUN_SA_KEY_JSON }}
metadata: ./deploy-cloudrun/tests/unit/service.full.yaml
- uses: actions/setup-node@master
with:
node-version: 12.x
- run: npm install
working-directory: ./deploy-cloudrun
- uses: ./setup-gcloud # Set up ADC to make authenticated request to service # Set up ADC to make authenticated request to service
with:
service_account_email: ${{ secrets.DEPLOY_CLOUDRUN_SA_EMAIL }}
service_account_key: ${{ secrets.DEPLOY_CLOUDRUN_SA_KEY_B64 }}
export_default_credentials: true
- name: integration tests
run: npm run e2e-tests
working-directory: ./deploy-cloudrun
env:
URL: ${{ steps.deploy.outputs.url }}
GCLOUD_PROJECT: ${{ secrets.DEPLOY_CLOUDRUN_PROJECT_ID }}

44
.github/workflows/deploy-cloudrun.yml vendored Normal file
View file

@ -0,0 +1,44 @@
name: deploy-cloudrun Unit
on:
push:
paths:
- 'deploy-cloudrun/**'
pull_request:
paths:
- 'deploy-cloudrun/**'
jobs:
run:
name: test
runs-on: ${{ matrix.operating-system }}
strategy:
matrix:
operating-system: [ubuntu-latest, windows-latest, macos-latest]
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@master
with:
node-version: 12.x
- name: npm install
run: npm install
working-directory: ./deploy-cloudrun
- name: npm lint
run: npm run lint
working-directory: ./deploy-cloudrun
- uses: ./setup-gcloud
with:
service_account_email: ${{ secrets.DEPLOY_CLOUDRUN_SA_EMAIL }}
service_account_key: ${{ secrets.DEPLOY_CLOUDRUN_SA_KEY_B64 }}
export_default_credentials: true
- name: npm test
run: npm run test
working-directory: ./deploy-cloudrun
env:
TEST_DEPLOY_CLOUDRUN_CREDENTIALS: ${{ secrets.DEPLOY_CLOUDRUN_SA_KEY_B64 }}
TEST_DEPLOY_CLOUDRUN_PROJECT: ${{ secrets.DEPLOY_CLOUDRUN_PROJECT_ID }}
GCLOUD_PROJECT: ${{ secrets.DEPLOY_CLOUDRUN_PROJECT_ID }}

View file

@ -0,0 +1,31 @@
/*
* Copyright 2020 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.
*/
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
'prettier/@typescript-eslint',
],
rules: {
'@typescript-eslint/camelcase': 'off',
}
};

45
deploy-cloudrun/.gitignore vendored Normal file
View file

@ -0,0 +1,45 @@
node_modules/
runner/
# Rest of the file pulled from https://github.com/github/gitignore/blob/master/Node.gitignore
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz

View file

@ -0,0 +1,30 @@
/*
* Copyright 2020 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.
*/
module.exports = {
arrowParens: 'always',
bracketSpacing: true,
endOfLine: 'auto',
jsxBracketSameLine: true,
jsxSingleQuote: true,
printWidth: 80,
quoteProps: 'consistent',
semi: true,
singleQuote: true,
tabWidth: 2,
trailingComma: 'all',
useTabs: false,
};

182
deploy-cloudrun/README.md Normal file
View file

@ -0,0 +1,182 @@
<!--
Copyright 2020 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.
-->
# deploy-cloudrun
This action deploys your container image to [Cloud Run][cloud-run] and makes the URL
available to later build steps via outputs.
## Prerequisites
This action requires:
- Google Cloud credentials that are authorized to deploy a
Cloud Run service. See the Authorization section below for more information.
- [Enable the Cloud Run API](http://console.cloud.google.com/apis/library/run.googleapis.com?_ga=2.267842766.1374248275.1591025444-475066991.1589991158)
## Usage
```yaml
steps:
- id: deploy
uses: GoogleCloudPlatform/github-actions/deploy-appengine@master
with:
image: gcr.io/cloudrun/hello
service: hello-cloud-run
credentials: ${{ secrets.gcp_credentials }}
# Example of using the output
- id: test
run: curl "${{ steps.deploy.outputs.url }}"
```
## Inputs
- `image`: Name of the container image to deploy (e.g. gcr.io/cloudrun/hello:latest).
Required if not using a service YAML.
- `service`: ID of the service or fully qualified identifier for the service.
Required if not using a service YAML.
- `region`: Region in which the resource can be found.
- `credentials`: Service account key to use for authentication. This should be
the JSON formatted private key which can be exported from the Cloud Console. The
value can be raw or base64-encoded. Required if not using a the
`setup-gcloud` action with exported credentials.
- `env_vars`: List of key-value pairs to set as environment variables in the format:
KEY1=VALUE1,KEY2=VALUE2. All existing environment variables will be
removed first.
- `metadata`: YAML serivce description for the Cloud Run service. See
[Metadata customizations](#metadata-customizations) for more information.
- `project_id`: (Optional) ID of the Google Cloud project. If provided, this
will override the project configured by gcloud.
### Metadata customizations
You can store your service specification in a YAML file. This will allow for
further service configuration, such as [memory limits](https://cloud.google.com/run/docs/configuring/memory-limits),
[CPU allocation](https://cloud.google.com/run/docs/configuring/cpu),
[max instances](https://cloud.google.com/run/docs/configuring/max-instances),
and [more.](https://cloud.google.com/sdk/gcloud/reference/run/deploy#OPTIONAL-FLAGS)
- See [Deploying a new service](https://cloud.google.com/run/docs/deploying#yaml)
to create a new YAML service definition, for example:
```YAML
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
name: SERVICE
spec:
template:
spec:
containers:
- image: IMAGE
```
- See [Deploy a new revision of an existing service](https://cloud.google.com/run/docs/deploying#yaml_1)
to generated a YAML service specification from an existing service:
```
gcloud run services describe SERVICE --format yaml > service.yaml
```
## Allow unauthenticated requests
A Cloud Run product recommendation is that CI/CD systems not set or change
settings for allowing unauthenticated invocations. New deployments are
automatically private services, while deploying a revision of a public
(unauthenticated) service will preserve the IAM setting of public
(unauthenticated). For more information, see [Controlling access on an individual service](https://cloud.google.com/run/docs/securing/managing-access).
## Outputs
- `url`: The URL of your Cloud Run service.
## Authorization
There are a few ways to authenticate this action. A service account will be needed
with the following roles:
- Cloud Run Admin (`roles/run.admin`):
- Can create, update, and delete services.
- Can get and set IAM policies.
This service account needs to a member of the `Compute Engine default service account`,
`(PROJECT_NUMBER-compute@developer.gserviceaccount.com)`, with role
`Service Account User`. To grant a user permissions for a service account, use
one of the methods found in [Configuring Ownership and access to a service account](https://cloud.google.com/iam/docs/granting-roles-to-service-accounts#granting_access_to_a_user_for_a_service_account).
### Used with `setup-gcloud`
You can provide credentials using the [setup-gcloud][setup-gcloud] action:
```yaml
- uses: GoogleCloudPlatform/github-actions/setup-gcloud@master
with:
version: '290.0.1'
service_account_key: ${{ secrets.GCP_SA_KEY }}
export_default_credentials: true
- id: Deploy
uses: GoogleCloudPlatform/github-actions/deploy-cloudrun@master
with:
image: gcr.io/cloudrun/hello
service: hello-cloud-run
```
### Via Credentials
You can provide [Google Cloud Service Account JSON][sa] directly to the action
by specifying the `credentials` input. First, create a [GitHub
Secret][gh-secret] that contains the JSON content, then import it into the
action:
```yaml
- id: Deploy
uses: GoogleCloudPlatform/github-actions/deploy-cloudrun@master
with:
credentials: ${{ secrets.GCP_SA_KEY }}
image: gcr.io/cloudrun/hello
service: hello-cloud-run
```
### Via Application Default Credentials
If you are hosting your own runners, **and** those runners are on Google Cloud,
you can leverage the Application Default Credentials of the instance. This will
authenticate requests as the service account attached to the instance. **This
only works using a custom runner hosted on GCP.**
```yaml
- id: Deploy
uses: GoogleCloudPlatform/github-actions/deploy-cloudrun@master
with:
image: gcr.io/cloudrun/hello
service: hello-cloud-run
```
The action will automatically detect and use the Application Default
Credentials.
[cloud-run]: https://cloud.google.com/run
[sm]: https://cloud.google.com/secret-manager
[sa]: https://cloud.google.com/iam/docs/creating-managing-service-accounts
[gh-runners]: https://help.github.com/en/actions/hosting-your-own-runners/about-self-hosted-runners
[gh-secret]: https://help.github.com/en/actions/configuring-and-managing-workflows/creating-and-storing-encrypted-secrets
[setup-gcloud]: ../setup-gcloud

View file

@ -0,0 +1,71 @@
# Copyright 2020 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.
name: Deploy to Cloud Run
author: GoogleCloudPlatform
description: |-
Cloud Run is a fully managed compute platform that automatically scales your
stateless containers. Use this action to deploy a container that has already
been uploaded to the Container Registry repository to Cloud Run.
inputs:
image:
description: |-
Name of the container image to deploy (e.g. gcr.io/cloudrun/hello:latest).
Required if not using a service YAML.
required: false
service:
description: |-
ID of the service or fully qualified identifier for the service.
Required if not using a service YAML.
required: false
region:
description: |-
Region in which the resource can be found.
required: false
default: us-central1
env_vars:
description: |-
List of key-value pairs to set as environment variables in the format:
KEY1=VALUE1,KEY2=VALUE2. All existing environment variables will be
removed first.
required: false
metadata:
description: |-
YAML serivce description for the Cloud Run service.
required: false
project_id:
description: The GCP project ID. Overrides project ID set by credentials.
required: false
credentials:
description: |-
Service account key to use for authentication. This should be the JSON
formatted private key which can be exported from the Cloud Console. The
value can be raw or base64-encoded. Required if not using a the
setup-gcloud action with exported credentials.
required: false
outputs:
url:
description: The URL of your Cloud Run service
runs:
using: node12
main: dist/index.js

325341
deploy-cloudrun/dist/index.js vendored Normal file

File diff suppressed because one or more lines are too long

2335
deploy-cloudrun/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,56 @@
{
"name": "deploy-cloudrun",
"version": "0.1.0",
"description": "Github Action: Deploy to Google Cloud Run",
"main": "dist/index.js",
"scripts": {
"build": "ncc build src/index.ts",
"lint": "eslint . --ext .ts,.tsx",
"format": "prettier --write **/*.ts",
"test": "mocha -r ts-node/register -t 120s 'tests/unit/*.test.ts'",
"e2e-tests": "mocha -r ts-node/register -t 120s 'tests/e2e.test.ts'"
},
"repository": {
"type": "git",
"url": "git+https://github.com/GoogleCloudPlatform/github-actions.git"
},
"keywords": [
"actions",
"google",
"cloud",
"cloud",
"run"
],
"author": "Google LLC",
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/GoogleCloudPlatform/github-actions/issues"
},
"homepage": "https://github.com/GoogleCloudPlatform/github-actions#readme",
"dependencies": {
"@actions/core": "^1.2.4",
"fs": "0.0.1-security",
"gaxios": "^3.0.3",
"googleapis": "^49.0.0",
"yaml": "^1.9.2"
},
"devDependencies": {
"@types/chai": "^4.2.9",
"@types/lodash": "^4.14.150",
"@types/mocha": "^7.0.1",
"@types/node": "^13.7.4",
"@types/uuid": "^7.0.3",
"@typescript-eslint/eslint-plugin": "^2.20.0",
"@typescript-eslint/parser": "^2.20.0",
"@zeit/ncc": "^0.21.0",
"chai": "^4.2.0",
"eslint": "^6.8.0",
"eslint-config-prettier": "^6.10.0",
"eslint-plugin-prettier": "^3.1.2",
"google-auth-library": "^6.0.0",
"mocha": "^7.0.1",
"prettier": "^1.19.1",
"ts-node": "^8.6.2",
"typescript": "^3.8.2"
}
}

View file

@ -0,0 +1,257 @@
/*
* Copyright 2020 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 * as core from '@actions/core';
import { GaxiosResponse } from 'gaxios';
import { google, run_v1 } from 'googleapis';
import {
GoogleAuth,
JWT,
Compute,
UserRefreshClient,
} from 'google-auth-library';
import { Service } from './service';
/**
* Available options to create the client.
*
* @param credentials GCP JSON credentials (default uses ADC).
* @param endpoint GCP endpoint (useful for testing).
*/
type ClientOptions = {
credentials?: string;
projectId?: string;
};
/**
* Wraps interactions with the Google Cloud Run API.
*
* @param region Region of the GCP resource.
* @param opts list of ClientOptions.
* @returns CloudRun client.
*/
export class CloudRun {
readonly methodOptions = {
userAgentDirectives: [
{
product: 'github-actions-deploy-cloudrun',
version: '0.1.0',
},
],
};
private run = google.run('v1');
readonly auth: GoogleAuth;
readonly parent: string;
authClient: any;
constructor(region: string, opts?: ClientOptions) {
let projectId = opts?.projectId;
if (
!opts?.credentials &&
(!process.env.GCLOUD_PROJECT ||
!process.env.GOOGLE_APPLICATION_CREDENTIALS)
) {
throw new Error(
'No method for authentication. Set credentials in this action or export credentials from the setup-gcloud action',
);
}
// Instatiate Auth Client
// This method looks for the GCLOUD_PROJECT and GOOGLE_APPLICATION_CREDENTIALS
// environment variables.
this.auth = new google.auth.GoogleAuth({
scopes: ['https://www.googleapis.com/auth/cloud-platform'],
});
// Set credentials, if any.
let jsonContent;
if (opts?.credentials) {
let creds = opts?.credentials;
if (!opts?.credentials.trim().startsWith('{')) {
creds = Buffer.from(creds, 'base64').toString('utf8');
}
jsonContent = JSON.parse(creds);
this.auth.jsonContent = jsonContent;
}
// Set project Id
if (!projectId && jsonContent && jsonContent.project_id) {
projectId = jsonContent.project_id;
core.info('Setting project Id from credentials');
} else if (!projectId && process.env.GCLOUD_PROJECT) {
projectId = process.env.GCLOUD_PROJECT;
core.info('Setting project Id from $GCLOUD_PROJECT');
} else if (!projectId) {
throw new Error('No project Id found. Set project Id in this action.');
}
this.parent = `projects/${projectId}/locations/${region}`;
}
/**
* Retrieves the auth client for authenticating requests.
*
* @returns JWT | Compute | UserRefreshClient.
*/
async getAuthClient(): Promise<JWT | Compute | UserRefreshClient> {
if (!this.authClient) {
this.authClient = await this.auth.getClient();
}
return this.authClient;
}
/**
* Generates full resource name.
*
* @param service Service object.
* @returns full resource name.
*/
getResource(service: Service): string {
return `${this.parent}/services/${service.name}`;
}
/**
* Retrieves a Cloud Run services.
*
* @returns a Cloud Run service.
*/
async getService(service: Service): Promise<run_v1.Schema$Service> {
const authClient = await this.getAuthClient();
const getRequest: run_v1.Params$Resource$Projects$Locations$Services$Get = {
name: this.getResource(service),
auth: authClient,
};
const serviceResponse: GaxiosResponse<run_v1.Schema$Service> = await this.run.projects.locations.services.get(
getRequest,
this.methodOptions,
);
return serviceResponse.data;
}
/**
* Retrieves a list of Cloud Run services.
*
* @returns list of Cloud Run services.
*/
async listServices(): Promise<string[]> {
const authClient = await this.getAuthClient();
const listRequest: run_v1.Params$Resource$Projects$Locations$Services$List = {
parent: this.parent,
auth: authClient,
};
const serviceListResponse: GaxiosResponse<run_v1.Schema$ListServicesResponse> = await this.run.projects.locations.services.list(
listRequest,
this.methodOptions,
);
const serviceList: run_v1.Schema$ListServicesResponse =
serviceListResponse.data;
let serviceNames: string[] = [];
if (serviceList.items !== undefined) {
serviceNames = serviceList.items!.map(
(service: run_v1.Schema$Service) => service.metadata!.name as string,
);
}
return serviceNames;
}
/**
* Deploy a Cloud Run service.
*
* @param service Service object.
* @returns Service object.
*/
async deploy(service: Service): Promise<run_v1.Schema$Service> {
const authClient = await this.getAuthClient();
const serviceNames = await this.listServices();
let serviceResponse: GaxiosResponse<run_v1.Schema$Service>;
if (serviceNames!.includes(service.name)) {
core.info('Creating a service revision...');
// Replace service
const createServiceRequest: run_v1.Params$Resource$Projects$Locations$Services$Replaceservice = {
name: this.getResource(service),
auth: authClient,
requestBody: service.request,
};
serviceResponse = await this.run.projects.locations.services.replaceService(
createServiceRequest,
this.methodOptions,
);
} else {
core.info('Creating a new service...');
// Create service
const createServiceRequest: run_v1.Params$Resource$Projects$Locations$Services$Create = {
parent: this.parent,
auth: authClient,
requestBody: service.request,
};
serviceResponse = await this.run.projects.locations.services.create(
createServiceRequest,
this.methodOptions,
);
}
core.info(`Service ${service.name} has been successfully deployed.`);
return serviceResponse.data;
}
/**
* Deletes a Cloud Run service.
*
* @param service Service object.
*/
async delete(service: Service): Promise<void> {
const authClient = await this.getAuthClient();
try {
const deleteServiceRequest: run_v1.Params$Resource$Projects$Locations$Services$Delete = {
name: this.getResource(service),
auth: authClient,
};
await this.run.projects.locations.services.delete(
deleteServiceRequest,
this.methodOptions,
);
core.info(`Service ${service.name} has been successfully deleted.`);
} catch (e) {
core.info(`Error deleting Service ${service.name}: ` + e);
}
}
/**
* Set's IAM policy for service (Not Recommended).
*
* @param service Service object.
*/
async allowUnauthenticatedRequests(service: Service): Promise<void> {
const authClient = await this.getAuthClient();
const bindings: run_v1.Schema$Binding[] = [
{
members: ['allUsers'],
role: 'roles/run.invoker',
},
];
const iamPolicy: run_v1.Schema$SetIamPolicyRequest = {
policy: {
bindings,
},
};
const setIamPolicyRequest: run_v1.Params$Resource$Projects$Locations$Services$Setiampolicy = {
resource: this.getResource(service),
auth: authClient,
requestBody: iamPolicy,
};
await this.run.projects.locations.services.setIamPolicy(
setIamPolicyRequest,
this.methodOptions,
);
}
}

View file

@ -0,0 +1,55 @@
/*
* Copyright 2020 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 * as core from '@actions/core';
import { CloudRun } from './cloudRun';
import { Service } from './service';
/**
* Executes the main action. It includes the main business logic and is the
* primary entry point. It is documented inline.
*/
async function run(): Promise<void> {
try {
// Get inputs
const image = core.getInput('image');
const name = core.getInput('service');
const envVars = core.getInput('env_vars');
const yaml = core.getInput('metadata');
const credentials = core.getInput('credentials');
const projectId = core.getInput('project_id');
const region = core.getInput('region') || 'us-central1';
// Create Cloud Run client
const client = new CloudRun(region, { projectId, credentials });
// Initialize service
const service = new Service({ image, name, envVars, yaml });
// Deploy service
let serviceResponse = await client.deploy(service);
while (!serviceResponse.status!.url) {
serviceResponse = await client.getService(service);
}
// Set URL as output
core.setOutput('url', serviceResponse.status!.url);
} catch (error) {
core.error(error);
core.setFailed(error.message);
}
}
run();

View file

@ -0,0 +1,131 @@
/*
* Copyright 2020 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 { run_v1 } from 'googleapis';
import fs from 'fs';
import YAML from 'yaml';
export type EnvVar = {
name: string;
value: string;
};
/**
* Available options to create the Service.
*
* @param image Name of the container image to deploy.
* @param name Name of the Cloud Run service.
* @param envVars String list of envvars.
* @param yaml Path to YAML file.
*/
export type ServiceOptions = {
image?: string;
name?: string;
envVars?: string;
yaml?: string;
};
/**
* Construct a Cloud Run Service.
*
* @param opts ServiceOptions.
* @returns Service.
*/
export class Service {
readonly request: run_v1.Schema$Service;
readonly name: string;
constructor(opts: ServiceOptions) {
if ((!opts.name || !opts.image) && !opts.yaml) {
throw new Error('Provide image and services names or a YAML file.');
}
let request: run_v1.Schema$Service = {
apiVersion: 'serving.knative.dev/v1',
kind: 'Service',
metadata: {},
spec: {},
};
// Parse Env Vars
let envVars;
if (opts?.envVars) {
envVars = this.parseEnvVars(opts.envVars);
}
// Parse YAML
if (opts.yaml) {
const file = fs.readFileSync(opts.yaml, 'utf8');
const yaml = YAML.parse(file);
request = yaml as run_v1.Schema$Service;
}
// If name is provided, set or override
if (opts.name) {
if (request.metadata) {
request.metadata.name = opts.name;
} else {
request.metadata = { name: opts.name };
}
}
// If image is provided, set or override
if (opts.image) {
const container: run_v1.Schema$Container = { image: opts.image };
if (request.spec?.template) {
request.spec.template!.spec!.containers = [container];
} else {
request.spec = {
template: {
spec: {
containers: [container],
},
},
};
}
}
// If Env Vars are provided, set or override
if (envVars) {
if (request.spec?.template?.spec?.containers) {
request.spec!.template!.spec!.containers[0]!.env = envVars;
}
}
this.request = request;
this.name = request.metadata!.name!;
}
/**
* Parses a string of the format `KEY1=VALUE1`.
*
* @param envVarInput Env var string to parse.
* @returns EnvVar[].
*/
protected parseEnvVars(envVarInput: string): EnvVar[] {
const envVarList = envVarInput.split(',');
const envVars = envVarList.map((envVar) => {
if (!envVar.includes('=')) {
throw new TypeError(
`Env Vars must be in "KEY1=VALUE1,KEY2=VALUE2" format, received ${envVar}`,
);
}
const keyValue = envVar.split('=');
return { name: keyValue[0], value: keyValue[1] };
});
return envVars;
}
}

View file

@ -0,0 +1,38 @@
/*
* Copyright 2020 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 { GoogleAuth } from 'google-auth-library';
describe('E2E tests', function() {
let URL: string;
before(function() {
if (process.env.URL) {
URL = process.env.URL;
} else {
throw Error('URL not found.');
}
});
it('can make a request', async function() {
// Requires ADC to be set
const auth = new GoogleAuth();
const client = await auth.getIdTokenClient(URL);
const response = await client.request({ url: URL });
expect(response.status).to.be.equal(200);
expect(response.data).to.include('Congrat');
});
});

View file

@ -0,0 +1,63 @@
/*
* Copyright 2020 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 { CloudRun } from '../../src/cloudRun';
import { Service } from '../../src/service';
import { JWT } from 'google-auth-library';
const credentials = process.env.TEST_DEPLOY_CLOUDRUN_CREDENTIALS;
const project = process.env.TEST_DEPLOY_CLOUDRUN_PROJECT;
const region = 'us-central1';
const image = 'gcr.io/cloudrun/hello';
const name = `test-${Math.round(Math.random() * 100000)}`; // Cloud Run currently has name length restrictions
const service = new Service({ image, name });
describe('CloudRun', function() {
it('initializes with JSON creds', function() {
const client = new CloudRun(region, {
credentials: `{"foo":"bar"}`,
projectId: 'test',
});
expect(client.auth.jsonContent).eql({ foo: 'bar' });
});
it('initializes with ADC', async function() {
const client = new CloudRun(region);
expect(client.auth.jsonContent).eql(null);
const auth = (await client.getAuthClient()) as JWT;
expect(auth.key).to.not.eql(undefined);
});
it('can deploy service', async function() {
if (!credentials) {
this.skip();
}
const client = new CloudRun(region, {
credentials: credentials,
projectId: project,
});
let result = await client.deploy(service);
while (!result.status!.url) {
result = await client.getService(service);
}
expect(result).to.not.eql(null);
expect(result.status!.url).to.include('run.app');
await client.delete(service);
});
});

View file

@ -0,0 +1,9 @@
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
name: test-basic-yaml
spec:
template:
spec:
containers:
- image: gcr.io/cloudrun/hello

View file

@ -0,0 +1,20 @@
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
name: test-full-yaml
spec:
template:
spec:
containerConcurrency: 20
containers:
- image: gcr.io/cloudrun/hello
ports:
- containerPort: 8080
resources:
limits:
cpu: '2'
memory: 1Gi
timeoutSeconds: 300
traffic:
- latestRevision: true
percent: 100

View file

@ -0,0 +1,104 @@
/*
* Copyright 2020 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 { get } from 'lodash';
import 'mocha';
import { Service, EnvVar } from '../../src/service';
const image = 'gcr.io/projectId/image';
const name = 'serviceName';
const yamlImage = 'gcr.io/cloudrun/hello';
describe('Service', function() {
it('creates a service', function() {
const service = new Service({ image, name });
expect(service.request.metadata!.name).equal(name);
expect(service.request).to.have.property('kind');
});
it('parses one env vars', function() {
const envVars = 'KEY1=VALUE1';
const service = new Service({ image, name, envVars });
const containers = get(service, 'request.spec.template.spec.containers');
const actual = containers[0]?.env[0];
const expected: EnvVar = {
name: 'KEY1',
value: 'VALUE1',
};
expect(actual.name).equal(expected.name);
});
it('parses three env vars', function() {
const envVars = 'KEY1=VALUE1,KEY2=VALUE2,KEY3=VALUE3';
const service = new Service({ image, name, envVars });
const containers = get(service, 'request.spec.template.spec.containers');
const actual = containers[0]?.env;
expect(actual).to.have.lengthOf(3);
expect(actual[0]).to.eql({ name: 'KEY1', value: 'VALUE1' });
});
it('throws error with bad env vars', function() {
const envVars = 'KEY1,VALUE1';
expect(function() {
const service = new Service({ image, name, envVars });
}).to.throw(
'Env Vars must be in "KEY1=VALUE1,KEY2=VALUE2" format, received KEY1',
);
});
it('parses yaml', function() {
const yaml = './tests/unit/service.basic.yaml';
const service = new Service({ image, name, yaml });
expect(service.request.metadata!.name).equal(name);
const containers = get(service, 'request.spec.template.spec.containers');
expect(containers[0]?.image).equal(image);
});
it('creates service from yaml', function() {
const yaml = './tests/unit/service.basic.yaml';
const service = new Service({ yaml });
expect(service.request.metadata!.name).equal('test-basic-yaml');
const containers = get(service, 'request.spec.template.spec.containers');
expect(containers[0]!.image).equal(yamlImage);
expect(service.request).to.have.property('kind');
});
it('parses yaml and env vars', function() {
const yaml = './tests/unit/service.basic.yaml';
const envVars = 'KEY1=VALUE1';
const service = new Service({ yaml, envVars });
const containers = get(service, 'request.spec.template.spec.containers');
expect(containers).to.be.length(1);
expect(containers[0].image).to.equal(yamlImage);
expect(containers[0]?.env[0]).to.eql({ name: 'KEY1', value: 'VALUE1' });
});
it('sets args from yaml', function() {
const yaml = './tests/unit/service.full.yaml';
const service = new Service({ yaml });
expect(service.request.metadata!.name!).equal('test-full-yaml');
const containers = get(service, 'request.spec.template.spec.containers');
expect(containers[0]?.resources?.limits?.cpu).equal('2');
expect(containers[0]?.resources?.limits?.memory).equal('1Gi');
const concurrency = get(
service,
'request.spec.template.spec.containerConcurrency',
);
expect(concurrency).equal(20);
});
});

View file

@ -0,0 +1,30 @@
/*
* Copyright 2020 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.
*/
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"lib": [
"es6"
],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"noImplicitAny": true,
"esModuleInterop": true
},
"exclude": ["node_modules", "**/*.test.ts"]
}