mirror of
https://github.com/google-github-actions/setup-gcloud.git
synced 2026-08-21 11:59:23 +00:00
Add appengine-deploy action and refactor setup-gcloud
initial Add test app and tests add license Update ReadMe and runner Fix lint errors Missed fixes Update tests to run integration on PR rename pkg -> setupGloudSDK Update ts congig Remove project id requirement Update gcloud set project id func Add unit tests for pkg Add set project to action rebuild Fix gcloud typo lint pkg and update project id dependency Update tmp JSON writer Add cleanup to integration tests Update get input validation fix json string Fix test order and version name add project id to test remove clean up step Update project url Add no promote flag test stderr vs stdout Update pkg build refactor setup-gcloud clean up Fix tool command for isAuthenticated debugging test command Fix isInstalled method Clean up
This commit is contained in:
parent
2c7422e8e9
commit
d153f05d28
42 changed files with 38575 additions and 6601 deletions
51
.github/workflows/appengine-deploy-it.yml
vendored
51
.github/workflows/appengine-deploy-it.yml
vendored
|
|
@ -1,5 +1,5 @@
|
|||
name: appengine-deploy Integration
|
||||
on: [push]
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
gcloud:
|
||||
name: with setup-gcloud
|
||||
|
|
@ -8,44 +8,53 @@ jobs:
|
|||
- uses: actions/checkout@v2
|
||||
- uses: ./setup-gcloud
|
||||
with:
|
||||
service_account_email: ${{ secrets.GET_SECRETMANAGER_SECRETS_SA_EMAIL }}
|
||||
service_account_key: ${{ secrets.GET_SECRETMANAGER_SECRETS_SA_KEY_B64 }}
|
||||
export_default_credentials: true
|
||||
service_account_key: ${{ secrets.APPENGINE_DEPLOY_SA_KEY_JSON }}
|
||||
- id: deploy
|
||||
name: Deploy to App Engine
|
||||
uses: ./appengine-deploy
|
||||
with:
|
||||
# secrets: |-
|
||||
# token:${{ secrets.GET_SECRETMANAGER_SECRETS_SECRET_REF }}
|
||||
# password:${{ secrets.GET_SECRETMANAGER_SECRETS_SECRET_VERSION_REF }}
|
||||
- name: get output
|
||||
# run: echo '${{ steps.secrets.outputs.token }}${{ steps.secrets.outputs.password }}'
|
||||
project_id: ${{ secrets.APPENGINE_DEPLOY_PROJECT_ID }}
|
||||
deliverables: ./appengine-deploy/test/app.yaml
|
||||
version: gcloud
|
||||
- name: Test Output
|
||||
run: curl '${{ steps.deploy.outputs.url }}'
|
||||
|
||||
b64_json:
|
||||
needs: gcloud
|
||||
name: with base64 json creds
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- id: deploy
|
||||
name: Deploy to App Engine
|
||||
uses: ./appengine-deploy
|
||||
with:
|
||||
credentials: ${{ secrets.GET_SECRETMANAGER_SECRETS_SA_KEY_JSON }}
|
||||
# secrets: |-
|
||||
# token:${{ secrets.GET_SECRETMANAGER_SECRETS_SECRET_REF }}
|
||||
# password:${{ secrets.GET_SECRETMANAGER_SECRETS_SECRET_VERSION_REF }}
|
||||
- name: get output
|
||||
# run: echo '${{ steps.secrets.outputs.token }}${{ steps.secrets.outputs.password }}'
|
||||
credentials: ${{ secrets.APPENGINE_DEPLOY_SA_KEY_B64 }}
|
||||
deliverables: ./appengine-deploy/test/app.yaml
|
||||
version: b64-json
|
||||
promote: false # Allows for deletion
|
||||
- name: Test Output
|
||||
run: curl '${{ steps.deploy.outputs.url }}'
|
||||
- name: Clean Up
|
||||
run: |
|
||||
gcloud app versions delete b64-json --quiet
|
||||
|
||||
json:
|
||||
needs: [gcloud, b64_json]
|
||||
name: with json creds
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- id: deploy
|
||||
name: Deploy to App Engine
|
||||
uses: ./appengine-deploy
|
||||
with:
|
||||
credentials: ${{ secrets.GET_SECRETMANAGER_SECRETS_SA_KEY_JSON }}
|
||||
# secrets: |-
|
||||
# token:${{ secrets.GET_SECRETMANAGER_SECRETS_SECRET_REF }}
|
||||
# password:${{ secrets.GET_SECRETMANAGER_SECRETS_SECRET_VERSION_REF }}
|
||||
- name: get output
|
||||
# run: echo '${{ steps.secrets.outputs.token }}${{ steps.secrets.outputs.password }}'
|
||||
credentials: ${{ secrets.APPENGINE_DEPLOY_SA_KEY_JSON }}
|
||||
deliverables: ./appengine-deploy/test/app.yaml
|
||||
version: json
|
||||
promote: false # Allows for deletion
|
||||
- name: Test Output
|
||||
run: curl '${{ steps.deploy.outputs.url }}'
|
||||
- name: Clean Up
|
||||
run: |
|
||||
gcloud app versions delete json --quiet
|
||||
|
|
|
|||
7
.github/workflows/appengine-deploy.yml
vendored
7
.github/workflows/appengine-deploy.yml
vendored
|
|
@ -21,10 +21,3 @@ jobs:
|
|||
- name: npm lint
|
||||
run: npm run lint
|
||||
working-directory: ./appengine-deploy
|
||||
|
||||
- name: npm test
|
||||
run: npm run test
|
||||
working-directory: ./appengine-deploy
|
||||
env:
|
||||
TEST_GET_SECRETMANAGER_SECRETS_CREDENTIALS: ${{ secrets.GET_SECRETMANAGER_SECRETS_SA_KEY_JSON }}
|
||||
TEST_GET_SECRETMANAGER_SECRETS_SECRET_VERSION_REF: ${{ secrets.GET_SECRETMANAGER_SECRETS_SECRET_VERSION_REF }}
|
||||
|
|
|
|||
3
.github/workflows/setup-gcloud-it.yml
vendored
3
.github/workflows/setup-gcloud-it.yml
vendored
|
|
@ -25,7 +25,6 @@ jobs:
|
|||
uses: ./setup-gcloud/
|
||||
with:
|
||||
version: '286.0.0'
|
||||
service_account_email: ${{ secrets.SETUP_GCLOUD_IT_EMAIL }}
|
||||
service_account_key: ${{ secrets.SETUP_GCLOUD_IT_KEY }}
|
||||
|
||||
- name: Integration Tests
|
||||
|
|
@ -46,7 +45,6 @@ jobs:
|
|||
uses: ./setup-gcloud/
|
||||
with:
|
||||
version: 'latest'
|
||||
service_account_email: ${{ secrets.SETUP_GCLOUD_IT_EMAIL }}
|
||||
service_account_key: ${{ secrets.SETUP_GCLOUD_IT_KEY }}
|
||||
|
||||
- name: Integration Tests
|
||||
|
|
@ -67,7 +65,6 @@ jobs:
|
|||
uses: ./setup-gcloud/
|
||||
with:
|
||||
version: 'latest'
|
||||
service_account_email: ${{ secrets.SETUP_GCLOUD_IT_EMAIL }}
|
||||
service_account_key: ${{ secrets.SETUP_GCLOUD_IT_KEY }}
|
||||
export_default_credentials: true
|
||||
|
||||
|
|
|
|||
4
.github/workflows/setup-gcloud.yml
vendored
4
.github/workflows/setup-gcloud.yml
vendored
|
|
@ -31,7 +31,3 @@ jobs:
|
|||
- name: Lint
|
||||
working-directory: ./setup-gcloud
|
||||
run: npm run lint
|
||||
|
||||
- name: Unit tests
|
||||
working-directory: ./setup-gcloud
|
||||
run: npm test
|
||||
|
|
|
|||
28
.github/workflows/setupGcloudSDK.yml
vendored
Normal file
28
.github/workflows/setupGcloudSDK.yml
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
name: setupGcloudSDK Unit
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
run:
|
||||
name: setupGcloudSDK
|
||||
runs-on: ${{ matrix.operating-system }}
|
||||
strategy:
|
||||
matrix:
|
||||
operating-system: [ubuntu-latest, windows-latest, macos-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Set Node.js 10.x
|
||||
uses: actions/setup-node@master
|
||||
with:
|
||||
node-version: 10.x
|
||||
|
||||
- name: npm install
|
||||
working-directory: ./setupGcloudSDK
|
||||
run: npm install
|
||||
|
||||
- name: Lint
|
||||
working-directory: ./setupGcloudSDK
|
||||
run: npm run lint
|
||||
|
||||
- name: Unit tests
|
||||
working-directory: ./setupGcloudSDK
|
||||
run: npm test
|
||||
|
|
@ -1,19 +1,137 @@
|
|||
<!--
|
||||
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.
|
||||
-->
|
||||
# appengine-deploy
|
||||
|
||||
This action deploys your source code to App Engine and makes the URL available to
|
||||
later build steps via outputs. This allows you to parameterize your App Engine
|
||||
deployments.
|
||||
|
||||
What does it do:
|
||||
|
||||
**Note** This action will install gcloud in the background if not using in with
|
||||
the [`setup-gcloud` action](../setup-gclou/README.md).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
## inputs
|
||||
This action requires Google Cloud credentials that are authorized to deploy an
|
||||
App Engine Application. See the Authorization section below for more information.
|
||||
|
||||
## outputs
|
||||
## Usage
|
||||
|
||||
## Example Usage
|
||||
```yaml
|
||||
steps:
|
||||
- id: deploy
|
||||
uses: GoogleCloudPlatform/github-actions/appengine-deploy@master
|
||||
with:
|
||||
credentials: ${{ secrets.gcp_credentials }}
|
||||
|
||||
Used with setup-gcloud
|
||||
or add Credentials
|
||||
# Example of using the output
|
||||
- id: test
|
||||
run: curl "${{ steps.deploy.outputs.url }}"
|
||||
```
|
||||
|
||||
## Inputs
|
||||
|
||||
- `project_id`: (Optional) ID of the Google Cloud project. If provided, this
|
||||
will override the project configured by gcloud.
|
||||
|
||||
- `deliverables`: (Optional) The [yaml files](https://cloud.google.com/appengine/docs/standard/nodejs/configuration-files#optional_configuration_files)
|
||||
for the services or configurations you want to deploy. If not given, defaults
|
||||
to app.yaml in the current directory. If that is not found, attempts to
|
||||
automatically generate necessary configuration files (such as app.yaml) in
|
||||
the current directory.
|
||||
|
||||
- `image-url`: (Optional) Deploy with a specific container image. The image url
|
||||
must be from one of the valid GCR hostnames (example, `gcr.io/`).
|
||||
|
||||
- `version`: (Optional) The version of the app that will be created or replaced
|
||||
by this deployment. If you do not specify a version, one will be generated for
|
||||
you.
|
||||
|
||||
- `promote`: (Optional) Promote the deployed version to receive all traffic.
|
||||
|
||||
### app.yaml customizations
|
||||
|
||||
Other application configurations can be customized through the app.yaml, ie the
|
||||
service name. See [app.yaml Configuration File](https://cloud.google.com/appengine/docs/standard/nodejs/config/appref)
|
||||
for more information.
|
||||
|
||||
## Outputs
|
||||
|
||||
- `url`: The URL of your App Engine Application.
|
||||
|
||||
## Authorization
|
||||
|
||||
There are a few ways to authenticate this action. The caller must have
|
||||
permissions to access the secrets being requested.
|
||||
|
||||
Roles needed:
|
||||
|
||||
- Storage Admin (`roles/compute.storageAdmin`):to upload files
|
||||
- Cloud Build Editor (`cloudbuild.builds.editor`): to build the application
|
||||
- App Engine roles:
|
||||
- Deployer (`roles/appengine.deployer`): Can deploy but can not promote
|
||||
- Admin (`roles/appengine.appAdmin`): Can manage all App Engine resources (not recommended)
|
||||
|
||||
*Note:* An owner will be needed to create the App Engine application
|
||||
|
||||
### Used with setup-gcloud
|
||||
|
||||
You can provide credentials using the [setup-gcloud][setup-gcloud] action,
|
||||
however you must provide your Project ID to the `appeninge-deploy` action:
|
||||
|
||||
```yaml
|
||||
- uses: GoogleCloudPlatform/github-actions/setup-gcloud@master
|
||||
with:
|
||||
export_default_credentials: true
|
||||
- uses: GoogleCloudPlatform/github-actions/appengine-deploy@master
|
||||
with:
|
||||
project_id: ${{ secrets.project_id }}
|
||||
```
|
||||
|
||||
### 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: secrets
|
||||
uses: GoogleCloudPlatform/github-actions/appengine-deploy@master
|
||||
with:
|
||||
credentials: ${{ secrets.gcp_credentials }}
|
||||
```
|
||||
|
||||
### 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: secrets
|
||||
uses: GoogleCloudPlatform/github-actions/appengine-deploy@master
|
||||
```
|
||||
|
||||
The action will automatically detect and use the Application Default
|
||||
Credentials.
|
||||
|
||||
|
||||
[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
|
||||
|
|
|
|||
|
|
@ -1,17 +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.
|
||||
name: 'Deploy to Google App Engine'
|
||||
description: 'Deploy to Google App Engine'
|
||||
author: 'GoogleCloudPlatform'
|
||||
|
||||
inputs:
|
||||
project_id:
|
||||
description: 'The GCP project ID'
|
||||
description: 'The GCP project ID. Overrides project ID set by credentials.'
|
||||
require: true
|
||||
deliverables:
|
||||
description: |-
|
||||
The yaml files for the services or configurations you want to deploy.
|
||||
If not given, defaults to app.yaml in the current directory. If that is
|
||||
not found, attempts to automatically generate necessary configuration
|
||||
files (such as app.yaml) in the current directory.'
|
||||
files (such as app.yaml) in the current directory.
|
||||
required: false
|
||||
image_url:
|
||||
description: |-
|
||||
|
|
@ -22,7 +35,7 @@ inputs:
|
|||
description: |-
|
||||
The version of the app that will be created or replaced by this
|
||||
deployment. If you do not specify a version, one will be generated for
|
||||
you.'
|
||||
you.
|
||||
required: false
|
||||
promote:
|
||||
description: 'Promote the deployed version to receive all traffic.'
|
||||
|
|
|
|||
12710
appengine-deploy/dist/index.js
vendored
12710
appengine-deploy/dist/index.js
vendored
File diff suppressed because it is too large
Load diff
99
appengine-deploy/package-lock.json
generated
99
appengine-deploy/package-lock.json
generated
|
|
@ -9,6 +9,14 @@
|
|||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.2.2.tgz",
|
||||
"integrity": "sha512-IbCx7oefq+Gi6FWbSs2Fnw8VkEI6Y4gvjrYprY3RV//ksq/KPMlClOerJ4jRosyal6zkUIc8R9fS/cpRMlGClg=="
|
||||
},
|
||||
"@actions/exec": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.0.3.tgz",
|
||||
"integrity": "sha512-TogJGnueOmM7ntCi0ASTUj4LapRRtDfj57Ja4IhPmg2fls28uVOPbAn8N+JifaOumN2UG3oEO/Ixek2A4NcYSA==",
|
||||
"requires": {
|
||||
"@actions/io": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"@actions/github": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@actions/github/-/github-2.1.1.tgz",
|
||||
|
|
@ -27,6 +35,31 @@
|
|||
"tunnel": "0.0.6"
|
||||
}
|
||||
},
|
||||
"@actions/io": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@actions/io/-/io-1.0.2.tgz",
|
||||
"integrity": "sha512-J8KuFqVPr3p6U8W93DOXlXW6zFvrQAJANdS+vw0YhusLIq+bszW8zmK2Fh1C2kDPX8FMvwIl1OUcFgvJoXLbAg=="
|
||||
},
|
||||
"@actions/tool-cache": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@actions/tool-cache/-/tool-cache-1.3.1.tgz",
|
||||
"integrity": "sha512-sKoEJv0/c7WzjPEq2PO12Sc8QdEp58XIBHMm3c4lUn/iZWgLz9HBeCuFGpLQjDvXJNfLZ4g+WD+rMjgOmpH4Ag==",
|
||||
"requires": {
|
||||
"@actions/core": "^1.2.0",
|
||||
"@actions/exec": "^1.0.0",
|
||||
"@actions/http-client": "^1.0.3",
|
||||
"@actions/io": "^1.0.1",
|
||||
"semver": "^6.1.0",
|
||||
"uuid": "^3.3.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"semver": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
|
||||
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"@babel/code-frame": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.8.3.tgz",
|
||||
|
|
@ -200,6 +233,11 @@
|
|||
"integrity": "sha512-OCutwjDZ4aFS6PB1UZ988C4YgwlBHJd6wCeQqaLdmadZ/7e+w79+hbMUFC1QXDNCmdyoRfAFdm0RypzwR+Qpag==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/js-base64": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/js-base64/-/js-base64-2.3.1.tgz",
|
||||
"integrity": "sha512-4RKbhIDGC87s4EBy2Cp2/5S2O6kmCRcZnD5KRCq1q9z2GhBte1+BdsfVKCpG8yKpDGNyEE2G6IqFIh6W2YwWPA=="
|
||||
},
|
||||
"@types/json-schema": {
|
||||
"version": "7.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.4.tgz",
|
||||
|
|
@ -223,6 +261,11 @@
|
|||
"integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/tmp": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.1.0.tgz",
|
||||
"integrity": "sha512-6IwZ9HzWbCq6XoQWhxLpDjuADodH/MKXRUIDFudvgjcVdjFknvmR+DNsoUeer4XPrEnrZs04Jj+kfV9pFsrhmA=="
|
||||
},
|
||||
"@typescript-eslint/eslint-plugin": {
|
||||
"version": "2.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-2.21.0.tgz",
|
||||
|
|
@ -387,8 +430,7 @@
|
|||
"balanced-match": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
|
||||
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
|
||||
"dev": true
|
||||
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c="
|
||||
},
|
||||
"before-after-hook": {
|
||||
"version": "2.1.0",
|
||||
|
|
@ -405,7 +447,6 @@
|
|||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
|
|
@ -588,8 +629,7 @@
|
|||
"concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
|
||||
"dev": true
|
||||
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
|
||||
},
|
||||
"cosmiconfig": {
|
||||
"version": "6.0.0",
|
||||
|
|
@ -906,6 +946,17 @@
|
|||
"chardet": "^0.7.0",
|
||||
"iconv-lite": "^0.4.24",
|
||||
"tmp": "^0.0.33"
|
||||
},
|
||||
"dependencies": {
|
||||
"tmp": {
|
||||
"version": "0.0.33",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
|
||||
"integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"os-tmpdir": "~1.0.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"fast-deep-equal": {
|
||||
|
|
@ -1007,8 +1058,7 @@
|
|||
"fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
|
||||
"dev": true
|
||||
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
|
||||
},
|
||||
"fsevents": {
|
||||
"version": "2.1.2",
|
||||
|
|
@ -1059,7 +1109,6 @@
|
|||
"version": "7.1.6",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
|
||||
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
|
|
@ -1225,7 +1274,6 @@
|
|||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
"integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"once": "^1.3.0",
|
||||
"wrappy": "1"
|
||||
|
|
@ -1234,8 +1282,7 @@
|
|||
"inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"inquirer": {
|
||||
"version": "7.0.4",
|
||||
|
|
@ -1365,6 +1412,11 @@
|
|||
"resolved": "https://registry.npmjs.org/isobject/-/isobject-4.0.0.tgz",
|
||||
"integrity": "sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA=="
|
||||
},
|
||||
"js-base64": {
|
||||
"version": "2.5.2",
|
||||
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.5.2.tgz",
|
||||
"integrity": "sha512-Vg8czh0Q7sFBSUMWWArX/miJeBWYBPpdU/3M/DKSaekLMqrqVPaedp+5mZhie/r0lgrcaYBfwXatEew6gwgiQQ=="
|
||||
},
|
||||
"js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
|
|
@ -1475,7 +1527,6 @@
|
|||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
|
||||
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
}
|
||||
|
|
@ -1809,8 +1860,7 @@
|
|||
"path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
"integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
|
||||
"dev": true
|
||||
"integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18="
|
||||
},
|
||||
"path-key": {
|
||||
"version": "2.0.1",
|
||||
|
|
@ -1948,7 +1998,6 @@
|
|||
"version": "2.6.3",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz",
|
||||
"integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"glob": "^7.1.3"
|
||||
}
|
||||
|
|
@ -2194,12 +2243,11 @@
|
|||
"dev": true
|
||||
},
|
||||
"tmp": {
|
||||
"version": "0.0.33",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
|
||||
"integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==",
|
||||
"dev": true,
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.1.0.tgz",
|
||||
"integrity": "sha512-J7Z2K08jbGcdA1kkQpJSqLF6T0tdQqpR2pnSUXsIchbPdTI9v3e85cLW0d6WDhwuAleOV71j2xWs8qMPfK7nKw==",
|
||||
"requires": {
|
||||
"os-tmpdir": "~1.0.2"
|
||||
"rimraf": "^2.6.3"
|
||||
}
|
||||
},
|
||||
"to-regex-range": {
|
||||
|
|
@ -2274,9 +2322,9 @@
|
|||
"dev": true
|
||||
},
|
||||
"typescript": {
|
||||
"version": "3.8.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.8.2.tgz",
|
||||
"integrity": "sha512-EgOVgL/4xfVrCMbhYKUQTdF37SQn4Iw73H5BgCrF1Abdun7Kwy/QZsE/ssAy0y4LxBbvua3PIbFsbRczWWnDdQ==",
|
||||
"version": "3.8.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.8.3.tgz",
|
||||
"integrity": "sha512-MYlEfn5VrLNsgudQTVJeNaQFUAI7DkhnOjdpAp4T+ku1TfQClewlbSuTVHiA+8skNBgaf02TL/kLOvig4y3G8w==",
|
||||
"dev": true
|
||||
},
|
||||
"universal-user-agent": {
|
||||
|
|
@ -2296,6 +2344,11 @@
|
|||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"uuid": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
|
||||
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A=="
|
||||
},
|
||||
"v8-compile-cache": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
"scripts": {
|
||||
"build": "ncc build src/main.ts",
|
||||
"lint": "eslint . --ext .ts,.tsx",
|
||||
"test": "mocha -r ts-node/register 'tests/*.test.ts'"
|
||||
"format": "prettier --write **/*.ts"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
@ -22,8 +22,14 @@
|
|||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.2.2",
|
||||
"@actions/exec": "^1.0.3",
|
||||
"@actions/github": "^2.1.1",
|
||||
"child_process": "^1.0.2"
|
||||
"@actions/tool-cache": "^1.3.1",
|
||||
"@types/js-base64": "^2.3.1",
|
||||
"@types/tmp": "^0.1.0",
|
||||
"child_process": "^1.0.2",
|
||||
"js-base64": "^2.5.2",
|
||||
"tmp": "^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chai": "^4.2.x",
|
||||
|
|
|
|||
|
|
@ -15,37 +15,91 @@
|
|||
*/
|
||||
|
||||
import * as core from '@actions/core';
|
||||
import { execSync } from 'child_process';
|
||||
/**
|
||||
* Executes the main action. It includes the main business logic and is the
|
||||
* primary entry point. It is documented inline.
|
||||
*/
|
||||
import * as exec from '@actions/exec';
|
||||
import * as setupGcloud from '../../setupGcloudSDK/dist/index';
|
||||
|
||||
async function run(): Promise<void> {
|
||||
try {
|
||||
// Get action inputs.
|
||||
const projectId = core.getInput('project_id', { required: true });
|
||||
let projectId = core.getInput('project_id');
|
||||
const deliverables = core.getInput('deliverables');
|
||||
const imageUrl = core.getInput('image-url');
|
||||
const version = core.getInput('version');
|
||||
const promote = core.getInput('promote');
|
||||
const serviceAccountKey = core.getInput('credentials');
|
||||
|
||||
// Get credentials, if any.
|
||||
const credentials = core.getInput('credentials');
|
||||
// Install gcloud if not already installed.
|
||||
if (!setupGcloud.isInstalled()) {
|
||||
const gcloudVersion = await setupGcloud.getLatestGcloudSDKVersion();
|
||||
await setupGcloud.installGcloudSDK(gcloudVersion);
|
||||
}
|
||||
|
||||
// gcloud Flag set up.
|
||||
const addImageUrl = imageUrl ? `--image-url=${imageUrl}` : '';
|
||||
const addVersion = version ? `--version=${version}` : '';
|
||||
const addPromote = promote ? '--promote' : '--no-promote';
|
||||
// Fail if no Project Id is provided.
|
||||
if (projectId === '' && serviceAccountKey === '') {
|
||||
core.setFailed('No project Id provided.');
|
||||
}
|
||||
|
||||
// Run gcloud
|
||||
execSync(`gcloud app deploy ${deliverables} --project=${projectId} \
|
||||
${addImageUrl} \
|
||||
${addVersion} \
|
||||
${addPromote}`);
|
||||
// Authenticate gcloud SDK.
|
||||
if (serviceAccountKey) {
|
||||
await setupGcloud.authenticateGcloudSDK(serviceAccountKey);
|
||||
// Set and retrieve Project Id if not provided
|
||||
if (projectId === '') {
|
||||
projectId = await setupGcloud.setProject(serviceAccountKey);
|
||||
}
|
||||
}
|
||||
if (!setupGcloud.isAuthenticated()) {
|
||||
core.setFailed('Error authenticating the Cloud SDK.');
|
||||
}
|
||||
|
||||
// Set output url.
|
||||
// Note: Any implications of just setting this??
|
||||
core.setOutput('url', `https://${projectId}.appspot.com/`);
|
||||
const toolCommand = setupGcloud.getToolCommand();
|
||||
|
||||
// Create app engine gcloud cmd.
|
||||
const appDeployCmd = ['app', 'deploy', '--quiet', deliverables];
|
||||
|
||||
// Add gcloud flags.
|
||||
if (projectId !== '') {
|
||||
appDeployCmd.push('--project', projectId);
|
||||
}
|
||||
if (imageUrl !== '') {
|
||||
appDeployCmd.push('--image-url', imageUrl);
|
||||
}
|
||||
if (version !== '') {
|
||||
appDeployCmd.push('--version', version);
|
||||
}
|
||||
if (promote === '' || String(promote).toLowerCase() === 'true') {
|
||||
appDeployCmd.push('--promote');
|
||||
} else {
|
||||
appDeployCmd.push('--no-promote');
|
||||
}
|
||||
|
||||
// Get output of gcloud cmd.
|
||||
let output = '';
|
||||
const stdout = (data: Buffer): void => {
|
||||
output += data.toString();
|
||||
};
|
||||
|
||||
const options = {
|
||||
listeners: {
|
||||
stderr: stdout,
|
||||
},
|
||||
};
|
||||
|
||||
// Run gcloud cmd.
|
||||
await exec.exec(toolCommand, appDeployCmd, options);
|
||||
|
||||
// Set url as output.
|
||||
const urlMatch = output.match(
|
||||
/https:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.com/,
|
||||
);
|
||||
if (urlMatch) {
|
||||
const url = urlMatch[0];
|
||||
core.setOutput('url', url);
|
||||
} else {
|
||||
core.info(
|
||||
'Can not find URL, defaulting to https://PROJECT_ID.appspot.com',
|
||||
);
|
||||
core.setOutput('url', `https://${projectId}.appspot.com`);
|
||||
}
|
||||
} catch (error) {
|
||||
core.setFailed(error.message);
|
||||
}
|
||||
|
|
|
|||
1
appengine-deploy/test/app.yaml
Normal file
1
appengine-deploy/test/app.yaml
Normal file
|
|
@ -0,0 +1 @@
|
|||
runtime: python37
|
||||
35
appengine-deploy/test/main.py
Normal file
35
appengine-deploy/test/main.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Copyright 2018 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.
|
||||
|
||||
# [START gae_python37_app]
|
||||
from flask import Flask
|
||||
|
||||
|
||||
# If `entrypoint` is not defined in app.yaml, App Engine will look for an app
|
||||
# called `app` in `main.py`.
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
@app.route('/')
|
||||
def hello():
|
||||
"""Return a friendly HTTP greeting."""
|
||||
return 'Hello World!'
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# This is used when running locally only. When deploying to Google App
|
||||
# Engine, a webserver process such as Gunicorn will serve the app. This
|
||||
# can be configured by adding an `entrypoint` to app.yaml.
|
||||
app.run(host='127.0.0.1', port=8080, debug=True)
|
||||
# [END gae_python37_app]
|
||||
1
appengine-deploy/test/requirements.txt
Normal file
1
appengine-deploy/test/requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Flask==1.1.1
|
||||
|
|
@ -24,7 +24,12 @@
|
|||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"esModuleInterop": true
|
||||
"esModuleInterop": true,
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../setupGcloudSDK"
|
||||
}
|
||||
],
|
||||
"exclude": ["node_modules", "**/*.test.ts"]
|
||||
}
|
||||
|
|
|
|||
17440
setup-gcloud/dist/index.js
vendored
17440
setup-gcloud/dist/index.js
vendored
File diff suppressed because it is too large
Load diff
|
|
@ -6,8 +6,7 @@
|
|||
"scripts": {
|
||||
"build": "ncc build src/setup-gcloud.ts",
|
||||
"lint": "eslint . --ext .ts,.tsx",
|
||||
"format": "prettier --write **/*.ts",
|
||||
"test": "mocha -r ts-node/register -t 120s 'tests/*.test.ts'"
|
||||
"format": "prettier --write **/*.ts"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
|
|
|
|||
|
|
@ -13,113 +13,37 @@
|
|||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as core from '@actions/core';
|
||||
import * as exec from '@actions/exec';
|
||||
import * as toolCache from '@actions/tool-cache';
|
||||
import * as setupGcloud from '../../setupGcloudSDK/dist/index';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as tmp from 'tmp';
|
||||
import * as os from 'os';
|
||||
import { getReleaseURL } from '../src/format-url';
|
||||
import { getLatestGcloudSDKVersion } from '../src/version-util';
|
||||
import * as downloadUtil from './download-util';
|
||||
import * as installUtil from './install-util';
|
||||
|
||||
async function installGcloudSDK(version: string): Promise<string> {
|
||||
// retreive the release corresponding to the specified version and the current env
|
||||
const osPlat = os.platform();
|
||||
const osArch = os.arch();
|
||||
const url = await getReleaseURL(osPlat, osArch, version);
|
||||
|
||||
// download and extract the release
|
||||
const extPath = await downloadUtil.downloadAndExtractTool(url);
|
||||
if (!extPath) {
|
||||
throw new Error(`Failed to download release, url: ${url}`);
|
||||
}
|
||||
|
||||
// install the downloaded release into the github action env
|
||||
return await installUtil.installGcloudSDK(version, extPath);
|
||||
}
|
||||
|
||||
async function run(): Promise<void> {
|
||||
try {
|
||||
tmp.setGracefulCleanup();
|
||||
|
||||
let version = core.getInput('version');
|
||||
if (!version || version == 'latest') {
|
||||
version = await getLatestGcloudSDKVersion();
|
||||
version = await setupGcloud.getLatestGcloudSDKVersion();
|
||||
}
|
||||
|
||||
// install the gcloud if not already present
|
||||
let toolPath = toolCache.find('gcloud', version);
|
||||
if (!toolPath) {
|
||||
toolPath = await installGcloudSDK(version);
|
||||
core.info('Successfully installed gcloud Cloud SDK');
|
||||
// Install the gcloud if not already present
|
||||
if (!setupGcloud.isInstalled()) {
|
||||
await setupGcloud.installGcloudSDK(version);
|
||||
} else {
|
||||
const toolPath = toolCache.find('gcloud', version);
|
||||
core.addPath(path.join(toolPath, 'bin'));
|
||||
}
|
||||
|
||||
// A workaround for https://github.com/actions/toolkit/issues/229
|
||||
// Currently exec on windows runs as cmd shell.
|
||||
let toolCommand = 'gcloud';
|
||||
if (process.platform == 'win32') {
|
||||
toolCommand = 'gcloud.cmd';
|
||||
}
|
||||
|
||||
// Set the project ID, if given.
|
||||
const projectId = core.getInput('project_id');
|
||||
if (projectId) {
|
||||
await exec.exec(toolCommand, [
|
||||
'--quiet',
|
||||
'config',
|
||||
'set',
|
||||
'core/project',
|
||||
projectId,
|
||||
]);
|
||||
core.info('Successfully set default project');
|
||||
}
|
||||
|
||||
const serviceAccountEmail = core.getInput('service_account_email') || '';
|
||||
const serviceAccountKey = core.getInput('service_account_key');
|
||||
|
||||
// if a service account key isn't provided, log an un-authenticated notice
|
||||
// If a service account key isn't provided, log an un-authenticated notice
|
||||
if (!serviceAccountKey) {
|
||||
core.info('No credentials provided, skipping authentication');
|
||||
return;
|
||||
} else {
|
||||
await setupGcloud.authenticateGcloudSDK(serviceAccountKey);
|
||||
}
|
||||
|
||||
// Handle base64-encoded credentials
|
||||
let serviceAccountJSON = serviceAccountKey;
|
||||
if (!serviceAccountKey.trim().startsWith('{')) {
|
||||
serviceAccountJSON = Buffer.from(serviceAccountKey, 'base64').toString();
|
||||
}
|
||||
|
||||
// write the service account key to a temporary file
|
||||
//
|
||||
// TODO: if actions/toolkit#164 is fixed, pass the key in on stdin and avoid
|
||||
// writing a file to disk.
|
||||
const tmpKeyFilePath = await new Promise<string>((resolve, reject) => {
|
||||
tmp.file((err, path) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
}
|
||||
resolve(path);
|
||||
});
|
||||
});
|
||||
await fs.writeFile(tmpKeyFilePath, serviceAccountJSON);
|
||||
|
||||
// Authenticate as the specified service account.
|
||||
await exec.exec(toolCommand, [
|
||||
'--quiet',
|
||||
'auth',
|
||||
'activate-service-account',
|
||||
serviceAccountEmail,
|
||||
'--key-file',
|
||||
tmpKeyFilePath,
|
||||
]);
|
||||
|
||||
// Export credentials if requested - these credentials must be exported in
|
||||
// the shared workspace directory, since the filesystem must be shared among
|
||||
// all steps.
|
||||
|
|
@ -131,7 +55,10 @@ async function run(): Promise<void> {
|
|||
}
|
||||
|
||||
const credsPath = path.join(workspace, uuidv4());
|
||||
await fs.writeFile(credsPath, serviceAccountJSON);
|
||||
await fs.writeFile(
|
||||
credsPath,
|
||||
setupGcloud.parseServiceAccountKey(serviceAccountKey),
|
||||
);
|
||||
|
||||
core.exportVariable('GOOGLE_APPLICATION_CREDENTIALS', credsPath);
|
||||
core.info('Successfully exported Default Application Credentials');
|
||||
|
|
|
|||
|
|
@ -1,68 +0,0 @@
|
|||
/*
|
||||
* Copyright 2019 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.
|
||||
*/
|
||||
|
||||
/*
|
||||
* Tests install-util.
|
||||
*/
|
||||
import 'mocha';
|
||||
import { expect } from 'chai';
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import * as io from '@actions/io';
|
||||
|
||||
import { TestToolCache, TEST_SDK_VERSION } from '../src/test-util';
|
||||
const [toolDir, tempDir] = TestToolCache.override();
|
||||
|
||||
import { getReleaseURL } from '../src/format-url';
|
||||
|
||||
import {
|
||||
GCLOUD_METRICS_ENV_VAR,
|
||||
GCLOUD_METRICS_LABEL,
|
||||
installGcloudSDK,
|
||||
} from '../src/install-util';
|
||||
|
||||
import { downloadAndExtractTool } from '../src/download-util';
|
||||
|
||||
describe('#installGcloudSDK', () => {
|
||||
before(async function() {
|
||||
await io.rmRF(toolDir);
|
||||
await io.rmRF(tempDir);
|
||||
});
|
||||
|
||||
after(async function() {
|
||||
await io.rmRF(toolDir);
|
||||
await io.rmRF(tempDir);
|
||||
});
|
||||
|
||||
it('installs gcloud for current env', async function() {
|
||||
const url = await getReleaseURL(os.platform(), os.arch(), TEST_SDK_VERSION);
|
||||
const extPath = await downloadAndExtractTool(url);
|
||||
await installGcloudSDK(TEST_SDK_VERSION, extPath);
|
||||
const gcloudDir = path.join(toolDir, 'gcloud', TEST_SDK_VERSION, os.arch());
|
||||
|
||||
expect(fs.existsSync(gcloudDir)).to.be.true;
|
||||
expect(fs.existsSync(path.join(gcloudDir, 'bin', 'gcloud'))).to.be.true;
|
||||
});
|
||||
|
||||
it('installs metrics tracking env var', async function() {
|
||||
const url = await getReleaseURL(os.platform(), os.arch(), TEST_SDK_VERSION);
|
||||
const extPath = await downloadAndExtractTool(url);
|
||||
await installGcloudSDK(TEST_SDK_VERSION, extPath);
|
||||
expect(process.env[GCLOUD_METRICS_ENV_VAR]).to.equal(GCLOUD_METRICS_LABEL);
|
||||
});
|
||||
});
|
||||
|
|
@ -26,5 +26,10 @@
|
|||
"noImplicitAny": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../setupGcloudSDK"
|
||||
}
|
||||
],
|
||||
"exclude": ["node_modules", "**/*.test.ts"]
|
||||
}
|
||||
|
|
|
|||
28
setupGcloudSDK/.eslintrc.js
Normal file
28
setupGcloudSDK/.eslintrc.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* 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',
|
||||
],
|
||||
};
|
||||
45
setupGcloudSDK/.gitignore
vendored
Normal file
45
setupGcloudSDK/.gitignore
vendored
Normal 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
|
||||
30
setupGcloudSDK/.prettierrc.js
Normal file
30
setupGcloudSDK/.prettierrc.js
Normal 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,
|
||||
};
|
||||
7
setupGcloudSDK/dist/download-util.d.ts
vendored
Normal file
7
setupGcloudSDK/dist/download-util.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* Downloads and extracts the tool at the specified URL.
|
||||
*
|
||||
* @url The URL of the tool to be downloaded.
|
||||
* @returns The path to the locally extracted tool.
|
||||
*/
|
||||
export declare function downloadAndExtractTool(url: string): Promise<string>;
|
||||
10
setupGcloudSDK/dist/format-url.d.ts
vendored
Normal file
10
setupGcloudSDK/dist/format-url.d.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* Creates the gcloud SDK release URL for the specified arguments, verifying
|
||||
* its existence.
|
||||
*
|
||||
* @param os The OS of the requested release.
|
||||
* @param arch The system architecture of the requested release.
|
||||
* @param version The version of the requested release.
|
||||
* @returns The verified gcloud SDK release URL.
|
||||
*/
|
||||
export declare function getReleaseURL(os: string, arch: string, version: string): Promise<string>;
|
||||
62
setupGcloudSDK/dist/index.d.ts
vendored
Normal file
62
setupGcloudSDK/dist/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { getLatestGcloudSDKVersion } from './version-util';
|
||||
export { getLatestGcloudSDKVersion };
|
||||
/**
|
||||
* Checks if gcloud is installed
|
||||
*
|
||||
* @param version (Optional) Cloud SDK version
|
||||
* @return true if gcloud is found in toolpath
|
||||
*/
|
||||
export declare function isInstalled(version?: string): boolean;
|
||||
/**
|
||||
* Returns the correct gcloud command for OS
|
||||
*
|
||||
* @returns gcloud command
|
||||
*/
|
||||
export declare function getToolCommand(): string;
|
||||
/**
|
||||
* Checks if gcloud is authenticated
|
||||
*
|
||||
* @returns true is gcloud is authenticated
|
||||
*/
|
||||
export declare function isAuthenticated(): Promise<boolean>;
|
||||
/**
|
||||
* Installs the gcloud SDK into the actions environment.
|
||||
*
|
||||
* @param version The version being installed.
|
||||
* @param gcloudExtPath The extraction path for the gcloud SDK.
|
||||
* @returns The path of the installed tool.
|
||||
*/
|
||||
export declare function installGcloudSDK(version: string): Promise<string>;
|
||||
/**
|
||||
* Parses the service account string into JSON
|
||||
*
|
||||
* @param serviceAccountKey The service account key used for authentication.
|
||||
* @returns ServiceAccountKey object
|
||||
*/
|
||||
export declare function parseServiceAccountKey(serviceAccountKey: string): ServiceAccountKey;
|
||||
/**
|
||||
* Authenticates the gcloud tool using a service account key
|
||||
*
|
||||
* @param serviceAccountKey The service account key used for authentication.
|
||||
* @returns exit code
|
||||
*/
|
||||
export declare function authenticateGcloudSDK(serviceAccountKey: string): Promise<number>;
|
||||
/**
|
||||
* Sets the GCP Project Id in the gcloud config
|
||||
*
|
||||
* @param serviceAccountKey The service account key used for authentication.
|
||||
* @returns project ID
|
||||
*/
|
||||
export declare function setProject(serviceAccountKey: string): Promise<string>;
|
||||
interface ServiceAccountKey {
|
||||
type: string;
|
||||
project_id: string;
|
||||
project_key_id: string;
|
||||
private_key: string;
|
||||
client_email: string;
|
||||
client_id: string;
|
||||
auth_uri: string;
|
||||
token_uri: string;
|
||||
auth_provider_x509_cert_url: string;
|
||||
client_x509_cert_url: string;
|
||||
}
|
||||
11398
setupGcloudSDK/dist/index.js
vendored
Normal file
11398
setupGcloudSDK/dist/index.js
vendored
Normal file
File diff suppressed because it is too large
Load diff
10
setupGcloudSDK/dist/install-util.d.ts
vendored
Normal file
10
setupGcloudSDK/dist/install-util.d.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export declare const GCLOUD_METRICS_ENV_VAR = "CLOUDSDK_METRICS_ENVIRONMENT";
|
||||
export declare const GCLOUD_METRICS_LABEL = "github-actions-setup-gcloud";
|
||||
/**
|
||||
* Installs the gcloud SDK into the actions environment.
|
||||
*
|
||||
* @param version The version being installed.
|
||||
* @param gcloudExtPath The extraction path for the gcloud SDK.
|
||||
* @returns The path of the installed tool.
|
||||
*/
|
||||
export declare function installGcloudSDK(version: string, gcloudExtPath: string): Promise<string>;
|
||||
22
setupGcloudSDK/dist/test-util.d.ts
vendored
Normal file
22
setupGcloudSDK/dist/test-util.d.ts
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* Creates an overridden runner cache and tool path. This is slightly
|
||||
* complicated by the fact that the runner initializes its cache path exactly
|
||||
* once at startup, so this must be imported and called BEFORE the toolcache is
|
||||
* used.
|
||||
*/
|
||||
export declare class TestToolCache {
|
||||
private static paths;
|
||||
/**
|
||||
* Creates temporary directories for the runner cache and temp, and configures
|
||||
* the Action's runner to use said directories.
|
||||
*
|
||||
* @returns two strings - first is overridden toolsPath, second is tempPath.
|
||||
*/
|
||||
static override(): [string, string];
|
||||
private static randomStr;
|
||||
}
|
||||
/**
|
||||
* The version of the gcloud SDK being tested against.
|
||||
*/
|
||||
export declare const TEST_SDK_VERSIONS: string[];
|
||||
export declare const TEST_SDK_VERSION: string;
|
||||
4
setupGcloudSDK/dist/version-util.d.ts
vendored
Normal file
4
setupGcloudSDK/dist/version-util.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/**
|
||||
* @returns The latest stable version of the gcloud SDK.
|
||||
*/
|
||||
export declare function getLatestGcloudSDKVersion(): Promise<string>;
|
||||
2466
setupGcloudSDK/package-lock.json
generated
Normal file
2466
setupGcloudSDK/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
49
setupGcloudSDK/package.json
Normal file
49
setupGcloudSDK/package.json
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
{
|
||||
"name": "setupGcloudSDK",
|
||||
"version": "1.0.0",
|
||||
"description": "Common tools needed to install and use the Cloud SDK",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "ncc build src/index.ts",
|
||||
"lint": "eslint ./src --ext .ts,.tsx",
|
||||
"format": "prettier --write **/*.ts",
|
||||
"test": "mocha -r ts-node/register -t 30s 'tests/*.test.ts'"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/GoogleCloudPlatform/github-actions.git"
|
||||
},
|
||||
"author": "GoogleCloudPlatform",
|
||||
"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.3",
|
||||
"@actions/exec": "^1.0.3",
|
||||
"@actions/tool-cache": "^1.3.3",
|
||||
"@lifeomic/attempt": "^3.0.0",
|
||||
"@types/tmp": "^0.1.0",
|
||||
"tmp": "^0.1.0",
|
||||
"typed-rest-client": "^1.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chai": "^4.2.x",
|
||||
"@types/mocha": "^7.0.x",
|
||||
"@types/node": "^12.12.27",
|
||||
"@typescript-eslint/eslint-plugin": "^2.20.0",
|
||||
"@typescript-eslint/parser": "^2.20.0",
|
||||
"@zeit/ncc": "^0.20.x",
|
||||
"chai": "^4.2.x",
|
||||
"eslint": "^6.8.x",
|
||||
"eslint-config-prettier": "^6.10.x",
|
||||
"eslint-plugin-prettier": "^3.1.x",
|
||||
"husky": "^4.2.3",
|
||||
"mocha": "^7.0.x",
|
||||
"prettier": "^1.19.1",
|
||||
"ts-node": "^8.6.x",
|
||||
"typescript": "^3.7.x"
|
||||
}
|
||||
}
|
||||
197
setupGcloudSDK/src/index.ts
Normal file
197
setupGcloudSDK/src/index.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
/*
|
||||
* Copyright 2019 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 exec from '@actions/exec';
|
||||
import * as toolCache from '@actions/tool-cache';
|
||||
import * as os from 'os';
|
||||
import * as tmp from 'tmp';
|
||||
import { promises as fs } from 'fs';
|
||||
import { getReleaseURL } from './format-url';
|
||||
import * as downloadUtil from './download-util';
|
||||
import * as installUtil from './install-util';
|
||||
import { getLatestGcloudSDKVersion } from './version-util';
|
||||
|
||||
export { getLatestGcloudSDKVersion };
|
||||
|
||||
/**
|
||||
* Checks if gcloud is installed
|
||||
*
|
||||
* @param version (Optional) Cloud SDK version
|
||||
* @return true if gcloud is found in toolpath
|
||||
*/
|
||||
export function isInstalled(version?: string): boolean {
|
||||
let toolPath;
|
||||
if (version) {
|
||||
toolPath = toolCache.find('gcloud', version);
|
||||
return toolPath != undefined && toolPath !== '';
|
||||
} else {
|
||||
toolPath = toolCache.findAllVersions('gcloud');
|
||||
return toolPath.length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the correct gcloud command for OS
|
||||
*
|
||||
* @returns gcloud command
|
||||
*/
|
||||
export function getToolCommand(): string {
|
||||
// A workaround for https://github.com/actions/toolkit/issues/229
|
||||
// Currently exec on windows runs as cmd shell.
|
||||
let toolCommand = 'gcloud';
|
||||
if (process.platform == 'win32') {
|
||||
toolCommand = 'gcloud.cmd';
|
||||
}
|
||||
return toolCommand;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if gcloud is authenticated
|
||||
*
|
||||
* @returns true is gcloud is authenticated
|
||||
*/
|
||||
export async function isAuthenticated(): Promise<boolean> {
|
||||
let output = '';
|
||||
const stdout = (data: Buffer): void => {
|
||||
output += data.toString();
|
||||
};
|
||||
const options = {
|
||||
listeners: {
|
||||
stdout,
|
||||
},
|
||||
};
|
||||
|
||||
const toolCommand = getToolCommand();
|
||||
|
||||
await exec.exec(toolCommand, ['auth', 'list'], options);
|
||||
return !output.includes('No credentialed accounts.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs the gcloud SDK into the actions environment.
|
||||
*
|
||||
* @param version The version being installed.
|
||||
* @param gcloudExtPath The extraction path for the gcloud SDK.
|
||||
* @returns The path of the installed tool.
|
||||
*/
|
||||
export async function installGcloudSDK(version: string): Promise<string> {
|
||||
// Retreive the release corresponding to the specified version and OS
|
||||
const osPlat = os.platform();
|
||||
const osArch = os.arch();
|
||||
const url = await getReleaseURL(osPlat, osArch, version);
|
||||
|
||||
// Download and extract the release
|
||||
const extPath = await downloadUtil.downloadAndExtractTool(url);
|
||||
if (!extPath) {
|
||||
throw new Error(`Failed to download release, url: ${url}`);
|
||||
}
|
||||
|
||||
// Install the downloaded release into the github action env
|
||||
return await installUtil.installGcloudSDK(version, extPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the service account string into JSON
|
||||
*
|
||||
* @param serviceAccountKey The service account key used for authentication.
|
||||
* @returns ServiceAccountKey object
|
||||
*/
|
||||
export function parseServiceAccountKey(
|
||||
serviceAccountKey: string,
|
||||
): ServiceAccountKey {
|
||||
let serviceAccount = serviceAccountKey;
|
||||
// Handle base64-encoded credentials
|
||||
if (!serviceAccountKey.trim().startsWith('{')) {
|
||||
serviceAccount = Buffer.from(serviceAccountKey, 'base64').toString('utf8');
|
||||
}
|
||||
return JSON.parse(serviceAccount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticates the gcloud tool using a service account key
|
||||
*
|
||||
* @param serviceAccountKey The service account key used for authentication.
|
||||
* @returns exit code
|
||||
*/
|
||||
export async function authenticateGcloudSDK(
|
||||
serviceAccountKey: string,
|
||||
): Promise<number> {
|
||||
tmp.setGracefulCleanup();
|
||||
const serviceAccountJson = parseServiceAccountKey(serviceAccountKey);
|
||||
const serviceAccountEmail = serviceAccountJson.client_email;
|
||||
|
||||
// write the service account key to a temporary file
|
||||
//
|
||||
// TODO: if actions/toolkit#164 is fixed, pass the key in on stdin and avoid
|
||||
// writing a file to disk.
|
||||
const tmpKeyFilePath = await new Promise<string>((resolve, reject) => {
|
||||
tmp.file((err, path) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
}
|
||||
resolve(path);
|
||||
});
|
||||
});
|
||||
await fs.writeFile(tmpKeyFilePath, JSON.stringify(serviceAccountJson));
|
||||
|
||||
const toolCommand = getToolCommand();
|
||||
|
||||
// Authenticate as the specified service account.
|
||||
return await exec.exec(toolCommand, [
|
||||
'--quiet',
|
||||
'auth',
|
||||
'activate-service-account',
|
||||
serviceAccountEmail,
|
||||
'--key-file',
|
||||
tmpKeyFilePath,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the GCP Project Id in the gcloud config
|
||||
*
|
||||
* @param serviceAccountKey The service account key used for authentication.
|
||||
* @returns project ID
|
||||
*/
|
||||
export async function setProject(serviceAccountKey: string): Promise<string> {
|
||||
tmp.setGracefulCleanup();
|
||||
const serviceAccountJson = parseServiceAccountKey(serviceAccountKey);
|
||||
|
||||
const toolCommand = getToolCommand();
|
||||
|
||||
await exec.exec(toolCommand, [
|
||||
'--quiet',
|
||||
'config',
|
||||
'set',
|
||||
'project',
|
||||
serviceAccountJson.project_id,
|
||||
]);
|
||||
|
||||
return serviceAccountJson.project_id;
|
||||
}
|
||||
|
||||
interface ServiceAccountKey {
|
||||
type: string;
|
||||
project_id: string;
|
||||
project_key_id: string;
|
||||
private_key: string;
|
||||
client_email: string;
|
||||
client_id: string;
|
||||
auth_uri: string;
|
||||
token_uri: string;
|
||||
auth_provider_x509_cert_url: string;
|
||||
client_x509_cert_url: string;
|
||||
}
|
||||
32
setupGcloudSDK/tsconfig.json
Normal file
32
setupGcloudSDK/tsconfig.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* Copyright 2019 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,
|
||||
"declaration": true,
|
||||
"composite": true
|
||||
},
|
||||
"exclude": ["node_modules", "**/*.test.ts"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue