├── .commitlintrc.json ├── .eslintignore ├── .eslintrc.json ├── .github ├── dependabot.yml └── workflows │ ├── pr-checks.yml │ └── release.yml ├── .gitignore ├── .husky └── commit-msg ├── .nvmrc ├── .releaserc.json ├── AUTHORS ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── config └── jest.ts ├── package-lock.json ├── package.json ├── src ├── deploy │ ├── deploy.test.ts │ └── deploy.ts ├── entities │ ├── api-gateway.ts │ ├── container-registry.ts │ ├── function.ts │ ├── message-queue.ts │ ├── object-storage.ts │ ├── openapi-spec.test.ts │ ├── openapi-spec.ts │ ├── service-account.ts │ └── trigger.ts ├── extend-config-schema.ts ├── index.ts ├── info │ ├── info.test.ts │ └── info.ts ├── invoke │ ├── invoke.test.ts │ └── invoke.ts ├── lockbox │ └── lockbox.ts ├── logs │ ├── logs.test.ts │ └── logs.ts ├── provider │ ├── helpers.ts │ ├── provider.ts │ └── types.ts ├── remove │ ├── remove.test.ts │ └── remove.ts ├── types │ ├── common.ts │ ├── events.ts │ ├── plugin-manager.ts │ ├── plugin.ts │ ├── serverless.ts │ └── service.ts └── utils │ ├── formatting.ts │ ├── get-env.ts │ ├── logging.ts │ └── yc-config.ts ├── templates └── nodejs │ ├── .nvmrc │ ├── package.json │ ├── serverless.yml │ ├── src │ └── index.ts │ └── tsconfig.json ├── tsconfig.eslint.json └── tsconfig.json /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@commitlint/config-conventional" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | src/lib/** 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "commonjs": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "airbnb-base", 9 | "airbnb-typescript/base", 10 | "plugin:unicorn/recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parser": "@typescript-eslint/parser", 14 | "plugins": [ 15 | "@typescript-eslint", 16 | "unicorn", 17 | "import", 18 | "prefer-arrow-functions" 19 | ], 20 | "parserOptions": { 21 | "project": "tsconfig.eslint.json" 22 | }, 23 | "rules": { 24 | "@typescript-eslint/lines-between-class-members": "off", 25 | "unicorn/no-array-reduce": "off", 26 | "no-continue": "off", 27 | "no-restricted-syntax": "off", 28 | "unicorn/prefer-node-protocol": "off", 29 | "class-methods-use-this": "off", 30 | "@typescript-eslint/no-unused-vars": "off", 31 | "import/prefer-default-export": "off", 32 | "comma-dangle": ["error", "always-multiline"], 33 | "indent": "off", 34 | "@typescript-eslint/indent": ["error", 4], 35 | "max-len": ["error", 140], 36 | "@typescript-eslint/ban-ts-comment": "off", 37 | "@typescript-eslint/prefer-optional-chain": "error", 38 | "prefer-arrow-functions/prefer-arrow-functions": ["error"], 39 | "unicorn/prevent-abbreviations": "off", 40 | "newline-after-var": "error", 41 | "newline-before-return": "error", 42 | "no-plusplus": "off", 43 | "unicorn/import-style": "off", 44 | "@typescript-eslint/no-var-requires": "off", 45 | "no-underscore-dangle": ["error", { 46 | "allowAfterThis": true, 47 | "allowAfterSuper": false 48 | }], 49 | "unicorn/no-null": "off", 50 | "import/no-extraneous-dependencies": ["error", { 51 | "devDependencies": true 52 | }], 53 | "import/no-cycle": "off", 54 | "no-await-in-loop": "off" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | commit-message: 8 | prefix: "fix(deps):" 9 | open-pull-requests-limit: 0 10 | -------------------------------------------------------------------------------- /.github/workflows/pr-checks.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request Automated Checks 2 | on: 3 | pull_request: 4 | branches: 5 | - master 6 | - alpha 7 | - beta 8 | jobs: 9 | tests: 10 | runs-on: ubuntu-20.04 11 | steps: 12 | - uses: yandex-cloud/nodejs-sdk/.github/actions/checkout-and-install-node@f69248b52b7991214847e889f28ba0883ed0ca2c 13 | - run: npm run test 14 | lint: 15 | runs-on: ubuntu-20.04 16 | steps: 17 | - uses: yandex-cloud/nodejs-sdk/.github/actions/checkout-and-install-node@f69248b52b7991214847e889f28ba0883ed0ca2c 18 | - run: npm run lint 19 | build: 20 | runs-on: ubuntu-20.04 21 | steps: 22 | - uses: yandex-cloud/nodejs-sdk/.github/actions/checkout-and-install-node@f69248b52b7991214847e889f28ba0883ed0ca2c 23 | - run: npm run build 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - master 6 | - alpha 7 | - beta 8 | jobs: 9 | release: 10 | if: github.actor != 'yandex-cloud-bot' 11 | name: Release 12 | runs-on: ubuntu-20.04 13 | steps: 14 | - uses: yandex-cloud/nodejs-sdk/.github/actions/checkout-and-install-node@f69248b52b7991214847e889f28ba0883ed0ca2c 15 | with: 16 | persist-credentials: false 17 | - env: 18 | GITHUB_TOKEN: ${{ secrets.YANDEX_CLOUD_BOT_TOKEN }} 19 | GIT_AUTHOR_NAME: yandex-cloud-bot 20 | GIT_AUTHOR_EMAIL: ycloud-bot@yandex.ru 21 | GIT_COMMITTER_NAME: yandex-cloud-bot 22 | GIT_COMMITTER_EMAIL: ycloud-bot@yandex.ru 23 | NPM_TOKEN: ${{ secrets.YANDEX_CLOUD_BOT_NPMJS_TOKEN }} 24 | run: npx --no-install semantic-release 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | dist 4 | templates/nodejs/package-lock.json 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | [ -n "$CI" ] && exit 0 3 | 4 | . "$(dirname "$0")/_/husky.sh" 5 | 6 | npx --no -- commitlint --edit "$1" 7 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20.13.0 2 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | "master", 4 | { 5 | "name": "beta", 6 | "prerelease": "beta" 7 | }, 8 | { 9 | "name": "alpha", 10 | "prerelease": "alpha" 11 | } 12 | ], 13 | "plugins": [ 14 | "@semantic-release/commit-analyzer", 15 | "@semantic-release/release-notes-generator", 16 | "@semantic-release/npm", 17 | "@semantic-release/github", 18 | "@semantic-release/git" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | The following authors have created the source code of "Yandex.Cloud Serverless Plugin" 2 | published and distributed by YANDEX LLC as the owner: 3 | 4 | Sergey Dubovtsev 5 | Gleb Borisov 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Notice to external contributors 2 | 3 | 4 | ## General info 5 | 6 | Hello! In order for us (YANDEX LLC) to accept patches and other contributions from you, you will have to adopt our Yandex Contributor License Agreement (the “**CLA**”). The current version of the CLA can be found here: 7 | 1) https://yandex.ru/legal/cla/?lang=en (in English) and 8 | 2) https://yandex.ru/legal/cla/?lang=ru (in Russian). 9 | 10 | By adopting the CLA, you state the following: 11 | 12 | * You obviously wish and are willingly licensing your contributions to us for our open source projects under the terms of the CLA, 13 | * You have read the terms and conditions of the CLA and agree with them in full, 14 | * You are legally able to provide and license your contributions as stated, 15 | * We may use your contributions for our open source projects and for any other our project too, 16 | * We rely on your assurances concerning the rights of third parties in relation to your contributions. 17 | 18 | If you agree with these principles, please read and adopt our CLA. By providing us your contributions, you hereby declare that you have already read and adopt our CLA, and we may freely merge your contributions with our corresponding open source project and use it in further in accordance with terms and conditions of the CLA. 19 | 20 | ## Provide contributions 21 | 22 | If you have already adopted terms and conditions of the CLA, you are able to provide your contributions. When you submit your pull request, please add the following information into it: 23 | 24 | ``` 25 | I hereby agree to the terms of the CLA available at: [link]. 26 | ``` 27 | 28 | Replace the bracketed text as follows: 29 | * [link] is the link to the current version of the CLA: https://yandex.ru/legal/cla/?lang=en (in English) or https://yandex.ru/legal/cla/?lang=ru (in Russian). 30 | 31 | It is enough to provide us such notification once. 32 | 33 | ## Other questions 34 | 35 | If you have any questions, please mail us at opensource@yandex-team.ru. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 YANDEX LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm](https://img.shields.io/npm/v/@yandex-cloud/serverless-plugin)](https://www.npmjs.com/package/@yandex-cloud/serverless-plugin) 2 | [![License](https://img.shields.io/github/license/yandex-cloud/serverless-plugin.svg)](https://github.com/yandex-cloud/serverless-plugin/blob/master/LICENSE) 3 | 4 | 5 | # Serverless Framework: Yandex Cloud 6 | 7 | This plugin enables support for Yandex Cloud Functions within the [Serverless Framework](https://github.com/serverless/serverless). 8 | 9 | ## Quick Start 10 | 11 | First you need to install the `serverless` command-line tool. Check the official [getting started](https://www.serverless.com/framework/docs/getting-started/) guide. Fastest way to do this is to use `npm`: 12 | 13 | npm install serverless -g 14 | 15 | Now you can create new project from template provided by this plugin: 16 | 17 | serverless create \ 18 | --template-url https://github.com/yandex-cloud/serverless-plugin/tree/master/templates/nodejs 19 | 20 | Before you deploy your first functions using Serverless, you need to configure Yandex.Cloud credentials. There are two ways to do it: 21 | - Install the `yc` command-line tool and use the setup wizard to provide all required parameters. All required guides and in-depth documentation is available at [Yandex.Cloud website](https://cloud.yandex.com/docs/cli/quickstart). 22 | - Provide `YC_OAUTH_TOKEN` (or `YC_IAM_TOKEN`), `YC_CLOUD_ID` and `YC_FOLDER_ID` environment variables 23 | - If you are going to create/edit YMQ or S3 buckets, you need to provide `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` environment variables. [How to create static access keys](https://cloud.yandex.com/en-ru/docs/iam/operations/sa/create-access-key) 24 | 25 | To deploy your project use: 26 | 27 | serverless deploy 28 | 29 | To invoke (test) your function: 30 | 31 | serverless invoke -f simple 32 | 33 | To remove all deployed resources: 34 | 35 | serverless remove 36 | 37 | ## Configuration variables from Lockbox 38 | 39 | This plugin adds [configuration variable source](https://www.serverless.com/framework/docs/providers/aws/guide/variables), which allows to retrieve secrets from [Lockbox](https://cloud.yandex.com/en/docs/lockbox/). 40 | Usage example: 41 | ```yaml 42 | functions: 43 | simple: 44 | handler: dist/index.hello 45 | memorySize: 128 46 | timeout: '5' 47 | account: function-sa 48 | environment: 49 | DB_PASSWORD: ${lockbox:/} 50 | ``` 51 | 52 | ## Environment Variables 53 | #### API Endpoints 54 | - S3_ENDPOINT 55 | - SQS_ENDPOINT 56 | - YC_ENDPOINT 57 | 58 | #### AWS Access Key (for YMQ and Object Storage manipulations) 59 | - AWS_ACCESS_KEY_ID 60 | - AWS_SECRET_ACCESS_KEY 61 | - 62 | #### Cloud API Authentication 63 | - YC_OAUTH_TOKEN_ENV 64 | - YC_IAM_TOKEN_ENV 65 | - YC_CLOUD_ID 66 | - YC_FOLDER_ID 67 | 68 | ## Supported resources 69 | - Cloud Functions 70 | - Triggers 71 | - Service Accounts 72 | - Container Registries 73 | - Message Queues 74 | - S3 Buckets 75 | -------------------------------------------------------------------------------- /config/jest.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | 3 | /* 4 | * For a detailed explanation regarding each configuration property and type check, visit: 5 | * https://jestjs.io/docs/configuration 6 | */ 7 | 8 | export default { 9 | moduleFileExtensions: [ 10 | 'js', 11 | 'ts', 12 | 'json', 13 | ], 14 | preset: 'ts-jest', 15 | rootDir: path.resolve('./src/'), 16 | transform: { 17 | '^.+\\.ts$': ['@swc/jest', { 18 | jsc: { 19 | parser: { 20 | syntax: 'typescript', 21 | tsx: true, 22 | }, 23 | }, 24 | }], 25 | }, 26 | testEnvironment: 'node', 27 | 28 | }; 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@yandex-cloud/serverless-plugin", 3 | "version": "1.7.25", 4 | "description": "Provider plugin for the Serverless Framework which adds support for Yandex Cloud Functions.", 5 | "keywords": [ 6 | "yandex-cloud", 7 | "cloud", 8 | "serverless" 9 | ], 10 | "files": [ 11 | "dist" 12 | ], 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/yandex-cloud/serverless-plugin.git" 16 | }, 17 | "author": "Yandex LLC", 18 | "license": "MIT", 19 | "main": "dist/index.js", 20 | "bugs": { 21 | "url": "https://github.com/yandex-cloud/serverless-plugin/issues" 22 | }, 23 | "homepage": "https://github.com/yandex-cloud/serverless-plugin#readme", 24 | "engines": { 25 | "node": ">=12.0.0" 26 | }, 27 | "dependencies": { 28 | "@serverless/utils": "^6.4.0", 29 | "@yandex-cloud/nodejs-sdk": "^2.3.1", 30 | "aws-sdk": "^2.695.0", 31 | "axios": "^1.6.0", 32 | "bind-decorator": "^1.0.11", 33 | "lodash": "^4.17.21", 34 | "yaml": "^2.2.2" 35 | }, 36 | "devDependencies": { 37 | "@commitlint/cli": "^17.6.6", 38 | "@commitlint/config-conventional": "^16.0.0", 39 | "@semantic-release/git": "^10.0.1", 40 | "@swc/core": "^1.2.237", 41 | "@swc/jest": "^0.2.22", 42 | "@types/jest": "^27.4.0", 43 | "@types/json-schema": "^7.0.9", 44 | "@types/lodash": "^4.14.178", 45 | "@types/serverless": "^3.0.1", 46 | "eslint": "^8.5.0", 47 | "eslint-config-airbnb-base": "^15.0.0", 48 | "eslint-config-airbnb-typescript": "^16.1.0", 49 | "eslint-plugin-import": "^2.25.3", 50 | "eslint-plugin-prefer-arrow-functions": "^3.1.4", 51 | "eslint-plugin-unicorn": "^39.0.0", 52 | "husky": "^7.0.4", 53 | "jest": "^27.5.1", 54 | "openapi-types": "^11.0.0", 55 | "semantic-release": "^23.0.8", 56 | "ts-jest": "^27.1.2", 57 | "typescript": "^4.5.4" 58 | }, 59 | "peerDependencies": { 60 | "serverless": "^3.5.1" 61 | }, 62 | "scripts": { 63 | "build": "tsc -p .", 64 | "test": "jest -c config/jest.ts", 65 | "lint": "eslint src", 66 | "prepare": "husky install", 67 | "prepublishOnly": "npm run build" 68 | }, 69 | "publishConfig": { 70 | "access": "public" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/deploy/deploy.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { YandexCloudDeploy } from './deploy'; 3 | import Serverless from '../types/serverless'; 4 | 5 | describe('Deploy', () => { 6 | let providerMock: any; 7 | let serverlessMock: any; 8 | 9 | const mockOptions: Serverless.Options = { 10 | region: 'ru-central1', 11 | stage: 'prod', 12 | }; 13 | 14 | beforeEach(() => { 15 | providerMock = { 16 | createFunction: jest.fn(), 17 | getFunctions: jest.fn(), 18 | updateFunction: jest.fn(), 19 | removeFunction: jest.fn(), 20 | getTriggers: jest.fn(), 21 | createCronTrigger: jest.fn(), 22 | createS3Trigger: jest.fn(), 23 | createYMQTrigger: jest.fn(), 24 | createCRTrigger: jest.fn(), 25 | removeTrigger: jest.fn(), 26 | getServiceAccounts: jest.fn(), 27 | createServiceAccount: jest.fn(), 28 | removeServiceAccount: jest.fn(), 29 | getMessageQueues: jest.fn(), 30 | createMessageQueue: jest.fn(), 31 | removeMessageQueue: jest.fn(), 32 | getS3Buckets: jest.fn(), 33 | createS3Bucket: jest.fn(), 34 | removeS3Bucket: jest.fn(), 35 | createContainerRegistry: jest.fn(), 36 | removeContainerRegistry: jest.fn(), 37 | getContainerRegistries: jest.fn(), 38 | getApiGateway: jest.fn(), 39 | createApiGateway: jest.fn(), 40 | }; 41 | 42 | providerMock.getFunctions.mockReturnValue([]); 43 | providerMock.getTriggers.mockReturnValue([]); 44 | providerMock.getServiceAccounts.mockReturnValue([]); 45 | providerMock.getMessageQueues.mockReturnValue([]); 46 | providerMock.getS3Buckets.mockReturnValue([]); 47 | providerMock.getContainerRegistries.mockReturnValue([]); 48 | 49 | serverlessMock = { 50 | getProvider: () => providerMock, 51 | cli: { 52 | log: console.log, 53 | }, 54 | }; 55 | jest.spyOn(fs, 'statSync').mockReturnValue({ size: 10_000 } as fs.Stats); 56 | }); 57 | 58 | afterEach(() => { 59 | jest.clearAllMocks(); 60 | }); 61 | 62 | it('Create new functions', async () => { 63 | serverlessMock.service = { 64 | functions: { 65 | func1: { name: 'yc-nodejs-dev-func1', account: 'acc' }, 66 | func2: { name: 'yc-nodejs-dev-func2' }, 67 | func3: { name: 'yc-nodejs-dev-func3', environment: { foo: 'bar' } }, 68 | }, 69 | package: { artifact: 'codePath' }, 70 | provider: { runtime: 'runtime' }, 71 | resources: { 72 | acc: { 73 | type: 'yc::ServiceAccount', 74 | roles: ['editor'], 75 | }, 76 | }, 77 | }; 78 | providerMock.createFunction.mockReturnValue({ id: 'id' }); 79 | providerMock.createServiceAccount.mockReturnValue({ id: 'SA_ID' }); 80 | const deploy = new YandexCloudDeploy(serverlessMock, mockOptions); 81 | 82 | await deploy.deploy(); 83 | expect(providerMock.createFunction).toBeCalledTimes(3); 84 | expect(providerMock.createFunction.mock.calls[0][0].artifact).toEqual({ 85 | code: 'codePath', 86 | }); 87 | expect(providerMock.createFunction.mock.calls[0][0].serviceAccount).toBe('SA_ID'); 88 | expect(providerMock.createFunction.mock.calls[2][0].environment).toEqual({ foo: 'bar' }); 89 | }); 90 | 91 | it('Update function', async () => { 92 | serverlessMock.service = { 93 | functions: { 94 | func1: { name: 'yc-nodejs-dev-func1' }, 95 | func2: { name: 'yc-nodejs-dev-func2' }, 96 | }, 97 | package: { artifact: 'codePath' }, 98 | provider: { runtime: 'runtime' }, 99 | }; 100 | 101 | providerMock.getFunctions.mockReturnValue([{ name: 'yc-nodejs-dev-func1', id: 'id1' }]); 102 | providerMock.createFunction.mockReturnValue({ id: 'id2' }); 103 | const deploy = new YandexCloudDeploy(serverlessMock, mockOptions); 104 | 105 | await deploy.deploy(); 106 | expect(providerMock.createFunction).toBeCalledTimes(1); 107 | expect(providerMock.updateFunction).toBeCalledTimes(1); 108 | expect(providerMock.updateFunction.mock.calls[0][0].id).toBe('id1'); 109 | expect(providerMock.removeFunction).not.toBeCalled(); 110 | }); 111 | 112 | it('Do not remove unnecessary', async () => { 113 | serverlessMock.service = { 114 | functions: { 115 | func1: { name: 'yc-nodejs-dev-func1' }, 116 | func2: { name: 'yc-nodejs-dev-func2' }, 117 | }, 118 | package: { artifact: 'codePath' }, 119 | provider: { runtime: 'runtime' }, 120 | }; 121 | 122 | providerMock.getFunctions.mockReturnValue([ 123 | { name: 'yc-nodejs-dev-func1', id: 'id1' }, 124 | { name: 'yc-nodejs-dev-func2', id: 'id2' }, 125 | { name: 'yc-nodejs-dev-func3', id: 'id3' }, 126 | ]); 127 | providerMock.getS3Buckets.mockReturnValue([{ name: 'bucket', id: 'id' }]); 128 | providerMock.getMessageQueues.mockReturnValue([{ name: 'queue', id: 'id', url: 'url' }]); 129 | providerMock.getServiceAccounts.mockReturnValue([{ name: 'acc', id: 'id' }]); 130 | providerMock.getTriggers.mockReturnValue([{ name: 'trigger', id: 'id' }]); 131 | const deploy = new YandexCloudDeploy(serverlessMock, mockOptions); 132 | 133 | await deploy.deploy(); 134 | expect(providerMock.createFunction).not.toBeCalled(); 135 | expect(providerMock.removeFunction).not.toBeCalled(); 136 | expect(providerMock.removeS3Bucket).not.toBeCalled(); 137 | expect(providerMock.removeTrigger).not.toBeCalled(); 138 | expect(providerMock.removeServiceAccount).not.toBeCalled(); 139 | expect(providerMock.removeMessageQueue).not.toBeCalled(); 140 | expect(providerMock.updateFunction).toBeCalledTimes(2); 141 | }); 142 | 143 | it('deploy single function', async () => { 144 | serverlessMock.service = { 145 | functions: { 146 | func1: { name: 'yc-nodejs-dev-func1' }, 147 | func2: { name: 'yc-nodejs-dev-func2' }, 148 | }, 149 | package: { artifact: 'codePath' }, 150 | provider: { runtime: 'runtime' }, 151 | }; 152 | 153 | providerMock.createFunction.mockReturnValue({ id: 'id1' }); 154 | const deploy = new YandexCloudDeploy(serverlessMock, { ...mockOptions, function: 'func1' }); 155 | 156 | await deploy.deploy(); 157 | expect(providerMock.createFunction).toBeCalledTimes(1); 158 | }); 159 | 160 | it('deploy API Gateway', async () => { 161 | serverlessMock.service = { 162 | functions: { 163 | func1: { name: 'yc-nodejs-dev-func1' }, 164 | func2: { name: 'yc-nodejs-dev-func2' }, 165 | }, 166 | package: { artifact: 'codePath' }, 167 | provider: { 168 | runtime: 'runtime', 169 | httpApi: { payload: '1.0' }, 170 | }, 171 | }; 172 | 173 | providerMock.createFunction.mockReturnValue({ id: 'id1' }); 174 | providerMock.getApiGateway.mockReturnValue({ name: 'apigw' }); 175 | const deploy = new YandexCloudDeploy(serverlessMock, { ...mockOptions, function: 'func1' }); 176 | 177 | await deploy.deploy(); 178 | expect(providerMock.createFunction).toBeCalledTimes(1); 179 | expect(providerMock.createApiGateway).toBeCalledTimes(1); 180 | }); 181 | 182 | it('do not deploy empty API Gateway', async () => { 183 | serverlessMock.service = { 184 | functions: {}, 185 | package: { artifact: 'codePath' }, 186 | provider: { 187 | runtime: 'runtime', 188 | httpApi: { payload: '1.0' }, 189 | }, 190 | }; 191 | 192 | providerMock.getApiGateway.mockReturnValue({ name: 'apigw' }); 193 | const deploy = new YandexCloudDeploy(serverlessMock, { ...mockOptions }); 194 | 195 | await deploy.deploy(); 196 | expect(providerMock.createApiGateway).not.toBeCalled(); 197 | }); 198 | 199 | it('deploy function with timer', async () => { 200 | serverlessMock.service = { 201 | functions: { 202 | func1: { 203 | name: 'yc-nodejs-dev-func1', 204 | events: [ 205 | { 206 | cron: { 207 | expression: '* * * * ? *', 208 | account: 'triggerSA', 209 | }, 210 | }, 211 | ], 212 | }, 213 | }, 214 | package: { artifact: 'codePath' }, 215 | provider: { runtime: 'runtime' }, 216 | resources: { 217 | triggerSA: { 218 | type: 'yc::ServiceAccount', 219 | roles: ['serverless.functions.invoker'], 220 | }, 221 | }, 222 | }; 223 | 224 | providerMock.createFunction.mockReturnValue({ id: 'id1' }); 225 | providerMock.createServiceAccount.mockReturnValue({ id: 'SA_ID' }); 226 | const deploy = new YandexCloudDeploy(serverlessMock, mockOptions); 227 | 228 | await deploy.deploy(); 229 | expect(providerMock.createServiceAccount).toBeCalledTimes(1); 230 | expect(providerMock.createServiceAccount.mock.calls[0][0].name).toBe('triggerSA'); 231 | expect(providerMock.createFunction).toBeCalledTimes(1); 232 | expect(providerMock.createCronTrigger).toBeCalledTimes(1); 233 | expect(providerMock.createCronTrigger.mock.calls[0][0].functionId).toBe('id1'); 234 | expect(providerMock.createCronTrigger.mock.calls[0][0].serviceAccount).toBe('SA_ID'); 235 | }); 236 | 237 | it('deploy existing function with timer', async () => { 238 | serverlessMock.service = { 239 | functions: { 240 | func1: { 241 | name: 'yc-nodejs-dev-func1', 242 | events: [ 243 | { 244 | cron: { 245 | expression: '* * * * ? *', 246 | account: 'triggerSA', 247 | }, 248 | }, 249 | ], 250 | }, 251 | }, 252 | package: { artifact: 'codePath' }, 253 | provider: { runtime: 'runtime' }, 254 | resources: { 255 | triggerSA: { 256 | type: 'yc::ServiceAccount', 257 | roles: ['serverless.functions.invoker'], 258 | }, 259 | }, 260 | }; 261 | 262 | providerMock.getFunctions.mockReturnValue([{ name: 'yc-nodejs-dev-func1', id: 'id1' }]); 263 | providerMock.getTriggers.mockReturnValue([{ name: 'yc-nodejs-dev-func1-cron', id: 'id2' }]); 264 | providerMock.getServiceAccounts.mockReturnValue([ 265 | { 266 | name: 'triggerSA', 267 | id: 'SA_ID', 268 | roles: ['serverless.functions.invoker'], 269 | }, 270 | ]); 271 | const deploy = new YandexCloudDeploy(serverlessMock, mockOptions); 272 | 273 | await deploy.deploy(); 274 | expect(providerMock.updateFunction).toBeCalledTimes(1); 275 | expect(providerMock.removeTrigger).toBeCalledTimes(1); 276 | expect(providerMock.removeTrigger.mock.calls[0][0]).toBe('id2'); 277 | expect(providerMock.removeServiceAccount).not.toBeCalled(); 278 | expect(providerMock.createCronTrigger).toBeCalledTimes(1); 279 | expect(providerMock.createCronTrigger.mock.calls[0][0].functionId).toBe('id1'); 280 | expect(providerMock.createServiceAccount).not.toBeCalled(); 281 | }); 282 | 283 | it('remove cron from function', async () => { 284 | serverlessMock.service = { 285 | functions: { 286 | func1: { 287 | name: 'yc-nodejs-dev-func1', 288 | }, 289 | }, 290 | package: { artifact: 'codePath' }, 291 | provider: { runtime: 'runtime' }, 292 | }; 293 | 294 | providerMock.getFunctions.mockReturnValue([{ name: 'yc-nodejs-dev-func1', id: 'id1' }]); 295 | providerMock.getTriggers.mockReturnValue([{ name: 'yc-nodejs-dev-func1-cron', id: 'id2' }]); 296 | providerMock.createFunction.mockReturnValue({ id: 'id3' }); 297 | const deploy = new YandexCloudDeploy(serverlessMock, mockOptions); 298 | 299 | await deploy.deploy(); 300 | expect(providerMock.updateFunction).toBeCalledTimes(1); 301 | expect(providerMock.removeTrigger).toBeCalledTimes(1); 302 | expect(providerMock.removeTrigger.mock.calls[0][0]).toBe('id2'); 303 | expect(providerMock.createCronTrigger).not.toBeCalled(); 304 | }); 305 | 306 | it('deploy function with s3 event', async () => { 307 | serverlessMock.service = { 308 | functions: { 309 | func1: { 310 | name: 'yc-nodejs-dev-func1', 311 | events: [ 312 | { 313 | s3: { 314 | events: ['create.object'], 315 | bucket: 'bucket', 316 | prefix: 'prefix', 317 | suffix: 'suffix', 318 | account: 'triggerSA', 319 | dlq: 'triggerDlq', 320 | retry: { 321 | attempts: 1, 322 | interval: 10, 323 | }, 324 | }, 325 | }, 326 | ], 327 | }, 328 | }, 329 | package: { artifact: 'codePath' }, 330 | provider: { runtime: 'runtime' }, 331 | resources: { 332 | triggerSA: { 333 | type: 'yc::ServiceAccount', 334 | roles: ['serverless.functions.invoker'], 335 | }, 336 | bucket: { 337 | type: 'yc::ObjectStorageBucket', 338 | }, 339 | triggerDlq: { 340 | type: 'yc::MessageQueue', 341 | }, 342 | }, 343 | }; 344 | 345 | providerMock.createFunction.mockReturnValue({ id: 'id1' }); 346 | providerMock.createServiceAccount.mockReturnValue({ id: 'SA_ID' }); 347 | providerMock.createMessageQueue.mockReturnValue({ id: 'dlq-id' }); 348 | const deploy = new YandexCloudDeploy(serverlessMock, mockOptions); 349 | 350 | await deploy.deploy(); 351 | expect(providerMock.createServiceAccount).toBeCalledTimes(1); 352 | expect(providerMock.createServiceAccount.mock.calls[0][0].name).toBe('triggerSA'); 353 | expect(providerMock.createFunction).toBeCalledTimes(1); 354 | expect(providerMock.createS3Trigger).toBeCalledTimes(1); 355 | expect(providerMock.createS3Trigger.mock.calls[0][0]).toEqual({ 356 | account: 'triggerSA', 357 | bucket: 'bucket', 358 | dlq: 'triggerDlq', 359 | dlqId: 'dlq-id', 360 | events: ['create.object'], 361 | functionId: 'id1', 362 | name: 'yc-nodejs-dev-func1-s3', 363 | prefix: 'prefix', 364 | retry: { 365 | attempts: 1, 366 | interval: 10, 367 | }, 368 | serviceAccount: 'SA_ID', 369 | suffix: 'suffix', 370 | }); 371 | expect(providerMock.createS3Bucket).toBeCalledTimes(1); 372 | expect(providerMock.createS3Bucket.mock.calls[0][0].name).toBe('bucket'); 373 | }); 374 | 375 | it('deploy function with YMQ event', async () => { 376 | serverlessMock.service = { 377 | functions: { 378 | func1: { 379 | name: 'yc-nodejs-dev-func1', 380 | events: [ 381 | { 382 | ymq: { 383 | queue: 'testQueue', 384 | queueAccount: 'triggerSA', 385 | account: 'queueSA', 386 | }, 387 | }, 388 | ], 389 | }, 390 | }, 391 | package: { artifact: 'codePath' }, 392 | provider: { runtime: 'runtime' }, 393 | resources: { 394 | triggerSA: { 395 | type: 'yc::ServiceAccount', 396 | roles: ['serverless.functions.invoker'], 397 | }, 398 | queueSA: { 399 | type: 'yc::ServiceAccount', 400 | roles: ['editor'], 401 | }, 402 | testQueue: { 403 | type: 'yc::MessageQueue', 404 | }, 405 | }, 406 | }; 407 | 408 | providerMock.createFunction.mockReturnValue({ id: 'id1' }); 409 | providerMock.createServiceAccount.mockReturnValue({ id: 'SA_ID' }); 410 | providerMock.createMessageQueue.mockReturnValue({ id: 'queue-id' }); 411 | const deploy = new YandexCloudDeploy(serverlessMock, mockOptions); 412 | 413 | await deploy.deploy(); 414 | expect(providerMock.createServiceAccount).toBeCalledTimes(2); 415 | expect(providerMock.createServiceAccount.mock.calls[0][0].name).toBe('triggerSA'); 416 | expect(providerMock.createServiceAccount.mock.calls[1][0].name).toBe('queueSA'); 417 | expect(providerMock.createFunction).toBeCalledTimes(1); 418 | expect(providerMock.createMessageQueue).toBeCalledTimes(1); 419 | expect(providerMock.createMessageQueue.mock.calls[0][0].name).toBe('testQueue'); 420 | expect(providerMock.createYMQTrigger).toBeCalledTimes(1); 421 | expect(providerMock.createYMQTrigger.mock.calls[0][0].functionId).toBe('id1'); 422 | expect(providerMock.createYMQTrigger.mock.calls[0][0].name).toBe('yc-nodejs-dev-func1-ymq'); 423 | expect(providerMock.createYMQTrigger.mock.calls[0][0].queueId).toBe('queue-id'); 424 | expect(providerMock.createYMQTrigger.mock.calls[0][0].serviceAccount).toBe('SA_ID'); 425 | expect(providerMock.createYMQTrigger.mock.calls[0][0].queueServiceAccount).toBe('SA_ID'); 426 | }); 427 | 428 | it('deploy function with YMQ event and existing queue', async () => { 429 | serverlessMock.service = { 430 | functions: { 431 | func1: { 432 | name: 'yc-nodejs-dev-func1', 433 | events: [ 434 | { 435 | ymq: { 436 | queue: 'testQueue', 437 | queueAccount: 'triggerSA', 438 | account: 'queueSA', 439 | }, 440 | }, 441 | ], 442 | }, 443 | }, 444 | package: { artifact: 'codePath' }, 445 | provider: { runtime: 'runtime' }, 446 | resources: { 447 | triggerSA: { 448 | type: 'yc::ServiceAccount', 449 | roles: ['serverless.functions.invoker'], 450 | }, 451 | queueSA: { 452 | type: 'yc::ServiceAccount', 453 | roles: ['editor'], 454 | }, 455 | testQueue: { 456 | type: 'yc::MessageQueue', 457 | }, 458 | }, 459 | }; 460 | 461 | providerMock.createFunction.mockReturnValue({ id: 'id1' }); 462 | providerMock.createServiceAccount.mockReturnValue({ id: 'SA_ID' }); 463 | providerMock.getMessageQueues.mockReturnValue([ 464 | { 465 | id: 'queue-id', 466 | name: 'testQueue', 467 | url: 'queue-url', 468 | }, 469 | ]); 470 | providerMock.createMessageQueue.mockReturnValue({ id: 'queue-id' }); 471 | const deploy = new YandexCloudDeploy(serverlessMock, mockOptions); 472 | 473 | await deploy.deploy(); 474 | expect(providerMock.createServiceAccount).toBeCalledTimes(2); 475 | expect(providerMock.createServiceAccount.mock.calls[0][0].name).toBe('triggerSA'); 476 | expect(providerMock.createServiceAccount.mock.calls[1][0].name).toBe('queueSA'); 477 | expect(providerMock.createFunction).toBeCalledTimes(1); 478 | expect(providerMock.createMessageQueue).not.toBeCalled(); 479 | expect(providerMock.removeMessageQueue).not.toBeCalled(); 480 | expect(providerMock.createYMQTrigger).toBeCalledTimes(1); 481 | expect(providerMock.createYMQTrigger.mock.calls[0][0].functionId).toBe('id1'); 482 | expect(providerMock.createYMQTrigger.mock.calls[0][0].name).toBe('yc-nodejs-dev-func1-ymq'); 483 | expect(providerMock.createYMQTrigger.mock.calls[0][0].queueId).toBe('queue-id'); 484 | expect(providerMock.createYMQTrigger.mock.calls[0][0].serviceAccount).toBe('SA_ID'); 485 | expect(providerMock.createYMQTrigger.mock.calls[0][0].queueServiceAccount).toBe('SA_ID'); 486 | }); 487 | 488 | it('deploy function with CR event', async () => { 489 | serverlessMock.service = { 490 | functions: { 491 | func1: { 492 | name: 'yc-nodejs-dev-func1', 493 | events: [ 494 | { 495 | cr: { 496 | events: ['create.object'], 497 | registry: 'testCR', 498 | imageName: 'imageName', 499 | tag: 'tag', 500 | account: 'triggerSA', 501 | dlq: 'triggerDlq', 502 | retry: { 503 | attempts: 1, 504 | interval: 10, 505 | }, 506 | }, 507 | }, 508 | ], 509 | }, 510 | }, 511 | package: { artifact: 'codePath' }, 512 | provider: { runtime: 'runtime' }, 513 | resources: { 514 | triggerSA: { 515 | type: 'yc::ServiceAccount', 516 | roles: ['serverless.functions.invoker', 'iam.serviceAccounts.user', 'container-registry.images.puller'], 517 | }, 518 | testCR: { 519 | type: 'yc::ContainerRegistry', 520 | }, 521 | triggerDlq: { 522 | type: 'yc::MessageQueue', 523 | }, 524 | }, 525 | }; 526 | 527 | providerMock.createFunction.mockReturnValue({ id: 'id1' }); 528 | providerMock.createServiceAccount.mockReturnValue({ id: 'SA_ID' }); 529 | providerMock.createMessageQueue.mockReturnValue({ id: 'queue-id' }); 530 | providerMock.createContainerRegistry.mockReturnValue({ id: 'cr-id' }); 531 | const deploy = new YandexCloudDeploy(serverlessMock, mockOptions); 532 | 533 | await deploy.deploy(); 534 | expect(providerMock.createServiceAccount).toBeCalledTimes(1); 535 | expect(providerMock.createServiceAccount.mock.calls[0][0].name).toBe('triggerSA'); 536 | expect(providerMock.createFunction).toBeCalledTimes(1); 537 | expect(providerMock.createMessageQueue).toBeCalledTimes(1); 538 | expect(providerMock.createMessageQueue.mock.calls[0][0].name).toBe('triggerDlq'); 539 | expect(providerMock.createContainerRegistry).toBeCalledTimes(1); 540 | expect(providerMock.createContainerRegistry.mock.calls[0][0].name).toBe('testCR'); 541 | expect(providerMock.createCRTrigger).toBeCalledTimes(1); 542 | expect(providerMock.createCRTrigger.mock.calls[0][0].functionId).toBe('id1'); 543 | expect(providerMock.createCRTrigger.mock.calls[0][0].name).toBe('yc-nodejs-dev-func1-cr'); 544 | expect(providerMock.createCRTrigger.mock.calls[0][0].registryId).toBe('cr-id'); 545 | expect(providerMock.createCRTrigger.mock.calls[0][0].serviceAccount).toBe('SA_ID'); 546 | }); 547 | 548 | it('deploy function with CR event and existing queue and existing registry', async () => { 549 | serverlessMock.service = { 550 | functions: { 551 | func1: { 552 | name: 'yc-nodejs-dev-func1', 553 | events: [ 554 | { 555 | cr: { 556 | events: ['create.object'], 557 | registry: 'testCR', 558 | imageName: 'imageName', 559 | tag: 'tag', 560 | account: 'triggerSA', 561 | dlq: 'triggerDlq', 562 | retry: { 563 | attempts: 1, 564 | interval: 10, 565 | }, 566 | }, 567 | }, 568 | ], 569 | }, 570 | }, 571 | package: { artifact: 'codePath' }, 572 | provider: { runtime: 'runtime' }, 573 | resources: { 574 | triggerSA: { 575 | type: 'yc::ServiceAccount', 576 | roles: ['serverless.functions.invoker', 'iam.serviceAccounts.user', 'container-registry.images.puller'], 577 | }, 578 | testCR: { 579 | type: 'yc::ContainerRegistry', 580 | }, 581 | triggerDlq: { 582 | type: 'yc::MessageQueue', 583 | }, 584 | }, 585 | }; 586 | 587 | providerMock.createFunction.mockReturnValue({ id: 'id1' }); 588 | providerMock.createServiceAccount.mockReturnValue({ id: 'SA_ID' }); 589 | providerMock.getMessageQueues.mockReturnValue([ 590 | { 591 | id: 'trigger-dlq-id', 592 | name: 'triggerDlq', 593 | url: 'trigger-dlq-url', 594 | }, 595 | ]); 596 | providerMock.getContainerRegistries.mockReturnValue([ 597 | { 598 | id: 'cr-id', 599 | name: 'testCR', 600 | }, 601 | ]); 602 | providerMock.createMessageQueue.mockReturnValue({ id: 'queue-id' }); 603 | const deploy = new YandexCloudDeploy(serverlessMock, mockOptions); 604 | 605 | await deploy.deploy(); 606 | expect(providerMock.createServiceAccount).toBeCalledTimes(1); 607 | expect(providerMock.createServiceAccount.mock.calls[0][0].name).toBe('triggerSA'); 608 | expect(providerMock.createFunction).toBeCalledTimes(1); 609 | expect(providerMock.createMessageQueue).not.toBeCalled(); 610 | expect(providerMock.removeMessageQueue).not.toBeCalled(); 611 | expect(providerMock.createContainerRegistry).not.toBeCalled(); 612 | expect(providerMock.removeContainerRegistry).not.toBeCalled(); 613 | expect(providerMock.createCRTrigger).toBeCalledTimes(1); 614 | expect(providerMock.createCRTrigger.mock.calls[0][0].functionId).toBe('id1'); 615 | expect(providerMock.createCRTrigger.mock.calls[0][0].name).toBe('yc-nodejs-dev-func1-cr'); 616 | expect(providerMock.createCRTrigger.mock.calls[0][0].registryId).toBe('cr-id'); 617 | expect(providerMock.createCRTrigger.mock.calls[0][0].serviceAccount).toBe('SA_ID'); 618 | }); 619 | }); 620 | -------------------------------------------------------------------------------- /src/deploy/deploy.ts: -------------------------------------------------------------------------------- 1 | import ServerlessPlugin from 'serverless/classes/Plugin'; 2 | 3 | import { YCFunction } from '../entities/function'; 4 | import { Trigger } from '../entities/trigger'; 5 | import { ServiceAccount } from '../entities/service-account'; 6 | import { MessageQueue } from '../entities/message-queue'; 7 | import { ObjectStorage } from '../entities/object-storage'; 8 | import { ContainerRegistry } from '../entities/container-registry'; 9 | import { YandexCloudProvider } from '../provider/provider'; 10 | import { ServerlessFunc } from '../types/common'; 11 | import { ApiGateway } from '../entities/api-gateway'; 12 | import { 13 | log, 14 | progress, 15 | } from '../utils/logging'; 16 | import Serverless from '../types/serverless'; 17 | 18 | const functionOption = 'function'; 19 | 20 | export class YandexCloudDeploy implements ServerlessPlugin { 21 | hooks: ServerlessPlugin.Hooks; 22 | commands?: ServerlessPlugin.Commands | undefined; 23 | variableResolvers?: ServerlessPlugin.VariableResolvers | undefined; 24 | private readonly serverless: Serverless; 25 | private readonly options: Serverless.Options; 26 | private readonly apiGatewayRegistry: Record; 27 | private readonly functionRegistry: Record; 28 | private readonly triggerRegistry: Record; 29 | private readonly serviceAccountRegistry: Record; 30 | private readonly messageQueueRegistry: Record; 31 | private readonly objectStorageRegistry: Record; 32 | private readonly containerRegistryRegistry: Record; 33 | private provider: YandexCloudProvider; 34 | 35 | constructor(serverless: Serverless, options: Serverless.Options) { 36 | this.serverless = serverless; 37 | this.options = options; 38 | this.provider = this.serverless.getProvider('yandex-cloud') as YandexCloudProvider; 39 | this.apiGatewayRegistry = {}; 40 | this.functionRegistry = {}; 41 | this.triggerRegistry = {}; 42 | this.serviceAccountRegistry = {}; 43 | this.messageQueueRegistry = {}; 44 | this.objectStorageRegistry = {}; 45 | this.containerRegistryRegistry = {}; 46 | 47 | this.hooks = { 48 | 'deploy:deploy': async () => { 49 | try { 50 | await this.deploy(); 51 | 52 | log.info('Service deployed successfully'); 53 | } catch (error: any) { 54 | log.error(error); 55 | } 56 | }, 57 | }; 58 | } 59 | 60 | getFunctionId(name: string) { 61 | return this.functionRegistry[name] ? this.functionRegistry[name].id : undefined; 62 | } 63 | 64 | getServiceAccountId(name: string) { 65 | return this.serviceAccountRegistry[name] ? this.serviceAccountRegistry[name].id : undefined; 66 | } 67 | 68 | getMessageQueueId(name: string) { 69 | return this.messageQueueRegistry[name] ? this.messageQueueRegistry[name].id : undefined; 70 | } 71 | 72 | getContainerRegistryId(name: string) { 73 | return this.containerRegistryRegistry[name] ? this.containerRegistryRegistry[name].id : undefined; 74 | } 75 | 76 | getNeedDeployFunctions() { 77 | const yFunctions = this.serverless.service.functions as unknown as Record; 78 | 79 | return Object.fromEntries( 80 | Object.entries(yFunctions) 81 | .filter(([k, _]) => !this.options[functionOption] || this.options[functionOption] === k), 82 | ); 83 | } 84 | 85 | async deployService(describedFunctions: Record) { 86 | const progressReporter = progress.create({}); 87 | 88 | progressReporter.update('Fetching functions'); 89 | for (const func of await this.provider.getFunctions()) { 90 | this.functionRegistry[func.name] = new YCFunction(this.serverless, this, func); 91 | } 92 | for (const [name, func] of Object.entries(describedFunctions)) { 93 | if (func.name && Object.keys(this.functionRegistry).includes(func.name)) { 94 | this.functionRegistry[func.name].setNewState({ 95 | params: func, 96 | name, 97 | }); 98 | } else if (func.name) { 99 | this.functionRegistry[func.name] = new YCFunction(this.serverless, this); 100 | this.functionRegistry[func.name].setNewState({ 101 | params: func, 102 | name, 103 | }); 104 | } 105 | } 106 | 107 | progressReporter.update('Fetching triggers'); 108 | for (const trigger of await this.provider.getTriggers()) { 109 | const found = Object.values(describedFunctions).find((f) => { 110 | for (const type of Trigger.supportedTriggers()) { 111 | if (trigger.name === `${f.name}-${type}`) { 112 | return true; 113 | } 114 | } 115 | 116 | return false; 117 | }); 118 | 119 | if (found) { 120 | this.triggerRegistry[trigger.name] = new Trigger(this.serverless, this, trigger); 121 | } 122 | } 123 | for (const func of Object.values(describedFunctions)) { 124 | for (const event of Object.values(func.events || [])) { 125 | const normalized = Trigger.normalizeEvent(event); 126 | 127 | if (!normalized) { 128 | continue; 129 | } 130 | 131 | const triggerName = `${func.name}-${normalized.type}`; 132 | 133 | if (triggerName in this.triggerRegistry) { 134 | this.triggerRegistry[triggerName].setNewState({ 135 | function: func, 136 | type: normalized.type, 137 | params: normalized.params, 138 | }); 139 | } else { 140 | this.triggerRegistry[triggerName] = new Trigger(this.serverless, this); 141 | this.triggerRegistry[triggerName].setNewState({ 142 | function: func, 143 | type: normalized.type, 144 | params: normalized.params, 145 | }); 146 | } 147 | } 148 | } 149 | 150 | progressReporter.update('Fetching service accounts'); 151 | for (const sa of await this.provider.getServiceAccounts()) { 152 | this.serviceAccountRegistry[sa.name] = new ServiceAccount(this.serverless, sa); 153 | } 154 | for (const [name, params] of Object.entries(this.serverless.service.resources || [])) { 155 | if (!params.type || params.type !== 'yc::ServiceAccount') { 156 | continue; 157 | } 158 | 159 | if (name in this.serviceAccountRegistry) { 160 | this.serviceAccountRegistry[name].setNewState({ name, ...params }); 161 | } else { 162 | this.serviceAccountRegistry[name] = new ServiceAccount(this.serverless); 163 | this.serviceAccountRegistry[name].setNewState({ name, ...params }); 164 | } 165 | } 166 | 167 | progressReporter.update('Fetching container registries'); 168 | for (const r of await this.provider.getContainerRegistries()) { 169 | this.containerRegistryRegistry[r.name] = new ContainerRegistry(this.serverless, r); 170 | } 171 | for (const [name, params] of Object.entries(this.serverless.service.resources || [])) { 172 | if (!params.type || params.type !== 'yc::ContainerRegistry') { 173 | continue; 174 | } 175 | 176 | if (name in this.containerRegistryRegistry) { 177 | this.containerRegistryRegistry[name].setNewState({ 178 | name, 179 | // params 180 | }); 181 | } else { 182 | this.containerRegistryRegistry[name] = new ContainerRegistry(this.serverless); 183 | this.containerRegistryRegistry[name].setNewState({ 184 | name, 185 | // params 186 | }); 187 | } 188 | } 189 | 190 | try { 191 | const ymqResources = Object.entries(this.serverless.service.resources) 192 | .filter(([name, params]) => params.type === 'yc::MessageQueue'); 193 | const s3Resouces = Object.entries(this.serverless.service.resources) 194 | .filter(([name, params]) => params.type === 'yc::ObjectStorageBucket'); 195 | 196 | if (ymqResources.length > 0) { 197 | progressReporter.update('Fetching queues'); 198 | 199 | for (const queue of await this.provider.getMessageQueues()) { 200 | this.messageQueueRegistry[queue.name] = new MessageQueue(this.serverless, queue); 201 | } 202 | 203 | for (const [name, params] of ymqResources) { 204 | if (!this.messageQueueRegistry[name]) { 205 | this.messageQueueRegistry[name] = new MessageQueue(this.serverless); 206 | } 207 | 208 | this.messageQueueRegistry[name].setNewState({ 209 | name, 210 | fifo: params.fifo, 211 | fifoContentDeduplication: params.fifoContentDeduplication, 212 | }); 213 | } 214 | } 215 | 216 | if (s3Resouces.length > 0) { 217 | progressReporter.update('Fetching buckets'); 218 | for (const bucket of await this.provider.getS3Buckets()) { 219 | this.objectStorageRegistry[bucket.name] = new ObjectStorage(this.serverless, bucket); 220 | } 221 | 222 | for (const [name, params] of s3Resouces) { 223 | if (name in this.objectStorageRegistry) { 224 | this.objectStorageRegistry[name].setNewState({ 225 | name, 226 | // params, 227 | }); 228 | } else { 229 | this.objectStorageRegistry[name] = new ObjectStorage(this.serverless); 230 | this.objectStorageRegistry[name].setNewState({ 231 | name, 232 | // params, 233 | }); 234 | } 235 | } 236 | } 237 | } catch (error) { 238 | log.error(`${error} 239 | Maybe you should set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY environment variables`); 240 | } 241 | 242 | progressReporter.update('Updating entities'); 243 | 244 | const registries = [ 245 | this.serviceAccountRegistry, 246 | this.messageQueueRegistry, 247 | this.objectStorageRegistry, 248 | this.containerRegistryRegistry, 249 | this.functionRegistry, 250 | this.triggerRegistry, 251 | ]; 252 | 253 | for (const registry of registries) { 254 | await Promise.all(Object.values(registry).map((resource) => resource.sync())); 255 | } 256 | 257 | const providerConfig = this.serverless.service.provider; 258 | 259 | if (providerConfig?.httpApi && Object.entries(this.functionRegistry).length > 0) { 260 | const apiGatewayInfo = await this.provider.getApiGateway(); 261 | const apiGateway = new ApiGateway(this.serverless, this, apiGatewayInfo, Object.values(this.functionRegistry)); 262 | 263 | await apiGateway.sync(); 264 | } 265 | progressReporter.remove(); 266 | } 267 | 268 | async deploy() { 269 | return this.deployService(this.getNeedDeployFunctions()); 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /src/entities/api-gateway.ts: -------------------------------------------------------------------------------- 1 | import { YandexCloudDeploy } from '../deploy/deploy'; 2 | import { YandexCloudProvider } from '../provider/provider'; 3 | import { UpdateApiGatewayRequest } from '../provider/types'; 4 | import { ApiGatewayInfo } from '../types/common'; 5 | import Serverless from '../types/serverless'; 6 | import { 7 | log, 8 | progress, 9 | } from '../utils/logging'; 10 | import { YCFunction } from './function'; 11 | import { OpenApiSpec } from './openapi-spec'; 12 | 13 | interface ApiGatewayState { 14 | id?: string; 15 | name: string; 16 | openapiSpec: string; 17 | } 18 | 19 | export class ApiGateway { 20 | public id?: string; 21 | private readonly initialState: ApiGatewayState; 22 | 23 | constructor( 24 | private serverless: Serverless, 25 | private deploy: YandexCloudDeploy, 26 | initial: ApiGatewayInfo, 27 | functions: YCFunction[], 28 | ) { 29 | this.initialState = { 30 | ...initial, 31 | openapiSpec: this.constructOpenApiSpec(functions, initial.name), 32 | }; 33 | this.id = initial?.id; 34 | } 35 | 36 | async sync() { 37 | const provider = this.serverless.getProvider('yandex-cloud') as YandexCloudProvider; 38 | 39 | const progressReporter = progress.create({}); 40 | 41 | if (this.id) { 42 | const requestParams: UpdateApiGatewayRequest = { 43 | ...this.initialState, 44 | id: this.id, 45 | }; 46 | 47 | try { 48 | progressReporter.update('Updating API Gateway'); 49 | await provider.updateApiGateway(requestParams); 50 | progressReporter.remove(); 51 | log.success(`ApiGateway updated\n${requestParams.name}`); 52 | } catch (error) { 53 | log.error(`${error}\nFailed to update API Gateway ${requestParams.name}`); 54 | } 55 | 56 | return; 57 | } 58 | 59 | try { 60 | const requestParams = { 61 | ...this.initialState, 62 | }; 63 | 64 | progressReporter.update('Creating API Gateway'); 65 | const response = await provider.createApiGateway(requestParams); 66 | 67 | progressReporter.remove(); 68 | this.id = response.id; 69 | log.success(`ApiGateway created\n${requestParams.name}`); 70 | } catch (error) { 71 | log.error(`${error}\nFailed to create API Gateway "${this.initialState.name}"`); 72 | } 73 | } 74 | 75 | private constructOpenApiSpec(functions: YCFunction[], name: string): string { 76 | const spec = new OpenApiSpec(this.serverless, this.deploy, name, functions); 77 | 78 | return spec.toString(); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/entities/container-registry.ts: -------------------------------------------------------------------------------- 1 | import { YandexCloudProvider } from '../provider/provider'; 2 | import { log } from '../utils/logging'; 3 | import Serverless from '../types/serverless'; 4 | 5 | interface ContainerRegistryState { 6 | id?: string; 7 | name: string; 8 | } 9 | 10 | export class ContainerRegistry { 11 | public id?: string; 12 | private readonly serverless: Serverless; 13 | private readonly initialState?: ContainerRegistryState; 14 | private newState?: ContainerRegistryState; 15 | 16 | constructor(serverless: Serverless, initial?: ContainerRegistryState) { 17 | this.serverless = serverless; 18 | this.initialState = initial; 19 | this.id = initial?.id; 20 | } 21 | 22 | setNewState(newState: ContainerRegistryState) { 23 | this.newState = newState; 24 | } 25 | 26 | async sync() { 27 | const provider = this.serverless.getProvider('yandex-cloud') as YandexCloudProvider; 28 | 29 | if (!this.newState) { 30 | return; 31 | } 32 | 33 | if (this.initialState) { 34 | return; 35 | } 36 | 37 | const response = await provider.createContainerRegistry({ name: this.newState.name }); 38 | 39 | this.id = response?.id; 40 | log.success(`Container Registry created "${this.newState.name}"`); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/entities/function.ts: -------------------------------------------------------------------------------- 1 | import { AWSError } from 'aws-sdk/lib/error'; 2 | import fs from 'node:fs'; 3 | import path from 'path'; 4 | import { YandexCloudDeploy } from '../deploy/deploy'; 5 | import { YandexCloudProvider } from '../provider/provider'; 6 | import { 7 | CodeOrPackage, 8 | UpdateFunctionRequest, 9 | } from '../provider/types'; 10 | import { ServerlessFunc } from '../types/common'; 11 | import Serverless from '../types/serverless'; 12 | import { humanFileSize } from '../utils/formatting'; 13 | import { 14 | log, 15 | progress, 16 | } from '../utils/logging'; 17 | 18 | export const MAX_PACKAGE_SIZE = 128 * 1024 * 1024; // 128MB 19 | export const MAX_PACKAGE_SIZE_FOR_DIRECT_UPLOAD = 3.5 * 1024 * 1024; // 3.5MB 20 | 21 | interface FunctionState { 22 | id: string; 23 | name: string; 24 | } 25 | 26 | interface FunctionNewState { 27 | params: ServerlessFunc; 28 | name: string; 29 | } 30 | 31 | export class YCFunction { 32 | public id?: string; 33 | private readonly serverless: Serverless; 34 | private readonly deploy: YandexCloudDeploy; 35 | private readonly initialState?: FunctionState; 36 | private newState?: FunctionNewState; 37 | 38 | constructor(serverless: Serverless, deploy: YandexCloudDeploy, initial?: FunctionState) { 39 | this.serverless = serverless; 40 | this.deploy = deploy; 41 | this.initialState = initial; 42 | this.id = initial?.id; 43 | } 44 | 45 | private static validateEnvironment(environment: Record | undefined, provider: YandexCloudProvider) { 46 | let result = true; 47 | 48 | if (!environment) { 49 | return result; 50 | } 51 | for (const [k, v] of Object.entries(environment)) { 52 | if (!/^[A-Za-z]\w*$/.test(k)) { 53 | log.error(`Environment variable "${k}" name does not match with "[a-zA-Z][a-zA-Z0-9_]*"`); 54 | result = false; 55 | } 56 | if (typeof v !== 'string') { 57 | log.error(`Environment variable "${k}" value is not string`); 58 | result = false; 59 | continue; 60 | } 61 | if (v.length > 4096) { 62 | log.error(`Environment variable "${k}" value is too long`); 63 | result = false; 64 | } 65 | } 66 | 67 | return result; 68 | } 69 | 70 | getNewState(): FunctionNewState | undefined { 71 | return this.newState; 72 | } 73 | 74 | setNewState(newState: FunctionNewState) { 75 | this.newState = newState; 76 | } 77 | 78 | async prepareArtifact(): Promise { 79 | const provider = this.serverless.getProvider('yandex-cloud'); 80 | 81 | let artifact: string; 82 | 83 | if (this.serverless.service.package?.individually) { 84 | const fnName = this.newState?.name; 85 | 86 | if (!fnName) { 87 | throw new Error('Function name is not defined'); 88 | } 89 | 90 | const fn = this.serverless.service.getFunction(fnName); 91 | 92 | if (!fn.package?.artifact) { 93 | throw new Error(`Packaging set to 'individually' but function '${fnName}' has no package property`); 94 | } 95 | 96 | artifact = fn.package.artifact; 97 | } else { 98 | artifact = this.serverless.service.package.artifact; 99 | } 100 | 101 | const artifactStat = fs.statSync(artifact); 102 | 103 | if (artifactStat.size >= MAX_PACKAGE_SIZE) { 104 | throw new Error(`Artifact size ${humanFileSize(artifactStat.size)} exceeds Maximum Package Size of 128MB`); 105 | } else if (artifactStat.size >= MAX_PACKAGE_SIZE_FOR_DIRECT_UPLOAD) { 106 | log.warning(`Artifact size ${humanFileSize(artifactStat.size)} exceeds Maximum Package Size for direct upload of 3.5MB.`); 107 | const providerConfig = this.serverless.service.provider; 108 | const bucketName = providerConfig.deploymentBucket ?? provider.getServerlessDeploymentBucketName(); 109 | const prefix = providerConfig.deploymentPrefix ?? 'serverless'; 110 | 111 | try { 112 | await provider.checkS3Bucket(bucketName); 113 | } catch (error) { 114 | const awsErr = error as AWSError; 115 | 116 | if (awsErr.statusCode === 404) { 117 | log.info(`No bucket ${bucketName}.`); 118 | await provider.createS3Bucket({ name: bucketName }); 119 | } 120 | } 121 | const objectName = path.join(prefix, path.basename(artifact)); 122 | 123 | await provider.putS3Object({ 124 | Bucket: bucketName, 125 | Key: objectName, 126 | Body: fs.readFileSync(artifact), 127 | }); 128 | 129 | return { 130 | package: { 131 | bucketName, 132 | objectName, 133 | }, 134 | }; 135 | } else { 136 | return { 137 | code: artifact, 138 | }; 139 | } 140 | } 141 | 142 | async sync() { 143 | const provider = this.serverless.getProvider('yandex-cloud'); 144 | 145 | if (!this.newState) { 146 | log.info(`Unknown function "${this.initialState?.name}" found`); 147 | 148 | return; 149 | } 150 | 151 | if (!YCFunction.validateEnvironment(this.newState.params.environment, provider)) { 152 | throw new Error('Invalid environment'); 153 | } 154 | 155 | if (!this.serverless.service.provider.runtime) { 156 | throw new Error('Provider\'s runtime is not defined'); 157 | } 158 | 159 | const progressReporter = progress.create({}); 160 | 161 | const artifact = await this.prepareArtifact(); 162 | 163 | if (this.initialState) { 164 | const requestParams: UpdateFunctionRequest = { 165 | ...this.newState.params, 166 | runtime: this.serverless.service.provider.runtime, 167 | artifact, 168 | id: this.initialState.id, 169 | serviceAccount: this.deploy.getServiceAccountId(this.newState.params.account), 170 | }; 171 | 172 | await provider.updateFunction(requestParams, progressReporter); 173 | progressReporter.remove(); 174 | 175 | log.success(`Function updated\n${this.newState.name}: ${requestParams.name}`); 176 | 177 | return; 178 | } 179 | 180 | const requestParams = { 181 | ...this.newState.params, 182 | runtime: this.serverless.service.provider.runtime, 183 | artifact, 184 | serviceAccount: this.deploy.getServiceAccountId(this.newState.params.account), 185 | }; 186 | const response = await provider.createFunction(requestParams, progressReporter); 187 | 188 | progressReporter.remove(); 189 | 190 | this.id = response.id; 191 | log.success(`Function created\n${this.newState.name}: ${requestParams.name}`); 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/entities/message-queue.ts: -------------------------------------------------------------------------------- 1 | import { YandexCloudProvider } from '../provider/provider'; 2 | import { MessageQueueInfo } from '../types/common'; 3 | import { log } from '../utils/logging'; 4 | import Serverless from '../types/serverless'; 5 | 6 | interface MessageQueueState { 7 | id?: string; 8 | url?: string; 9 | name: string; 10 | fifo?: boolean; 11 | fifoContentDeduplication?: boolean; 12 | } 13 | 14 | export class MessageQueue { 15 | public id?: string; 16 | public url?: string; 17 | private readonly serverless: Serverless; 18 | private readonly initialState?: MessageQueueInfo; 19 | private newState?: MessageQueueState; 20 | 21 | constructor(serverless: Serverless, initial?: MessageQueueInfo) { 22 | this.serverless = serverless; 23 | this.initialState = initial; 24 | this.id = initial?.id; 25 | this.url = initial?.url; 26 | } 27 | 28 | setNewState(newState: MessageQueueState) { 29 | this.newState = newState; 30 | } 31 | 32 | async sync() { 33 | const provider = this.serverless.getProvider('yandex-cloud') as YandexCloudProvider; 34 | 35 | if (!this.newState) { 36 | return; 37 | } 38 | 39 | if (this.initialState) { 40 | return; 41 | } 42 | 43 | const response = await provider.createMessageQueue({ 44 | name: this.newState.name, 45 | fifo: this.newState.fifo, 46 | fifoContentDeduplication: this.newState.fifoContentDeduplication, 47 | }); 48 | 49 | this.id = response.id; 50 | this.url = response.url; 51 | log.success(`Message queue created\n${this.newState.name}: ${response.url}`); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/entities/object-storage.ts: -------------------------------------------------------------------------------- 1 | import { YandexCloudProvider } from '../provider/provider'; 2 | import { log } from '../utils/logging'; 3 | import Serverless from '../types/serverless'; 4 | 5 | interface ObjectStorageState { 6 | name: string; 7 | } 8 | 9 | export class ObjectStorage { 10 | private readonly serverless: Serverless; 11 | private readonly initialState?: ObjectStorageState; 12 | 13 | private newState?: ObjectStorageState; 14 | 15 | constructor(serverless: Serverless, initial?: ObjectStorageState) { 16 | this.serverless = serverless; 17 | this.initialState = initial; 18 | } 19 | 20 | setNewState(newState: ObjectStorageState) { 21 | this.newState = newState; 22 | } 23 | 24 | async sync() { 25 | const provider = this.serverless.getProvider('yandex-cloud') as YandexCloudProvider; 26 | 27 | if (!this.newState) { 28 | return; 29 | } 30 | 31 | if (this.initialState) { 32 | return; 33 | } 34 | 35 | await provider.createS3Bucket({ name: this.newState.name }); 36 | log.success(`S3 bucket created "${this.newState.name}"`); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/entities/openapi-spec.test.ts: -------------------------------------------------------------------------------- 1 | import { OpenApiSpec } from './openapi-spec'; 2 | import { YCFunction } from './function'; 3 | 4 | describe('OpenAPI Spec', () => { 5 | let providerMock: any; 6 | let serverlessMock: any; 7 | let deployMock: any; 8 | 9 | beforeEach(() => { 10 | providerMock = { 11 | createFunction: jest.fn(), 12 | getFunctions: jest.fn(), 13 | updateFunction: jest.fn(), 14 | removeFunction: jest.fn(), 15 | getTriggers: jest.fn(), 16 | createCronTrigger: jest.fn(), 17 | createS3Trigger: jest.fn(), 18 | createYMQTrigger: jest.fn(), 19 | createCRTrigger: jest.fn(), 20 | removeTrigger: jest.fn(), 21 | getServiceAccounts: jest.fn(), 22 | createServiceAccount: jest.fn(), 23 | removeServiceAccount: jest.fn(), 24 | getMessageQueues: jest.fn(), 25 | createMessageQueue: jest.fn(), 26 | removeMessageQueue: jest.fn(), 27 | getS3Buckets: jest.fn(), 28 | createS3Bucket: jest.fn(), 29 | removeS3Bucket: jest.fn(), 30 | createContainerRegistry: jest.fn(), 31 | removeContainerRegistry: jest.fn(), 32 | getContainerRegistries: jest.fn(), 33 | }; 34 | 35 | providerMock.getFunctions.mockReturnValue([]); 36 | providerMock.getTriggers.mockReturnValue([]); 37 | providerMock.getMessageQueues.mockReturnValue([]); 38 | providerMock.getS3Buckets.mockReturnValue([]); 39 | providerMock.getContainerRegistries.mockReturnValue([]); 40 | providerMock.getServiceAccounts.mockReturnValue([{ 41 | name: 'acc', 42 | id: 'acc_id', 43 | }]); 44 | 45 | serverlessMock = { 46 | getProvider: () => providerMock, 47 | cli: { 48 | log: console.log, 49 | }, 50 | resources: { 51 | acc: { 52 | type: 'yc::ServiceAccount', 53 | roles: ['editor'], 54 | }, 55 | }, 56 | service: { 57 | provider: { 58 | httpApi: { 59 | payload: '1.0', 60 | }, 61 | }, 62 | }, 63 | }; 64 | 65 | deployMock = { 66 | serverless: serverlessMock, 67 | options: {}, 68 | provider: providerMock, 69 | apiGatewayRegistry: {}, 70 | functionRegistry: {}, 71 | triggerRegistry: {}, 72 | serviceAccountRegistry: {}, 73 | messageQueueRegistry: {}, 74 | objectStorageRegistry: {}, 75 | containerRegistryRegistry: {}, 76 | getServiceAccountId: jest.fn(), 77 | getApiGateway: jest.fn(), 78 | }; 79 | deployMock.getServiceAccountId.mockReturnValue('acc_id'); 80 | }); 81 | 82 | afterEach(() => { 83 | jest.clearAllMocks(); 84 | }); 85 | 86 | it('should return of JSON', () => { 87 | const spec = new OpenApiSpec(serverlessMock, deployMock, 'serverless', []); 88 | const expected = { 89 | openapi: '3.0.0', 90 | paths: {}, 91 | info: { 92 | title: 'serverless', 93 | version: '1.0.0', 94 | }, 95 | }; 96 | 97 | expect(spec.toJson()).toEqual(expected); 98 | }); 99 | 100 | it('should add pathes for functions with `http` event', () => { 101 | const func = new YCFunction(serverlessMock, deployMock, { 102 | id: 'func_id', 103 | name: 'func_name', 104 | }); 105 | 106 | func.setNewState({ 107 | name: 'func_name', 108 | params: { 109 | account: 'acc', 110 | handler: 'index.handler', 111 | runtime: '', 112 | timeout: 3, 113 | memorySize: 128, 114 | environment: {}, 115 | events: [ 116 | { 117 | http: { 118 | path: '/any', 119 | method: 'any' as any, 120 | }, 121 | }, 122 | { 123 | http: { 124 | path: '/post', 125 | method: 'post', 126 | }, 127 | }, 128 | ], 129 | tags: {}, 130 | }, 131 | }); 132 | const spec = new OpenApiSpec(serverlessMock, deployMock, 'serverless', [func]); 133 | const expected = { 134 | openapi: '3.0.0', 135 | paths: { 136 | '/any': { 137 | 'x-yc-apigateway-any-method': { 138 | 'x-yc-apigateway-integration': { 139 | context: undefined, 140 | function_id: 'func_id', 141 | payload_format_version: '1.0', 142 | service_account_id: 'acc_id', 143 | tag: '$latest', 144 | type: 'cloud_functions', 145 | }, 146 | responses: { 147 | 200: { 148 | description: 'ok', 149 | }, 150 | }, 151 | }, 152 | }, 153 | '/post': { 154 | post: { 155 | 'x-yc-apigateway-integration': { 156 | context: undefined, 157 | function_id: 'func_id', 158 | payload_format_version: '1.0', 159 | service_account_id: 'acc_id', 160 | tag: '$latest', 161 | type: 'cloud_functions', 162 | }, 163 | responses: { 164 | 200: { 165 | description: 'ok', 166 | }, 167 | }, 168 | }, 169 | }, 170 | }, 171 | info: { 172 | title: 'serverless', 173 | version: '1.0.0', 174 | }, 175 | }; 176 | 177 | expect(spec.toJson()).toEqual(expected); 178 | }); 179 | it('should merge when declared multiple methods for same path', () => { 180 | const func = new YCFunction(serverlessMock, deployMock, { 181 | id: 'func_id', 182 | name: 'func_name', 183 | }); 184 | 185 | func.setNewState({ 186 | name: 'func_name', 187 | params: { 188 | account: 'acc', 189 | handler: 'index.handler', 190 | runtime: '', 191 | timeout: 3, 192 | memorySize: 128, 193 | environment: {}, 194 | events: [ 195 | { 196 | http: { 197 | path: '/foo', 198 | method: 'get' as any, 199 | }, 200 | }, 201 | { 202 | http: { 203 | path: '/foo', 204 | method: 'post', 205 | }, 206 | }, 207 | ], 208 | tags: {}, 209 | }, 210 | }); 211 | const spec = new OpenApiSpec(serverlessMock, deployMock, 'serverless', [func]); 212 | const expected = { 213 | openapi: '3.0.0', 214 | paths: { 215 | '/foo': { 216 | get: { 217 | 'x-yc-apigateway-integration': { 218 | context: undefined, 219 | function_id: 'func_id', 220 | payload_format_version: '1.0', 221 | service_account_id: 'acc_id', 222 | tag: '$latest', 223 | type: 'cloud_functions', 224 | }, 225 | responses: { 226 | 200: { 227 | description: 'ok', 228 | }, 229 | }, 230 | }, 231 | post: { 232 | 'x-yc-apigateway-integration': { 233 | context: undefined, 234 | function_id: 'func_id', 235 | payload_format_version: '1.0', 236 | service_account_id: 'acc_id', 237 | tag: '$latest', 238 | type: 'cloud_functions', 239 | }, 240 | responses: { 241 | 200: { 242 | description: 'ok', 243 | }, 244 | }, 245 | }, 246 | }, 247 | }, 248 | info: { 249 | title: 'serverless', 250 | version: '1.0.0', 251 | }, 252 | }; 253 | 254 | expect(spec.toJson()).toEqual(expected); 255 | }); 256 | 257 | it('should merge when same path declared in different functions', () => { 258 | const func1 = new YCFunction(serverlessMock, deployMock, { 259 | id: 'func_id1', 260 | name: 'func_name', 261 | }); 262 | const func2 = new YCFunction(serverlessMock, deployMock, { 263 | id: 'func_id2', 264 | name: 'func_name', 265 | }); 266 | 267 | func1.setNewState({ 268 | name: 'func_name', 269 | params: { 270 | account: 'acc', 271 | handler: 'index.handler', 272 | runtime: '', 273 | timeout: 3, 274 | memorySize: 128, 275 | environment: {}, 276 | events: [ 277 | { 278 | http: { 279 | path: '/foo', 280 | method: 'get' as any, 281 | }, 282 | }, 283 | ], 284 | tags: {}, 285 | }, 286 | }); 287 | 288 | func2.setNewState({ 289 | name: 'func_name', 290 | params: { 291 | account: 'acc', 292 | handler: 'index.handler', 293 | runtime: '', 294 | timeout: 3, 295 | memorySize: 128, 296 | environment: {}, 297 | events: [ 298 | { 299 | http: { 300 | path: '/foo', 301 | method: 'post' as any, 302 | }, 303 | }, 304 | ], 305 | tags: {}, 306 | }, 307 | }); 308 | const spec = new OpenApiSpec(serverlessMock, deployMock, 'serverless', [func2, func1]); 309 | const expected = { 310 | openapi: '3.0.0', 311 | paths: { 312 | '/foo': { 313 | get: { 314 | 'x-yc-apigateway-integration': { 315 | context: undefined, 316 | function_id: 'func_id1', 317 | payload_format_version: '1.0', 318 | service_account_id: 'acc_id', 319 | tag: '$latest', 320 | type: 'cloud_functions', 321 | }, 322 | responses: { 323 | 200: { 324 | description: 'ok', 325 | }, 326 | }, 327 | }, 328 | post: { 329 | 'x-yc-apigateway-integration': { 330 | context: undefined, 331 | function_id: 'func_id2', 332 | payload_format_version: '1.0', 333 | service_account_id: 'acc_id', 334 | tag: '$latest', 335 | type: 'cloud_functions', 336 | }, 337 | responses: { 338 | 200: { 339 | description: 'ok', 340 | }, 341 | }, 342 | }, 343 | }, 344 | }, 345 | info: { 346 | title: 'serverless', 347 | version: '1.0.0', 348 | }, 349 | }; 350 | 351 | expect(spec.toJson()).toEqual(expected); 352 | }); 353 | 354 | it('should throw an error if genric method collide with specific', () => { 355 | const func1 = new YCFunction(serverlessMock, deployMock, { 356 | id: 'func_id1', 357 | name: 'func_name', 358 | }); 359 | const func2 = new YCFunction(serverlessMock, deployMock, { 360 | id: 'func_id2', 361 | name: 'func_name', 362 | }); 363 | 364 | func1.setNewState({ 365 | name: 'func_name', 366 | params: { 367 | account: 'acc', 368 | handler: 'index.handler', 369 | runtime: '', 370 | timeout: 3, 371 | memorySize: 128, 372 | environment: {}, 373 | events: [ 374 | { 375 | http: { 376 | path: '/foo', 377 | method: 'get' as any, 378 | }, 379 | }, 380 | ], 381 | tags: {}, 382 | }, 383 | }); 384 | 385 | func2.setNewState({ 386 | name: 'func_name', 387 | params: { 388 | account: 'acc', 389 | handler: 'index.handler', 390 | runtime: '', 391 | timeout: 3, 392 | memorySize: 128, 393 | environment: {}, 394 | events: [ 395 | { 396 | http: { 397 | path: '/foo', 398 | method: 'any' as any, 399 | }, 400 | }, 401 | ], 402 | tags: {}, 403 | }, 404 | }); 405 | 406 | expect(() => new OpenApiSpec(serverlessMock, deployMock, 'serverless', [func2, func1])).toThrow(); 407 | }); 408 | }); 409 | -------------------------------------------------------------------------------- /src/entities/openapi-spec.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import { OpenAPIV3 } from 'openapi-types'; 3 | import { YandexCloudDeploy } from '../deploy/deploy'; 4 | import { ProviderConfig } from '../provider/types'; 5 | import { 6 | HttpMethod, 7 | HttpMethodAlias, 8 | HttpMethodAliases, 9 | HttpMethods, 10 | IntegrationType, 11 | PayloadFormatVersion, 12 | RequestParameters, 13 | YcOpenAPI3, 14 | YcPathItemObject, 15 | YcPathsObject, 16 | } from '../types/common'; 17 | import { Event } from '../types/events'; 18 | import Serverless from '../types/serverless'; 19 | import { YCFunction } from './function'; 20 | import OperationObject = OpenAPIV3.OperationObject; 21 | import ParameterObject = OpenAPIV3.ParameterObject; 22 | 23 | const notUndefined = (x: T | undefined): x is T => x !== undefined; 24 | 25 | const mapParamGroupToPlacement = (group: keyof RequestParameters): string => { 26 | switch (group) { 27 | case 'querystrings': 28 | return 'query'; 29 | case 'headers': 30 | return 'header'; 31 | case 'paths': 32 | return 'path'; 33 | default: 34 | throw new Error('unexpected value'); 35 | } 36 | }; 37 | 38 | interface FunctionIntegration { 39 | 'x-yc-apigateway-integration': { 40 | type: IntegrationType.cloud_functions; 41 | function_id: string; 42 | tag: string; 43 | payload_format_version: string; 44 | service_account_id?: string 45 | context?: object 46 | }; 47 | } 48 | 49 | export class OpenApiSpec { 50 | private spec: YcOpenAPI3; 51 | 52 | constructor(private serverless: Serverless, private deploy: YandexCloudDeploy, title: string, functions: YCFunction[]) { 53 | this.spec = { 54 | openapi: '3.0.0', 55 | info: { 56 | title, 57 | version: '1.0.0', 58 | }, 59 | paths: this.addPaths(functions), 60 | }; 61 | } 62 | 63 | toString() { 64 | return JSON.stringify(this.toJson()); 65 | } 66 | 67 | toJson() { 68 | return Object.fromEntries(Object.entries(this.spec) 69 | .filter((field) => field[1] !== undefined)); 70 | } 71 | 72 | mapMethod(method: HttpMethodAlias): HttpMethod { 73 | switch (method) { 74 | case HttpMethodAliases.GET: 75 | return HttpMethods.GET; 76 | case HttpMethodAliases.PUT: 77 | return HttpMethods.PUT; 78 | case HttpMethodAliases.POST: 79 | return HttpMethods.POST; 80 | case HttpMethodAliases.DELETE: 81 | return HttpMethods.DELETE; 82 | case HttpMethodAliases.OPTIONS: 83 | return HttpMethods.OPTIONS; 84 | case HttpMethodAliases.HEAD: 85 | return HttpMethods.HEAD; 86 | case HttpMethodAliases.PATCH: 87 | return HttpMethods.PATCH; 88 | case HttpMethodAliases.TRACE: 89 | return HttpMethods.TRACE; 90 | case HttpMethodAliases.ANY: 91 | return HttpMethods.ANY; 92 | default: 93 | throw new Error('Unknown method'); 94 | } 95 | } 96 | 97 | makeParameter(placement: keyof RequestParameters, name: string, required: boolean): ParameterObject { 98 | return { 99 | in: mapParamGroupToPlacement(placement), 100 | name, 101 | schema: { 102 | type: 'string', 103 | }, 104 | required, 105 | }; 106 | } 107 | 108 | toPathItemObject = (func: YCFunction, event: Event): [string, YcPathItemObject] | undefined => { 109 | if (!event.http || typeof event.http === 'string' || func.id === undefined) { 110 | return undefined; 111 | } 112 | const providerConfig: ProviderConfig | undefined = this.serverless.service?.provider; 113 | 114 | const { http } = event; 115 | const acc = func.getNewState()?.params.account; 116 | const serviceAccountId = acc ? this.deploy.getServiceAccountId(acc) : undefined; 117 | const payloadFormatVersion = http.eventFormat || (providerConfig?.httpApi.payload ?? PayloadFormatVersion.V0); 118 | const operation: OperationObject = { 119 | 'x-yc-apigateway-integration': { 120 | type: IntegrationType.cloud_functions, 121 | function_id: func.id, 122 | tag: '$latest', 123 | payload_format_version: payloadFormatVersion, 124 | service_account_id: serviceAccountId, 125 | context: http.context, 126 | }, 127 | responses: { 128 | 200: { 129 | description: 'ok', 130 | }, 131 | }, 132 | }; 133 | const { parameters } = http.request ?? {}; 134 | 135 | if (parameters) { 136 | const constructParams = (key: keyof RequestParameters) => Object.entries(parameters[key] ?? {}) 137 | .map(([name, required]) => this.makeParameter(key, name, required)); 138 | 139 | operation.parameters = (['paths', 'querystrings', 'headers'] as const).flatMap((x) => constructParams(x)); 140 | } 141 | 142 | return [http.path, 143 | { 144 | [this.mapMethod(http.method)]: operation, 145 | }]; 146 | }; 147 | 148 | toPathTuples(func: YCFunction): [string, YcPathItemObject][] { 149 | const events = func.getNewState()?.params.events ?? []; 150 | 151 | return events 152 | .map((x) => this.toPathItemObject(func, x)) 153 | .filter((x) => notUndefined(x)) as [string, YcPathItemObject][]; 154 | } 155 | 156 | private addPaths(functions: YCFunction[]) { 157 | const paths: { [path: string]: YcPathsObject } = {}; 158 | const results = _.flatMap(functions, (f) => this.toPathTuples(f)); 159 | 160 | for (const [path, pathObj] of results) { 161 | const currentPathObj = paths[path] ?? {}; 162 | const merged = _.merge(pathObj, currentPathObj); 163 | 164 | if (HttpMethods.ANY in merged && Object.keys(merged).length > 1) { 165 | throw new Error('\'x-yc-apigateway-any-method\' declared in the same path with other method'); 166 | } 167 | paths[path] = merged; 168 | } 169 | 170 | return paths; 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/entities/service-account.ts: -------------------------------------------------------------------------------- 1 | import { YandexCloudProvider } from '../provider/provider'; 2 | import { log } from '../utils/logging'; 3 | import Serverless from '../types/serverless'; 4 | 5 | interface ServiceAccountState { 6 | id?: string; 7 | roles: string[]; 8 | name: string; 9 | } 10 | 11 | export class ServiceAccount { 12 | public id?: string; 13 | private readonly serverless: Serverless; 14 | private readonly initialState?: ServiceAccountState; 15 | private newState?: ServiceAccountState; 16 | 17 | constructor(serverless: Serverless, initial?: ServiceAccountState) { 18 | this.serverless = serverless; 19 | this.initialState = initial; 20 | this.id = initial?.id; 21 | } 22 | 23 | setNewState(newState: ServiceAccountState) { 24 | this.newState = newState; 25 | } 26 | 27 | async sync() { 28 | const provider = this.serverless.getProvider('yandex-cloud') as YandexCloudProvider; 29 | 30 | if (!this.newState) { 31 | return; 32 | } 33 | 34 | if (this.initialState) { 35 | if ( 36 | (this.initialState.roles 37 | && this.initialState.roles.length === this.newState?.roles.length 38 | && this.initialState.roles.every((ir) => this.newState?.roles.find((nr) => nr === ir))) 39 | || !this.initialState?.id 40 | ) { 41 | return; 42 | } 43 | 44 | await provider.removeServiceAccount(this.initialState.id); 45 | 46 | log.success(`Service account removed "${this.initialState.name}"`); 47 | } 48 | 49 | const response = await provider.createServiceAccount({ 50 | name: this.newState.name, 51 | roles: this.newState?.roles, 52 | }); 53 | 54 | this.id = response?.id; 55 | log.success(`Service account created\n${this.newState.name}: ${response?.id}`); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/entities/trigger.ts: -------------------------------------------------------------------------------- 1 | import { YandexCloudProvider } from '../provider/provider'; 2 | import { YandexCloudDeploy } from '../deploy/deploy'; 3 | import { TriggerInfo, TriggerType } from '../types/common'; 4 | import { log } from '../utils/logging'; 5 | import Serverless from '../types/serverless'; 6 | import { Event } from '../types/events'; 7 | 8 | interface RetryOptions { 9 | attempts: number; 10 | interval: number; 11 | } 12 | 13 | interface BaseTriggerState { 14 | id?: string; 15 | name?: string; 16 | function: { 17 | name?: string; 18 | }; 19 | } 20 | 21 | interface CronTriggerState extends BaseTriggerState { 22 | type: 'cron'; 23 | params: { 24 | account: string; 25 | expression: string; 26 | retry?: RetryOptions, 27 | dlq?: string; 28 | dlqId?: string; 29 | dlqAccountId?: string; 30 | dlqAccount?: string; 31 | }; 32 | } 33 | 34 | interface S3TriggerState extends BaseTriggerState { 35 | type: 's3'; 36 | params: { 37 | account: string; 38 | events: string[]; 39 | bucket: string; 40 | prefix?: string; 41 | suffix?: string; 42 | retry?: RetryOptions, 43 | dlq?: string; 44 | dlqId?: string; 45 | dlqAccountId?: string; 46 | dlqAccount?: string; 47 | }; 48 | } 49 | 50 | interface YmqTriggerState extends BaseTriggerState { 51 | type: 'ymq'; 52 | params: { 53 | account: string; 54 | queueId: string; 55 | queue: string; 56 | queueAccount: string; 57 | retry: RetryOptions, 58 | batch?: number, 59 | cutoff?: number, 60 | }; 61 | } 62 | 63 | interface YdsTriggerState extends BaseTriggerState { 64 | type: 'yds'; 65 | params: { 66 | stream: string; 67 | database: string; 68 | streamAccount: string; 69 | account: string; 70 | retry: RetryOptions, 71 | batch?: number, 72 | cutoff?: number, 73 | dlq?: string; 74 | dlqId?: string; 75 | dlqAccountId?: string; 76 | dlqAccount?: string; 77 | }; 78 | } 79 | 80 | interface CrTriggerState extends BaseTriggerState { 81 | type: 'cr'; 82 | params: { 83 | events: string[]; 84 | account: string; 85 | registryId?: string; 86 | registry: string; 87 | imageName: string; 88 | tag: string; 89 | dlq?: string; 90 | dlqId?: string; 91 | dlqAccountId?: string; 92 | dlqAccount?: string; 93 | retry: RetryOptions, 94 | }; 95 | } 96 | 97 | type TriggerState = CrTriggerState | YmqTriggerState | YdsTriggerState | S3TriggerState | CronTriggerState; 98 | 99 | export class Trigger { 100 | public id?: string; 101 | private readonly provider: YandexCloudProvider; 102 | private readonly serverless: Serverless; 103 | private readonly initialState?: TriggerInfo; 104 | private readonly deploy: YandexCloudDeploy; 105 | private newState?: TriggerState; 106 | 107 | constructor(serverless: Serverless, deploy: YandexCloudDeploy, initial?: TriggerInfo) { 108 | this.provider = serverless.getProvider('yandex-cloud') as YandexCloudProvider; 109 | this.serverless = serverless; 110 | this.initialState = initial; 111 | this.deploy = deploy; 112 | } 113 | 114 | static supportedTriggers(): TriggerType[] { 115 | return [TriggerType.CRON, TriggerType.S3, TriggerType.YMQ, TriggerType.CR, TriggerType.YDS]; 116 | } 117 | 118 | static normalizeEvent(event: Event) { 119 | // @ts-ignore 120 | const foundTriggerType = Trigger.supportedTriggers().find((type) => event[type]); 121 | 122 | // @ts-ignore 123 | return foundTriggerType && { type: foundTriggerType, params: event[foundTriggerType] }; 124 | } 125 | 126 | setNewState(newState: TriggerState) { 127 | this.newState = newState; 128 | } 129 | 130 | async sync() { 131 | if (!this.newState) { 132 | if (!this.initialState?.id) { 133 | log.info('Trigger id is not defined'); 134 | 135 | return; 136 | } 137 | 138 | await this.provider.removeTrigger(this.initialState.id); 139 | log.success(`Trigger removed "${this.initialState.name}"`); 140 | 141 | return; 142 | } 143 | 144 | if (this.initialState) { 145 | if (!this.initialState?.id) { 146 | log.error('Trigger id is not defined'); 147 | 148 | return; 149 | } 150 | 151 | await this.provider.removeTrigger(this.initialState.id); 152 | log.success(`Trigger removed "${this.initialState.name}"`); 153 | } 154 | 155 | const triggerName = `${this.newState.function.name}-${this.newState.type}`; 156 | 157 | if (!this.newState.function.name) { 158 | throw new Error('Function name is not defined'); 159 | } 160 | 161 | const response = await this.creators()[this.newState.type]({ 162 | name: triggerName, 163 | streamServiceAccount: this.streamServiceAccount(), 164 | queueServiceAccount: this.queueServiceAccount(), 165 | queueId: this.queueId(), 166 | functionId: this.deploy.getFunctionId(this.newState.function.name), 167 | serviceAccount: this.deploy.getServiceAccountId(this.newState.params.account), 168 | dlqId: this.dlqId(), 169 | dlqAccountId: this.dlqServiceAccount(), 170 | registryId: this.crId(), 171 | ...this.newState.params, 172 | }); 173 | 174 | this.id = response?.id; 175 | log.success(`Trigger created "${triggerName}"`); 176 | } 177 | 178 | streamServiceAccount() { 179 | return this.newState?.type === 'yds' ? this.deploy.getServiceAccountId(this.newState.params.streamAccount) : undefined; 180 | } 181 | 182 | queueServiceAccount() { 183 | return this.newState?.type === 'ymq' ? this.deploy.getServiceAccountId(this.newState.params.queueAccount) : undefined; 184 | } 185 | 186 | queueId() { 187 | let qId: string | undefined; 188 | 189 | if (this.newState?.type === 'ymq') { 190 | qId = this.newState?.params.queueId 191 | ? this.newState.params.queueId 192 | : this.deploy.getMessageQueueId(this.newState.params.queue); 193 | } 194 | 195 | return qId; 196 | } 197 | 198 | dlqId() { 199 | let dlqId: string | undefined; 200 | 201 | if (this.newState?.type !== 'ymq') { 202 | dlqId = this.newState?.params.dlqId || (this.newState?.params.dlq && this.deploy.getMessageQueueId(this.newState.params.dlq)); 203 | } 204 | 205 | return dlqId; 206 | } 207 | 208 | dlqServiceAccount() { 209 | let dlqSaId: string | undefined; 210 | 211 | if (this.newState?.type !== 'ymq') { 212 | dlqSaId = this.newState?.params.dlqAccountId 213 | || (this.newState?.params.dlqAccount && this.deploy.getServiceAccountId(this.newState?.params.dlqAccount)); 214 | } 215 | 216 | return dlqSaId; 217 | } 218 | 219 | crId() { 220 | let crId: string | undefined; 221 | 222 | if (this.newState?.type === 'cr') { 223 | crId = this.newState.params.registryId 224 | ? this.newState.params.registryId 225 | : this.deploy.getContainerRegistryId(this.newState.params.registry); 226 | } 227 | 228 | return crId; 229 | } 230 | 231 | private creators() { 232 | return { 233 | cron: this.provider.createCronTrigger, 234 | s3: this.provider.createS3Trigger, 235 | ymq: this.provider.createYMQTrigger, 236 | cr: this.provider.createCRTrigger, 237 | yds: this.provider.createYDSTrigger, 238 | }; 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /src/extend-config-schema.ts: -------------------------------------------------------------------------------- 1 | import type { JSONSchema7, JSONSchema7Definition } from 'json-schema'; 2 | 3 | import { YandexCloudProvider } from './provider/provider'; 4 | import { EventType, TriggerType } from './types/common'; 5 | import Serverless from './types/serverless'; 6 | 7 | const requestParametersSchema: JSONSchema7 = { 8 | type: 'object', 9 | additionalProperties: { 10 | anyOf: [ 11 | { type: 'boolean' }, 12 | ], 13 | }, 14 | }; 15 | 16 | const requestSchema: JSONSchema7 = { 17 | type: 'object', 18 | properties: { 19 | parameters: { 20 | type: 'object', 21 | properties: { 22 | querystrings: requestParametersSchema, 23 | headers: requestParametersSchema, 24 | paths: requestParametersSchema, 25 | }, 26 | additionalProperties: false, 27 | }, 28 | // schemas: { 29 | // type: 'object', 30 | // additionalProperties: { anyOf: [{ type: 'object' }, { type: 'string' }] }, 31 | // }, 32 | }, 33 | additionalProperties: false, 34 | }; 35 | 36 | const responseSchema: JSONSchema7 = { 37 | type: 'object', 38 | properties: { 39 | headers: { 40 | type: 'object', 41 | additionalProperties: { type: 'string' }, 42 | }, 43 | template: { type: 'string' }, 44 | statusCodes: { 45 | type: 'object', 46 | propertyNames: { 47 | type: 'string', 48 | pattern: '^\\d{3}$', 49 | }, 50 | additionalProperties: { 51 | type: 'object', 52 | properties: { 53 | headers: { 54 | type: 'object', 55 | additionalProperties: { type: 'string' }, 56 | }, 57 | pattern: { type: 'string' }, 58 | template: { 59 | anyOf: [ 60 | { type: 'string' }, 61 | { 62 | type: 'object', 63 | additionalProperties: { type: 'string' }, 64 | }, 65 | ], 66 | }, 67 | }, 68 | additionalProperties: false, 69 | }, 70 | }, 71 | }, 72 | additionalProperties: false, 73 | }; 74 | 75 | const schemaHttpTrigger: JSONSchema7 = { 76 | type: 'object', 77 | properties: { 78 | path: { type: 'string' }, 79 | method: { 80 | enum: [ 81 | 'get', 82 | 'put', 83 | 'post', 84 | 'delete', 85 | 'options', 86 | 'head', 87 | 'patch', 88 | 'trace', 89 | 'any', 90 | ], 91 | }, 92 | authorizer: { type: 'string' }, 93 | eventFormat: { 94 | enum: ['1.0', '0.1'], 95 | }, 96 | context: { 97 | type: 'object', 98 | }, 99 | request: requestSchema, 100 | // response: responseSchema, 101 | }, 102 | required: ['path', 'method'], 103 | }; 104 | 105 | const schemaCronTrigger: JSONSchema7 = { 106 | type: 'object', 107 | properties: { 108 | expression: { type: 'string' }, 109 | account: { type: 'string' }, 110 | retry: { 111 | type: 'object', 112 | properties: { 113 | attempts: { type: 'number' }, 114 | interval: { type: 'number' }, 115 | }, 116 | }, 117 | dlq: { type: 'string' }, 118 | dlqId: { type: 'string' }, 119 | dlqAccountId: { type: 'string' }, 120 | dlqAccount: { type: 'string' }, 121 | }, 122 | required: ['expression', 'account'], 123 | }; 124 | 125 | const schemaS3Trigger: JSONSchema7 = { 126 | type: 'object', 127 | properties: { 128 | bucket: { type: 'string' }, 129 | account: { type: 'string' }, 130 | events: { 131 | type: 'array', 132 | items: { 133 | type: 'string', 134 | }, 135 | }, 136 | prefix: { type: 'string' }, 137 | suffix: { type: 'string' }, 138 | retry: { 139 | type: 'object', 140 | properties: { 141 | attempts: { type: 'number' }, 142 | interval: { type: 'number' }, 143 | }, 144 | }, 145 | dlq: { type: 'string' }, 146 | dlqId: { type: 'string' }, 147 | dlqAccountId: { type: 'string' }, 148 | dlqAccount: { type: 'string' }, 149 | }, 150 | required: ['bucket', 'account', 'events'], 151 | }; 152 | 153 | const schemaYMQTrigger: JSONSchema7 = { 154 | type: 'object', 155 | properties: { 156 | queue: { type: 'string' }, 157 | queueId: { type: 'string' }, 158 | queueAccount: { type: 'string' }, 159 | account: { type: 'string' }, 160 | retry: { 161 | type: 'object', 162 | properties: { 163 | attempts: { type: 'number' }, 164 | interval: { type: 'number' }, 165 | }, 166 | }, 167 | batch: { type: 'number' }, 168 | cutoff: { type: 'number' }, 169 | dlq: { type: 'string' }, 170 | dlqId: { type: 'string' }, 171 | dlqAccountId: { type: 'string' }, 172 | dlqAccount: { type: 'string' }, 173 | }, 174 | required: ['queue', 'account', 'queueAccount', 'batch', 'cutoff'], 175 | }; 176 | 177 | const schemaYDSTrigger: JSONSchema7 = { 178 | type: 'object', 179 | properties: { 180 | stream: { type: 'string' }, 181 | database: { type: 'string' }, 182 | streamAccount: { type: 'string' }, 183 | account: { type: 'string' }, 184 | retry: { 185 | type: 'object', 186 | properties: { 187 | attempts: { type: 'number' }, 188 | interval: { type: 'number' }, 189 | }, 190 | }, 191 | batch: { type: 'number' }, 192 | cutoff: { type: 'number' }, 193 | dlq: { type: 'string' }, 194 | dlqId: { type: 'string' }, 195 | dlqAccountId: { type: 'string' }, 196 | dlqAccount: { type: 'string' }, 197 | }, 198 | required: ['stream', 'database', 'account', 'streamAccount'], 199 | }; 200 | 201 | const schemaCRTrigger: JSONSchema7 = { 202 | type: 'object', 203 | properties: { 204 | registry: { type: 'string' }, 205 | registryId: { type: 'string' }, 206 | imageName: { type: 'string' }, 207 | tag: { type: 'string' }, 208 | events: { 209 | type: 'array', 210 | items: { 211 | type: 'string', 212 | }, 213 | }, 214 | account: { type: 'string' }, 215 | retry: { 216 | type: 'object', 217 | properties: { 218 | attempts: { type: 'number' }, 219 | interval: { type: 'number' }, 220 | }, 221 | }, 222 | dlq: { type: 'string' }, 223 | dlqId: { type: 'string' }, 224 | dlqAccountId: { type: 'string' }, 225 | dlqAccount: { type: 'string' }, 226 | }, 227 | required: ['events', 'account', 'imageName', 'tag', 'registry'], 228 | }; 229 | 230 | const schemaResources: JSONSchema7 = { 231 | type: 'object', 232 | patternProperties: { 233 | '^.*$': { 234 | oneOf: [ 235 | { 236 | type: 'object', 237 | properties: { 238 | type: { 239 | enum: ['yc::ServiceAccount'], 240 | }, 241 | roles: { 242 | type: 'array', 243 | items: { 244 | type: 'string', 245 | }, 246 | }, 247 | }, 248 | }, 249 | { 250 | type: 'object', 251 | properties: { 252 | type: { 253 | enum: ['yc::MessageQueue'], 254 | }, 255 | name: { type: 'string' }, 256 | fifo: { type: 'boolean' }, 257 | fifoContentDeduplication: { type: 'boolean' }, 258 | }, 259 | }, 260 | { 261 | type: 'object', 262 | properties: { 263 | type: { 264 | enum: ['yc::ObjectStorageBucket'], 265 | }, 266 | name: { type: 'string' }, 267 | }, 268 | }, 269 | { 270 | type: 'object', 271 | properties: { 272 | type: { 273 | enum: ['yc::ContainerRegistry'], 274 | }, 275 | name: { type: 'string' }, 276 | }, 277 | }, 278 | ], 279 | }, 280 | }, 281 | }; 282 | 283 | export const extendConfigSchema = (sls: Serverless) => { 284 | sls.configSchemaHandler.defineProvider(YandexCloudProvider.getProviderName(), { 285 | definitions: { 286 | cloudFunctionRegion: { 287 | enum: [ 288 | 'ru-central1', 289 | ], 290 | }, 291 | cloudFunctionRuntime: { 292 | // Source: https://cloud.google.com/functions/docs/concepts/exec#runtimes 293 | enum: [ 294 | 'nodejs10', 295 | 'nodejs12', 296 | 'nodejs14', 297 | 'nodejs16', 298 | 'nodejs18', 299 | 'python37', 300 | 'python38', 301 | 'python39', 302 | 'python311', 303 | 'python312', 304 | 'golang116', 305 | 'golang117', 306 | 'golang118', 307 | 'golang119', 308 | 'golang121', 309 | 'java11', 310 | 'java17', 311 | 'java21', 312 | 'dotnet31', 313 | 'dotnet6', 314 | 'dotnet8', 315 | 'bash', 316 | 'bash-2204', 317 | 'php74', 318 | 'php8', 319 | 'php82', 320 | 'r42', 321 | 'r43', 322 | ], 323 | }, 324 | cloudFunctionMemory: { 325 | type: 'number', 326 | }, 327 | cloudFunctionEnvironmentVariables: { 328 | type: 'object', 329 | patternProperties: { 330 | '^.*$': { type: 'string' }, 331 | }, 332 | additionalProperties: false, 333 | }, 334 | resourceManagerLabels: { 335 | type: 'object', 336 | propertyNames: { 337 | type: 'string', 338 | minLength: 1, 339 | maxLength: 63, 340 | }, 341 | patternProperties: { 342 | '^[a-z][a-z0-9_.]*$': { type: 'string' }, 343 | }, 344 | additionalProperties: false, 345 | }, 346 | apiKeyFunctionAuthorizer: { 347 | type: 'object', 348 | properties: { 349 | type: { enum: ['apiKey'] }, 350 | in: { enum: ['header', 'query', 'cookie'] }, 351 | name: { type: 'string' }, 352 | function: { type: 'string' }, 353 | }, 354 | required: ['type', 'in', 'name', 'function'], 355 | }, 356 | httpFunctionAuthorizer: { 357 | type: 'object', 358 | properties: { 359 | type: { enum: ['http'] }, 360 | scheme: { enum: ['bearer'] }, 361 | function: { type: 'string' }, 362 | bearerFormat: { type: 'string' }, 363 | }, 364 | required: ['type', 'scheme', 'function'], 365 | }, 366 | functionAuthorizer: { 367 | type: 'object', 368 | oneOf: [ 369 | { 370 | $ref: '#/definitions/apiKeyFunctionAuthorizer', 371 | }, 372 | { 373 | $ref: '#/definitions/httpFunctionAuthorizer', 374 | }, 375 | ], 376 | }, 377 | authorizers: { 378 | type: 'object', 379 | patternProperties: { 380 | '^[a-z][a-z0-9_.]*$': { $ref: '#/definitions/functionAuthorizer' }, 381 | }, 382 | }, 383 | apiGatewayConfig: { 384 | type: 'object', 385 | properties: { 386 | payload: { 387 | enum: [ 388 | '0.1', 389 | '1.0', 390 | ], 391 | }, 392 | authorizers: { $ref: '#/definitions/authorizers' }, 393 | }, 394 | }, 395 | }, 396 | provider: { 397 | properties: { 398 | credentials: { type: 'string' }, 399 | project: { type: 'string' }, 400 | region: { $ref: '#/definitions/cloudFunctionRegion' }, 401 | httpApi: { $ref: '#/definitions/apiGatewayConfig' }, 402 | runtime: { $ref: '#/definitions/cloudFunctionRuntime' }, // Can be overridden by function configuration 403 | memorySize: { $ref: '#/definitions/cloudFunctionMemory' }, // Can be overridden by function configuration 404 | timeout: { type: 'string' }, // Can be overridden by function configuration 405 | environment: { $ref: '#/definitions/cloudFunctionEnvironmentVariables' }, // Can be overridden by function configuration 406 | vpc: { type: 'string' }, // Can be overridden by function configuration 407 | labels: { $ref: '#/definitions/resourceManagerLabels' }, // Can be overridden by function configuration 408 | }, 409 | }, 410 | function: { 411 | properties: { 412 | handler: { type: 'string' }, 413 | runtime: { $ref: '#/definitions/cloudFunctionRuntime' }, // Override provider configuration 414 | memorySize: { $ref: '#/definitions/cloudFunctionMemory' }, // Override provider configuration 415 | timeout: { type: 'string' }, // Override provider configuration 416 | environment: { $ref: '#/definitions/cloudFunctionEnvironmentVariables' }, // Override provider configuration 417 | vpc: { type: 'string' }, // Override provider configuration 418 | labels: { $ref: '#/definitions/resourceManagerLabels' }, // Override provider configuration 419 | account: { type: 'string' }, 420 | package: { 421 | type: 'object', 422 | properties: { 423 | artifact: { type: 'string' }, 424 | exclude: { type: 'array', items: { type: 'string' } }, 425 | include: { type: 'array', items: { type: 'string' } }, 426 | individually: { type: 'boolean' }, 427 | patterns: { type: 'array', items: { type: 'string' } }, 428 | }, 429 | additionalProperties: false, 430 | }, 431 | }, 432 | }, 433 | }); 434 | 435 | sls.configSchemaHandler.defineTopLevelProperty( 436 | 'resources', 437 | schemaResources as Record, 438 | ); 439 | 440 | sls.configSchemaHandler.defineFunctionEvent( 441 | YandexCloudProvider.getProviderName(), 442 | EventType.HTTP, 443 | schemaHttpTrigger as Record, 444 | ); 445 | 446 | sls.configSchemaHandler.defineFunctionEvent( 447 | YandexCloudProvider.getProviderName(), 448 | TriggerType.CRON, 449 | schemaCronTrigger as Record, 450 | ); 451 | 452 | sls.configSchemaHandler.defineFunctionEvent( 453 | YandexCloudProvider.getProviderName(), 454 | TriggerType.S3, 455 | schemaS3Trigger as Record, 456 | ); 457 | 458 | sls.configSchemaHandler.defineFunctionEvent( 459 | YandexCloudProvider.getProviderName(), 460 | TriggerType.YMQ, 461 | schemaYMQTrigger as Record, 462 | ); 463 | 464 | sls.configSchemaHandler.defineFunctionEvent( 465 | YandexCloudProvider.getProviderName(), 466 | TriggerType.YDS, 467 | schemaYDSTrigger as Record, 468 | ); 469 | 470 | sls.configSchemaHandler.defineFunctionEvent( 471 | YandexCloudProvider.getProviderName(), 472 | TriggerType.CR, 473 | schemaCRTrigger as Record, 474 | ); 475 | }; 476 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-import-module-exports */ 2 | 3 | import ServerlessPlugin from 'serverless/classes/Plugin'; 4 | import { YandexCloudProvider } from './provider/provider'; 5 | import { YandexCloudDeploy } from './deploy/deploy'; 6 | import { YandexCloudRemove } from './remove/remove'; 7 | import { YandexCloudInvoke } from './invoke/invoke'; 8 | import { YandexCloudInfo } from './info/info'; 9 | import { YandexCloudLogs } from './logs/logs'; 10 | import { extendConfigSchema } from './extend-config-schema'; 11 | import Serverless from './types/serverless'; 12 | import { YandexCloudLockbox } from './lockbox/lockbox'; 13 | 14 | class YandexCloudServerlessPlugin implements ServerlessPlugin { 15 | hooks: ServerlessPlugin.Hooks = {}; 16 | private readonly serverless: Serverless; 17 | private readonly options: Serverless.Options; 18 | 19 | constructor(serverless: Serverless, options: Serverless.Options) { 20 | this.serverless = serverless; 21 | this.options = options; 22 | 23 | this.serverless.pluginManager.addPlugin(YandexCloudProvider); 24 | this.serverless.pluginManager.addPlugin(YandexCloudDeploy); 25 | this.serverless.pluginManager.addPlugin(YandexCloudRemove); 26 | this.serverless.pluginManager.addPlugin(YandexCloudInvoke); 27 | this.serverless.pluginManager.addPlugin(YandexCloudInfo); 28 | this.serverless.pluginManager.addPlugin(YandexCloudLogs); 29 | this.serverless.pluginManager.addPlugin(YandexCloudLockbox); 30 | 31 | extendConfigSchema(this.serverless); 32 | } 33 | } 34 | 35 | // eslint-disable-next-line unicorn/prefer-module 36 | module.exports = YandexCloudServerlessPlugin; 37 | -------------------------------------------------------------------------------- /src/info/info.test.ts: -------------------------------------------------------------------------------- 1 | import { YandexCloudInfo } from './info'; 2 | import { log } from '../utils/logging'; 3 | import Serverless from '../types/serverless'; 4 | 5 | jest.mock('../utils/logging', () => ({ 6 | log: { 7 | warning: jest.fn(), 8 | notice: jest.fn(), 9 | }, 10 | })); 11 | 12 | describe('Info', () => { 13 | let providerMock: any; 14 | let serverlessMock: any; 15 | 16 | const mockOptions: Serverless.Options = { 17 | region: 'ru-central1', 18 | stage: 'prod', 19 | }; 20 | 21 | beforeEach(() => { 22 | providerMock = { 23 | getFunctions: jest.fn(), 24 | getTriggers: jest.fn(), 25 | getServiceAccounts: jest.fn(), 26 | getS3Buckets: jest.fn(), 27 | getMessageQueues: jest.fn(), 28 | getApiGateway: jest.fn(), 29 | }; 30 | 31 | serverlessMock = { 32 | getProvider: () => providerMock, 33 | }; 34 | jest.clearAllMocks(); 35 | }); 36 | 37 | afterEach(() => { 38 | providerMock = null; 39 | serverlessMock = null; 40 | }); 41 | 42 | test('get functions info', async () => { 43 | serverlessMock.service = { 44 | functions: { 45 | func1: { name: 'yc-nodejs-dev-func1' }, 46 | func2: { name: 'yc-nodejs-dev-func2' }, 47 | }, 48 | package: { artifact: 'codePath' }, 49 | provider: { runtime: 'runtime' }, 50 | }; 51 | 52 | providerMock.getFunctions.mockReturnValue([{ name: 'yc-nodejs-dev-func1', id: 'id1' }]); 53 | providerMock.getApiGateway.mockReturnValue({ name: 'apigw' }); 54 | const info = new YandexCloudInfo(serverlessMock, mockOptions); 55 | 56 | await info.info(); 57 | expect(jest.mocked(log).notice.mock.calls[0][0]).toBe('Function "func1" deployed with id "id1"'); 58 | expect(jest.mocked(log).warning.mock.calls[0][0]).toBe('Function "func2" not deployed'); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /src/info/info.ts: -------------------------------------------------------------------------------- 1 | import ServerlessPlugin from 'serverless/classes/Plugin'; 2 | 3 | import { Trigger } from '../entities/trigger'; 4 | import { YandexCloudProvider } from '../provider/provider'; 5 | import { 6 | FunctionInfo, 7 | MessageQueueInfo, 8 | S3BucketInfo, 9 | ServiceAccountInfo, 10 | TriggerInfo, 11 | } from '../types/common'; 12 | import { log } from '../utils/logging'; 13 | import Serverless, { FunctionDefinition } from '../types/serverless'; 14 | 15 | export class YandexCloudInfo implements ServerlessPlugin { 16 | hooks: ServerlessPlugin.Hooks; 17 | private readonly serverless: Serverless; 18 | private readonly options: Serverless.Options; 19 | private provider: YandexCloudProvider; 20 | private existingQueues: MessageQueueInfo[] | undefined = undefined; 21 | private existingBuckets: S3BucketInfo[] | undefined = undefined; 22 | 23 | constructor(serverless: Serverless, options: Serverless.Options) { 24 | this.serverless = serverless; 25 | this.options = options; 26 | this.provider = this.serverless.getProvider('yandex-cloud'); 27 | 28 | this.hooks = { 29 | 'info:info': async () => { 30 | try { 31 | await this.info(); 32 | } catch (error) { 33 | log.error(`Failed to get state. ${error}`); 34 | } 35 | }, 36 | }; 37 | } 38 | 39 | serviceAccountInfo(name: string, params: unknown, currentAccounts: ServiceAccountInfo[]) { 40 | const acc = currentAccounts.find((item) => item.name === name); 41 | 42 | if (acc) { 43 | log.notice(`Service account "${name}" created with id "${acc.id}"`); 44 | } else { 45 | log.warning(`Service account "${name}" not created`); 46 | } 47 | } 48 | 49 | messageQueueInfo(name: string, params: unknown, currentQueues: MessageQueueInfo[]) { 50 | const queue = currentQueues.find((item) => item.name === name); 51 | 52 | if (queue) { 53 | log.notice(`Message queue "${name}" created with id "${queue.id}"`); 54 | } else { 55 | log.warning(`Message queue "${name}" not created`); 56 | } 57 | } 58 | 59 | objectStorageInfo(name: string, params: unknown, currentBuckets: S3BucketInfo[]) { 60 | const bucket = currentBuckets.find((item) => item.name === name); 61 | 62 | if (bucket) { 63 | log.notice(`Object storage bucket "${name}" created`); 64 | } else { 65 | log.warning(`Object storage bucket "${name}" not created`); 66 | } 67 | } 68 | 69 | triggersInfo(func: string, params: FunctionDefinition, currentTriggers: TriggerInfo[]) { 70 | for (const event of Object.values(params.events || [])) { 71 | const normalized = Trigger.normalizeEvent(event); 72 | 73 | if (!normalized) { 74 | continue; 75 | } 76 | 77 | const triggerName = `${params.name}-${normalized.type}`; 78 | const trigger = currentTriggers.find((item) => item.name === triggerName); 79 | 80 | if (trigger) { 81 | log.notice(`Trigger "${triggerName}" for function "${func}" deployed with id "${trigger.id}"`); 82 | } else { 83 | log.warning(`Trigger "${triggerName}" for function "${func}" not deployed`); 84 | } 85 | } 86 | } 87 | 88 | functionInfo(name: string, params: FunctionDefinition, currentFunctions: FunctionInfo[], currentTriggers: TriggerInfo[]) { 89 | const func = currentFunctions.find((currFunc) => currFunc.name === params.name); 90 | 91 | if (func) { 92 | log.notice(`Function "${name}" deployed with id "${func.id}"`); 93 | this.triggersInfo(name, params, currentTriggers); 94 | } else { 95 | log.warning(`Function "${name}" not deployed`); 96 | } 97 | } 98 | 99 | async apiGatewayInfo() { 100 | const existingApiGateway = await this.provider.getApiGateway(); 101 | 102 | if (existingApiGateway?.id) { 103 | log.notice( 104 | `API Gateway "${existingApiGateway.name}" deployed with url "https://${existingApiGateway.domain}/"`, 105 | ); 106 | } else { 107 | log.warning(`API Gateway "${existingApiGateway.name}" not deployed`); 108 | } 109 | } 110 | 111 | async getMessageQueuesCached() { 112 | if (!this.existingQueues) { 113 | this.existingQueues = await this.provider.getMessageQueues(); 114 | } 115 | 116 | return this.existingQueues; 117 | } 118 | 119 | async getS3BucketsCached() { 120 | if (!this.existingBuckets) { 121 | this.existingBuckets = await this.provider.getS3Buckets(); 122 | } 123 | 124 | return this.existingBuckets; 125 | } 126 | 127 | async info() { 128 | const currentFunctions = await this.provider.getFunctions(); 129 | const currentTriggers = await this.provider.getTriggers(); 130 | const currentServiceAccounts = await this.provider.getServiceAccounts(); 131 | 132 | for (const [key, value] of Object.entries(this.serverless.service.functions || [])) { 133 | this.functionInfo(key, value, currentFunctions, currentTriggers); 134 | } 135 | 136 | for (const [key, value] of Object.entries(this.serverless.service.resources || [])) { 137 | try { 138 | // eslint-disable-next-line default-case 139 | switch (value.type) { 140 | case 'yc::MessageQueue': 141 | this.messageQueueInfo(key, value, await this.getMessageQueuesCached()); 142 | break; 143 | case 'yc::ObjectStorageBucket': 144 | this.objectStorageInfo(key, value, await this.getS3BucketsCached()); 145 | break; 146 | } 147 | } catch (error) { 148 | log.error(`Failed to get state for "${key}" 149 | ${error} 150 | Maybe you should set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY environment variables`); 151 | } 152 | } 153 | 154 | for (const [key, value] of Object.entries(this.serverless.service.resources || [])) { 155 | if (value.type === 'yc::ServiceAccount') { 156 | this.serviceAccountInfo(key, value, currentServiceAccounts); 157 | } 158 | } 159 | 160 | await this.apiGatewayInfo(); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/invoke/invoke.test.ts: -------------------------------------------------------------------------------- 1 | import { YandexCloudInvoke } from './invoke'; 2 | import Serverless from '../types/serverless'; 3 | 4 | describe('Invoke', () => { 5 | let providerMock: any; 6 | let serverlessMock: any; 7 | 8 | const mockOptions: Serverless.Options = { 9 | region: 'ru-central1', 10 | stage: 'prod', 11 | }; 12 | 13 | beforeEach(() => { 14 | providerMock = { 15 | getFunctions: jest.fn(), 16 | invokeFunction: jest.fn(), 17 | }; 18 | 19 | serverlessMock = { 20 | getProvider: () => providerMock, 21 | }; 22 | serverlessMock.cli = { log: jest.fn() }; 23 | }); 24 | 25 | afterEach(() => { 26 | providerMock = null; 27 | serverlessMock = null; 28 | }); 29 | 30 | test('invoke function', async () => { 31 | serverlessMock.service = { 32 | functions: { 33 | func1: { name: 'yc-nodejs-dev-func1' }, 34 | func2: { name: 'yc-nodejs-dev-func2' }, 35 | }, 36 | package: { artifact: 'codePath' }, 37 | provider: { runtime: 'runtime' }, 38 | }; 39 | 40 | providerMock.getFunctions.mockReturnValue([{ name: 'yc-nodejs-dev-func1', id: 'id1' }]); 41 | const invoke = new YandexCloudInvoke(serverlessMock, { ...mockOptions, function: 'func1' }); 42 | 43 | await invoke.invoke(); 44 | expect(providerMock.invokeFunction).toBeCalledTimes(1); 45 | expect(providerMock.invokeFunction.mock.calls[0][0]).toBe('id1'); 46 | }); 47 | 48 | test('invoke unknown function', async () => { 49 | serverlessMock.service = { 50 | functions: { 51 | func2: { name: 'yc-nodejs-dev-func2' }, 52 | }, 53 | package: { artifact: 'codePath' }, 54 | provider: { runtime: 'runtime' }, 55 | }; 56 | 57 | providerMock.getFunctions.mockReturnValue([{ name: 'yc-nodejs-dev-func1', id: 'id1' }]); 58 | const invoke = new YandexCloudInvoke(serverlessMock, { ...mockOptions, function: 'func1' }); 59 | 60 | await invoke.invoke(); 61 | expect(providerMock.invokeFunction).not.toBeCalled(); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/invoke/invoke.ts: -------------------------------------------------------------------------------- 1 | import ServerlessPlugin from 'serverless/classes/Plugin'; 2 | 3 | import { YandexCloudProvider } from '../provider/provider'; 4 | import { log, writeText } from '../utils/logging'; 5 | import Serverless from '../types/serverless'; 6 | 7 | export class YandexCloudInvoke implements ServerlessPlugin { 8 | hooks: ServerlessPlugin.Hooks; 9 | private readonly serverless: Serverless; 10 | private readonly options: Serverless.Options; 11 | private readonly provider: YandexCloudProvider; 12 | 13 | constructor(serverless: Serverless, options: Serverless.Options) { 14 | this.serverless = serverless; 15 | this.options = options; 16 | this.provider = this.serverless.getProvider('yandex-cloud') as YandexCloudProvider; 17 | 18 | this.hooks = { 19 | 'invoke:invoke': async () => { 20 | await this.invoke(); 21 | }, 22 | }; 23 | } 24 | 25 | async invoke() { 26 | const currentFunctions = await this.provider.getFunctions(); 27 | const describedFunctions = this.serverless.service.functions; 28 | const toInvoke = currentFunctions.filter((currFunc) => Object.keys(describedFunctions) 29 | .find((funcKey) => describedFunctions[funcKey].name === currFunc.name && funcKey === this.options.function)); 30 | 31 | if (toInvoke.length !== 1) { 32 | log.notice(`Function "${this.options.function}" not found`); 33 | 34 | return; 35 | } 36 | 37 | const result = await this.provider.invokeFunction(toInvoke[0].id); 38 | 39 | writeText(JSON.stringify(result)); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/lockbox/lockbox.ts: -------------------------------------------------------------------------------- 1 | import ServerlessPlugin from 'serverless/classes/Plugin'; 2 | import { bind } from 'bind-decorator'; 3 | import Serverless from '../types/serverless'; 4 | import { log } from '../utils/logging'; 5 | import { YandexCloudProvider } from '../provider/provider'; 6 | 7 | interface ConfigVariableResolveRequest { 8 | address: string; 9 | } 10 | 11 | export class YandexCloudLockbox implements ServerlessPlugin { 12 | hooks: ServerlessPlugin.Hooks = {}; 13 | 14 | private readonly serverless: Serverless; 15 | private readonly options: Serverless.Options; 16 | private provider: YandexCloudProvider; 17 | configurationVariablesSources?: ServerlessPlugin.ConfigurationVariablesSources; 18 | 19 | constructor(serverless: Serverless, options: Serverless.Options) { 20 | this.serverless = serverless; 21 | this.options = options; 22 | this.provider = this.serverless.getProvider('yandex-cloud'); 23 | 24 | this.configurationVariablesSources = { 25 | lockbox: { 26 | resolve: this.resolveLockboxVariable, 27 | }, 28 | }; 29 | } 30 | 31 | @bind 32 | private async resolveLockboxVariable(request: ConfigVariableResolveRequest) { 33 | const addressParts = request.address.split('/'); 34 | 35 | if (addressParts.length !== 2) { 36 | // eslint-disable-next-line no-template-curly-in-string 37 | throw new Error('Invalid variable declaration. Use following format: ${lockbox:/}'); 38 | } 39 | 40 | const secretId = addressParts[0]; 41 | const secretKey = addressParts[1]; 42 | 43 | try { 44 | const secretPayload = await this.provider.getLockboxSecretKey(secretId); 45 | const secretValue = secretPayload[secretKey]; 46 | 47 | if (!secretValue) { 48 | throw new Error(`Secret doesn't contain key ${secretKey} or it has empty text value`); 49 | } 50 | 51 | return { value: secretValue }; 52 | } catch (error: unknown) { 53 | log.error(`Unable to get content of secret key from lockbox: ${error}`); 54 | 55 | throw error; 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/logs/logs.test.ts: -------------------------------------------------------------------------------- 1 | import { YandexCloudLogs } from './logs'; 2 | import Serverless from '../types/serverless'; 3 | 4 | describe('Logs', () => { 5 | let providerMock: any; 6 | let serverlessMock: any; 7 | 8 | const mockOptions: Serverless.Options = { 9 | region: 'ru-central1', 10 | stage: 'prod', 11 | }; 12 | 13 | beforeEach(() => { 14 | providerMock = { 15 | getFunctions: jest.fn(), 16 | getFunctionLogs: jest.fn(), 17 | }; 18 | 19 | serverlessMock = { 20 | getProvider: () => providerMock, 21 | }; 22 | }); 23 | 24 | afterEach(() => { 25 | providerMock = null; 26 | serverlessMock = null; 27 | }); 28 | 29 | test('function logs', async () => { 30 | serverlessMock.service = { 31 | functions: { 32 | func1: { name: 'yc-nodejs-dev-func1' }, 33 | func2: { name: 'yc-nodejs-dev-func2' }, 34 | }, 35 | package: { artifact: 'codePath' }, 36 | provider: { runtime: 'runtime' }, 37 | }; 38 | serverlessMock.cli = { log: jest.fn() }; 39 | 40 | providerMock.getFunctions.mockReturnValue([{ name: 'yc-nodejs-dev-func1', id: 'id1' }]); 41 | const logs = new YandexCloudLogs(serverlessMock, { ...mockOptions, function: 'func1' }); 42 | 43 | await logs.logs(); 44 | expect(providerMock.getFunctionLogs).toBeCalledTimes(1); 45 | expect(providerMock.getFunctionLogs.mock.calls[0][0]).toBe('id1'); 46 | }); 47 | 48 | test('logs for unknown function', async () => { 49 | serverlessMock.service = { 50 | functions: { 51 | func2: { name: 'yc-nodejs-dev-func2' }, 52 | }, 53 | package: { artifact: 'codePath' }, 54 | provider: { runtime: 'runtime' }, 55 | }; 56 | 57 | providerMock.getFunctions.mockReturnValue([{ name: 'yc-nodejs-dev-func1', id: 'id1' }]); 58 | const logs = new YandexCloudLogs(serverlessMock, { ...mockOptions, function: 'func1' }); 59 | 60 | await logs.logs(); 61 | expect(providerMock.getFunctionLogs).not.toBeCalled(); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/logs/logs.ts: -------------------------------------------------------------------------------- 1 | import ServerlessPlugin from 'serverless/classes/Plugin'; 2 | import { YandexCloudProvider } from '../provider/provider'; 3 | import { writeText } from '../utils/logging'; 4 | import Serverless from '../types/serverless'; 5 | 6 | export class YandexCloudLogs implements ServerlessPlugin { 7 | hooks: ServerlessPlugin.Hooks; 8 | private readonly serverless: Serverless; 9 | private readonly options: Serverless.Options; 10 | private readonly provider: YandexCloudProvider; 11 | 12 | constructor(serverless: Serverless, options: Serverless.Options) { 13 | this.serverless = serverless; 14 | this.options = options; 15 | this.provider = this.serverless.getProvider('yandex-cloud') as YandexCloudProvider; 16 | 17 | this.hooks = { 18 | 'logs:logs': async () => { 19 | await this.logs(); 20 | }, 21 | }; 22 | } 23 | 24 | async logs() { 25 | const currentFunctions = await this.provider.getFunctions(); 26 | const describedFunctions = this.serverless.service.functions; 27 | const toLog = currentFunctions.filter((currFunc) => Object.keys(describedFunctions) 28 | .find((funcKey) => describedFunctions[funcKey].name === currFunc.name && funcKey === this.options.function)); 29 | 30 | if (toLog.length !== 1) { 31 | return; 32 | } 33 | 34 | const result = await this.provider.getFunctionLogs(toLog[0].id); 35 | 36 | writeText(JSON.stringify(result)); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/provider/helpers.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | export const fileToBase64 = (filePath: string) => fs.readFileSync(filePath, 'base64'); 4 | -------------------------------------------------------------------------------- /src/provider/provider.ts: -------------------------------------------------------------------------------- 1 | import { 2 | cloudApi, 3 | decodeMessage, 4 | serviceClients, 5 | Session, 6 | waitForOperation, 7 | WrappedServiceClientType, 8 | } from '@yandex-cloud/nodejs-sdk'; 9 | import { Any } from '@yandex-cloud/nodejs-sdk/dist/generated/google/protobuf/any'; 10 | import { Status } from '@yandex-cloud/nodejs-sdk/dist/generated/google/rpc/status'; 11 | import { Operation } from '@yandex-cloud/nodejs-sdk/dist/generated/yandex/cloud/operation/operation'; 12 | import { GetOpenapiSpecResponse } from '@yandex-cloud/nodejs-sdk/dist/generated/yandex/cloud/serverless/apigateway/v1/apigateway_service'; 13 | import { Package } from '@yandex-cloud/nodejs-sdk/dist/generated/yandex/cloud/serverless/functions/v1/function'; 14 | import AWS, { S3 } from 'aws-sdk'; 15 | import axios from 'axios'; 16 | import bind from 'bind-decorator'; 17 | import * as lodash from 'lodash'; 18 | import _ from 'lodash'; 19 | import type ServerlessAwsProviderType from 'serverless/aws'; 20 | import ServerlessPlugin from 'serverless/classes/Plugin'; 21 | 22 | // @ts-ignore TODO: fix @types/serverless and remove this ignore 23 | import ServerlessAwsProvider from 'serverless/lib/plugins/aws/provider'; 24 | import yaml from 'yaml'; 25 | 26 | import { 27 | ApiGatewayInfo, 28 | FunctionInfo, 29 | MessageQueueInfo, 30 | S3BucketInfo, 31 | ServiceAccountInfo, 32 | TriggerInfo, 33 | } from '../types/common'; 34 | import { S3Event } from '../types/events'; 35 | import Serverless from '../types/serverless'; 36 | import { getEnv, getEnvStrict } from '../utils/get-env'; 37 | import { 38 | log, 39 | ProgressReporter, 40 | } from '../utils/logging'; 41 | import { getYcConfig } from '../utils/yc-config'; 42 | import { fileToBase64 } from './helpers'; 43 | import { 44 | CreateApiGatewayRequest, 45 | CreateContainerRegistryRequest, 46 | CreateCronTriggerRequest, 47 | CreateCrTriggerRequest, 48 | CreateFunctionRequest, 49 | CreateMessageQueueRequest, 50 | CreateS3BucketRequest, 51 | CreateS3TriggerRequest, 52 | CreateServiceAccountRequest, CreateYdsTriggerRequest, 53 | CreateYmqTriggerRequest, 54 | InvokeFunctionRequest, 55 | UpdateApiGatewayRequest, 56 | UpdateFunctionRequest, 57 | } from './types'; 58 | 59 | const PROVIDER_NAME = 'yandex-cloud'; 60 | const S3_DEFAULT_ENDPOINT = 'https://storage.yandexcloud.net'; 61 | const SQS_DEFAULT_ENDPOINT = 'https://message-queue.api.cloud.yandex.net'; 62 | 63 | const { 64 | containerregistry: { registry_service: CloudApiRegistryService }, 65 | lockbox: { payload_service: CloudApiLockboxPayloadService }, 66 | serverless: { 67 | apigateway_service: CloudApiApiGatewayService, 68 | functions_function_service: CloudApiFunctionsService, 69 | triggers_trigger_service: CloudApiTriggersService, 70 | triggers_trigger: CloudApiTriggers, 71 | }, 72 | iam: { service_account_service: CloudApiServiceAccountService }, 73 | access: { access: CloudApiAccess }, 74 | } = cloudApi; 75 | 76 | const AwsProvider = ServerlessAwsProvider as typeof ServerlessAwsProviderType; 77 | 78 | type SuccessfulOperation = Operation & { response: Any }; 79 | type FailedOperation = Operation & { response: undefined; error: Status }; 80 | 81 | export class YandexCloudProvider implements ServerlessPlugin { 82 | hooks: ServerlessPlugin.Hooks; 83 | commands?: ServerlessPlugin.Commands | undefined; 84 | variableResolvers?: ServerlessPlugin.VariableResolvers | undefined; 85 | private readonly serverless: Serverless; 86 | private readonly options: Serverless.Options; 87 | private session: Session; 88 | private folderId: string; 89 | private cloudId: string; 90 | private triggers: WrappedServiceClientType; 91 | private serviceAccounts: WrappedServiceClientType; 92 | private apiGateways: WrappedServiceClientType; 93 | private functions: WrappedServiceClientType; 94 | private folders: WrappedServiceClientType; 95 | private containerRegistryService: WrappedServiceClientType; 96 | private lockboxPayloadService: WrappedServiceClientType; 97 | private ymq: AWS.SQS; 98 | private s3: AWS.S3; 99 | 100 | constructor(serverless: Serverless, options: Serverless.Options) { 101 | this.serverless = serverless; 102 | this.options = options; 103 | this.serverless.setProvider(PROVIDER_NAME, this); 104 | this.hooks = {}; 105 | 106 | // Init YC API client 107 | const config = getYcConfig(); 108 | const sessionConfig = 'token' in config ? { 109 | oauthToken: config.token, 110 | } : { 111 | iamToken: config.iamToken, 112 | }; 113 | const endpoint = config.endpoint || undefined; 114 | 115 | this.session = new Session(sessionConfig); 116 | this.folderId = config.folderId; 117 | this.cloudId = config.cloudId; 118 | 119 | this.apiGateways = this.session.client(serviceClients.ApiGatewayServiceClient, endpoint); 120 | this.triggers = this.session.client(serviceClients.TriggerServiceClient, endpoint); 121 | this.serviceAccounts = this.session.client(serviceClients.ServiceAccountServiceClient, endpoint); 122 | this.functions = this.session.client(serviceClients.FunctionServiceClient, endpoint); 123 | this.folders = this.session.client(serviceClients.FolderServiceClient, endpoint); 124 | this.containerRegistryService = this.session.client(serviceClients.RegistryServiceClient, endpoint); 125 | this.lockboxPayloadService = this.session.client(serviceClients.PayloadServiceClient, endpoint); 126 | 127 | // Init AWS SDK 128 | const awsConfig = { 129 | region: 'ru-central1', 130 | accessKeyId: getEnv('AWS_ACCESS_KEY_ID'), 131 | secretAccessKey: getEnv('AWS_SECRET_ACCESS_KEY'), 132 | }; 133 | 134 | this.ymq = new AWS.SQS({ 135 | endpoint: getEnvStrict('SQS_ENDPOINT', SQS_DEFAULT_ENDPOINT), 136 | ...awsConfig, 137 | }); 138 | this.s3 = new AWS.S3({ 139 | endpoint: getEnvStrict('S3_ENDPOINT', S3_DEFAULT_ENDPOINT), 140 | ...awsConfig, 141 | }); 142 | } 143 | 144 | static getProviderName() { 145 | return PROVIDER_NAME; 146 | } 147 | 148 | getValues(source: object, objectPaths: string[][]) { 149 | return objectPaths.map((objectPath) => ({ 150 | path: objectPath, 151 | value: _.get(source, objectPath.join('.')), 152 | })); 153 | } 154 | 155 | firstValue(values: { value: unknown }[]) { 156 | return values.reduce( 157 | (result, current) => (result.value ? result : current), 158 | {} as { value: unknown }, 159 | ); 160 | } 161 | 162 | getStageSourceValue() { 163 | const values = this.getValues(this, [ 164 | ['options', 'stage'], 165 | ['serverless', 'config', 'stage'], 166 | ['serverless', 'service', 'provider', 'stage'], 167 | ]); 168 | 169 | return this.firstValue(values); 170 | } 171 | 172 | getStage() { 173 | const defaultStage = 'dev'; 174 | const stageSourceValue = this.getStageSourceValue(); 175 | 176 | return stageSourceValue.value || defaultStage; 177 | } 178 | 179 | getServerlessDeploymentBucketName(): string { 180 | return `serverless-${this.folderId}`; 181 | } 182 | 183 | makeInvokeFunctionWithRetryParams(request: InvokeFunctionRequest) { 184 | return { 185 | functionId: request.functionId, 186 | serviceAccountId: request.serviceAccount, 187 | retrySettings: request.retry 188 | ? { 189 | retryAttempts: request.retry.attempts, 190 | interval: { 191 | seconds: request.retry.interval, 192 | }, 193 | } 194 | : undefined, 195 | deadLetterQueue: request.dlqId && request.dlqAccountId 196 | ? { 197 | queueId: request.dlqId, 198 | serviceAccountId: request.dlqAccountId, 199 | } 200 | : undefined, 201 | }; 202 | } 203 | 204 | @bind 205 | async createCronTrigger(request: CreateCronTriggerRequest) { 206 | const operation = await this.triggers.create(CloudApiTriggersService.CreateTriggerRequest.fromPartial({ 207 | folderId: this.folderId, 208 | name: request.name, 209 | rule: { 210 | timer: { 211 | cronExpression: request.expression, 212 | invokeFunctionWithRetry: this.makeInvokeFunctionWithRetryParams(request), 213 | }, 214 | }, 215 | })); 216 | 217 | return this.check(operation); 218 | } 219 | 220 | convertS3EvenType(type: string) { 221 | return { 222 | [S3Event.CREATE]: CloudApiTriggers.Trigger_ObjectStorageEventType.OBJECT_STORAGE_EVENT_TYPE_CREATE_OBJECT, 223 | [S3Event.DELETE]: CloudApiTriggers.Trigger_ObjectStorageEventType.OBJECT_STORAGE_EVENT_TYPE_DELETE_OBJECT, 224 | [S3Event.UPDATE]: CloudApiTriggers.Trigger_ObjectStorageEventType.OBJECT_STORAGE_EVENT_TYPE_UPDATE_OBJECT, 225 | }[type]; 226 | } 227 | 228 | @bind 229 | async createS3Trigger(request: CreateS3TriggerRequest) { 230 | const operation = await this.triggers.create(CloudApiTriggersService.CreateTriggerRequest.fromPartial({ 231 | folderId: this.folderId, 232 | name: request.name, 233 | rule: { 234 | objectStorage: { 235 | eventType: lodash.compact(request.events.map((type) => this.convertS3EvenType(type.toLowerCase()))), 236 | bucketId: request.bucket, 237 | prefix: request.prefix, 238 | suffix: request.suffix, 239 | invokeFunction: this.makeInvokeFunctionWithRetryParams(request), 240 | }, 241 | }, 242 | })); 243 | 244 | return this.check(operation); 245 | } 246 | 247 | @bind 248 | async createYMQTrigger(request: CreateYmqTriggerRequest) { 249 | const operation = await this.triggers.create(CloudApiTriggersService.CreateTriggerRequest.fromPartial({ 250 | folderId: this.folderId, 251 | name: request.name, 252 | rule: { 253 | messageQueue: { 254 | queueId: request.queueId, 255 | serviceAccountId: request.queueServiceAccount, 256 | batchSettings: { 257 | size: request.batch, 258 | cutoff: { seconds: request.cutoff }, 259 | }, 260 | invokeFunction: { 261 | functionId: request.functionId, 262 | serviceAccountId: request.serviceAccount, 263 | }, 264 | }, 265 | }, 266 | })); 267 | 268 | return this.check(operation); 269 | } 270 | 271 | @bind 272 | async createYDSTrigger(request: CreateYdsTriggerRequest) { 273 | const operation = await this.triggers.create(CloudApiTriggersService.CreateTriggerRequest.fromPartial({ 274 | folderId: this.folderId, 275 | name: request.name, 276 | rule: { 277 | dataStream: { 278 | stream: request.stream, 279 | serviceAccountId: request.streamServiceAccount, 280 | database: request.database, 281 | batchSettings: { 282 | size: request.batch, 283 | cutoff: { seconds: request.cutoff }, 284 | }, 285 | invokeFunction: this.makeInvokeFunctionWithRetryParams(request), 286 | }, 287 | }, 288 | })); 289 | 290 | return this.check(operation); 291 | } 292 | 293 | convertCREventType(type: string) { 294 | return { 295 | 'create.image': CloudApiTriggers.Trigger_ContainerRegistryEventType.CONTAINER_REGISTRY_EVENT_TYPE_CREATE_IMAGE, 296 | 'delete.image': CloudApiTriggers.Trigger_ContainerRegistryEventType.CONTAINER_REGISTRY_EVENT_TYPE_DELETE_IMAGE, 297 | 'create.image-tag': CloudApiTriggers.Trigger_ContainerRegistryEventType.CONTAINER_REGISTRY_EVENT_TYPE_CREATE_IMAGE_TAG, 298 | 'delete.image-tag': CloudApiTriggers.Trigger_ContainerRegistryEventType.CONTAINER_REGISTRY_EVENT_TYPE_DELETE_IMAGE_TAG, 299 | }[type]; 300 | } 301 | 302 | @bind 303 | async createCRTrigger(request: CreateCrTriggerRequest) { 304 | const operation = await this.triggers.create(CloudApiTriggersService.CreateTriggerRequest.fromPartial({ 305 | folderId: this.folderId, 306 | name: request.name, 307 | rule: { 308 | containerRegistry: { 309 | eventType: lodash.compact(request.events 310 | .map((type) => this.convertCREventType(type.toLowerCase()))), 311 | registryId: request.registryId, 312 | imageName: request.imageName, 313 | tag: request.tag, 314 | invokeFunction: this.makeInvokeFunctionWithRetryParams(request), 315 | }, 316 | }, 317 | })); 318 | 319 | return this.check(operation); 320 | } 321 | 322 | async removeTrigger(id: string) { 323 | const operation = await this.triggers.delete(CloudApiTriggersService.DeleteTriggerRequest.fromPartial({ 324 | triggerId: id, 325 | })); 326 | 327 | return this.check(operation); 328 | } 329 | 330 | async getTriggers(): Promise { 331 | const result = []; 332 | 333 | let nextPageToken; 334 | 335 | type ListTriggersResponse = cloudApi.serverless.triggers_trigger_service.ListTriggersResponse; 336 | 337 | do { 338 | const response: ListTriggersResponse = await this.triggers.list( 339 | CloudApiTriggersService.ListTriggersRequest.fromPartial({ 340 | folderId: this.folderId, 341 | pageToken: nextPageToken, 342 | }), 343 | ); 344 | 345 | if (response.triggers) { 346 | for (const trigger of response.triggers) { 347 | result.push({ 348 | name: trigger.name, 349 | id: trigger.id, 350 | }); 351 | } 352 | } 353 | 354 | nextPageToken = response.nextPageToken; 355 | } while (nextPageToken); 356 | 357 | return result; 358 | } 359 | 360 | async getServiceAccounts(): Promise { 361 | const access = await this.getAccessBindings(); 362 | 363 | const result = []; 364 | let nextPageToken; 365 | 366 | type ListServiceAccountsResponse = cloudApi.iam.service_account_service.ListServiceAccountsResponse; 367 | 368 | do { 369 | const response: ListServiceAccountsResponse = await this.serviceAccounts.list( 370 | CloudApiServiceAccountService.ListServiceAccountsRequest.fromPartial({ 371 | folderId: this.folderId, 372 | pageToken: nextPageToken, 373 | }), 374 | ); 375 | 376 | if (response.serviceAccounts) { 377 | for (const account of response.serviceAccounts) { 378 | result.push({ 379 | name: account.name, 380 | id: account.id, 381 | roles: access.filter((a) => a.subjectId === account.id).map((a) => a.role), 382 | }); 383 | } 384 | } 385 | 386 | nextPageToken = response.nextPageToken; 387 | } while (nextPageToken); 388 | 389 | return result; 390 | } 391 | 392 | async getAccessBindings() { 393 | const result = []; 394 | 395 | let nextPageToken; 396 | 397 | type ListAccessBindingsResponse = cloudApi.access.access.ListAccessBindingsResponse; 398 | 399 | do { 400 | const response: ListAccessBindingsResponse = await this.folders.listAccessBindings( 401 | CloudApiAccess.ListAccessBindingsRequest.fromPartial({ 402 | resourceId: this.folderId, 403 | pageToken: nextPageToken, 404 | }), 405 | ); 406 | 407 | if (response.accessBindings) { 408 | for (const access of response.accessBindings) { 409 | result.push({ 410 | role: access.roleId, 411 | subjectId: access.subject?.id, 412 | }); 413 | } 414 | } 415 | 416 | nextPageToken = response.nextPageToken; 417 | } while (nextPageToken); 418 | 419 | return result; 420 | } 421 | 422 | async updateAccessBindings(saId: string, roles: string[]): Promise { 423 | if (!roles || roles.length === 0) { 424 | return undefined; 425 | } 426 | 427 | const accessBindingDeltas = []; 428 | 429 | for (const roleId of Object.values(roles)) { 430 | accessBindingDeltas.push({ 431 | action: CloudApiAccess.AccessBindingAction.ADD, 432 | accessBinding: { 433 | roleId, 434 | subject: { 435 | id: saId, 436 | type: 'serviceAccount', 437 | }, 438 | }, 439 | }); 440 | } 441 | 442 | const operation = await this.folders.updateAccessBindings(CloudApiAccess.UpdateAccessBindingsRequest.fromPartial({ 443 | resourceId: this.folderId, 444 | accessBindingDeltas, 445 | })); 446 | 447 | return this.check(operation); 448 | } 449 | 450 | async createServiceAccount(request: CreateServiceAccountRequest) { 451 | const operation = await this.serviceAccounts.create(CloudApiServiceAccountService.CreateServiceAccountRequest.fromPartial({ 452 | folderId: this.folderId, 453 | name: request.name, 454 | })); 455 | const successfulOperation = await this.check(operation); 456 | const sa = decodeMessage(successfulOperation.response); 457 | 458 | await this.updateAccessBindings(sa.id, request.roles); 459 | 460 | return sa; 461 | } 462 | 463 | async removeServiceAccount(id: string) { 464 | return this.serviceAccounts.delete(CloudApiServiceAccountService.DeleteServiceAccountRequest.fromPartial({ 465 | serviceAccountId: id, 466 | })); 467 | } 468 | 469 | async createApiGateway(request: CreateApiGatewayRequest) { 470 | log.debug(yaml.stringify(JSON.parse(request.openapiSpec))); 471 | const operation = await this.apiGateways.create(CloudApiApiGatewayService.CreateApiGatewayRequest.fromPartial({ 472 | name: request.name, 473 | folderId: this.folderId, 474 | openapiSpec: request.openapiSpec, 475 | // description: request.description, 476 | })); 477 | 478 | const successfulOperation = await this.check(operation); 479 | 480 | const apigw = decodeMessage(successfulOperation.response); 481 | 482 | return { 483 | id: apigw.id, 484 | name: request.name, 485 | openapiSpec: request.openapiSpec, 486 | }; 487 | } 488 | 489 | async updateApiGateway(request: UpdateApiGatewayRequest) { 490 | const operation = await this.apiGateways.update(CloudApiApiGatewayService.UpdateApiGatewayRequest.fromPartial({ 491 | apiGatewayId: request.id, 492 | openapiSpec: request.openapiSpec, 493 | updateMask: { 494 | paths: [ 495 | 'openapi_spec', 496 | ], 497 | }, 498 | })); 499 | 500 | const successfulOperation = await this.check(operation); 501 | 502 | const apigw = decodeMessage(successfulOperation.response); 503 | 504 | request.id = apigw.id; 505 | 506 | return request; 507 | } 508 | 509 | async removeApiGateway(id: string) { 510 | const operation = await this.apiGateways.delete(CloudApiApiGatewayService.DeleteApiGatewayRequest.fromPartial({ 511 | apiGatewayId: id, 512 | })); 513 | 514 | return this.check(operation); 515 | } 516 | 517 | async getApiGateway(): Promise { 518 | type ListApiGatewayResponse = cloudApi.serverless.apigateway_service.ListApiGatewayResponse; 519 | const name = `${this.serverless.service.getServiceName()}-${this.getStage()}`; 520 | const listResponse: ListApiGatewayResponse = await this.apiGateways.list( 521 | CloudApiApiGatewayService.ListApiGatewayRequest.fromPartial({ 522 | folderId: this.folderId, 523 | filter: `name = "${name}"`, 524 | }), 525 | ); 526 | 527 | if (listResponse.apiGateways.length > 0) { 528 | const apiGateway = listResponse.apiGateways[0]; 529 | const specResponse: GetOpenapiSpecResponse = await this.apiGateways.getOpenapiSpec( 530 | CloudApiApiGatewayService.GetOpenapiSpecRequest.fromPartial({ 531 | apiGatewayId: apiGateway.id, 532 | }), 533 | ); 534 | 535 | return { 536 | name: apiGateway.name, 537 | id: apiGateway.id, 538 | domain: apiGateway.domain, 539 | domains: apiGateway.attachedDomains, 540 | openapiSpec: specResponse.openapiSpec, 541 | }; 542 | } 543 | 544 | return { 545 | name, 546 | }; 547 | } 548 | 549 | async removeFunction(id: string) { 550 | const operation = await this.functions.delete(CloudApiFunctionsService.DeleteFunctionRequest.fromPartial({ 551 | functionId: id, 552 | })); 553 | 554 | return waitForOperation(operation, this.session); 555 | } 556 | 557 | async invokeFunction(id: string) { 558 | const fn = await this.functions.get(CloudApiFunctionsService.GetFunctionRequest.fromPartial({ 559 | functionId: id, 560 | })); 561 | const response = await axios.get(fn.httpInvokeUrl); 562 | 563 | return response.data; 564 | } 565 | 566 | async getFunctions(): Promise { 567 | const result: FunctionInfo[] = []; 568 | 569 | let nextPageToken; 570 | 571 | type ListFunctionsResponse = cloudApi.serverless.functions_function_service.ListFunctionsResponse; 572 | 573 | do { 574 | const listResponse: ListFunctionsResponse = await this.functions.list( 575 | CloudApiFunctionsService.ListFunctionsRequest.fromPartial({ 576 | folderId: this.folderId, 577 | pageToken: nextPageToken, 578 | }), 579 | ); 580 | 581 | if (listResponse.functions) { 582 | for (const func of listResponse.functions) { 583 | result.push({ 584 | name: func.name, 585 | id: func.id, 586 | }); 587 | } 588 | } 589 | 590 | nextPageToken = listResponse.nextPageToken; 591 | } while (nextPageToken); 592 | 593 | return result; 594 | } 595 | 596 | async updateFunction(request: UpdateFunctionRequest, progress?: ProgressReporter) { 597 | const createVersionRequest: any = { 598 | functionId: request.id, 599 | runtime: request.runtime, 600 | entrypoint: request.handler, 601 | resources: { memory: request.memorySize && (request.memorySize * 1024 * 1024) }, 602 | executionTimeout: { 603 | seconds: request.timeout, 604 | }, 605 | serviceAccountId: request.serviceAccount, 606 | environment: request.environment, 607 | }; 608 | 609 | if ('code' in request.artifact) { 610 | createVersionRequest.content = Buffer.from(fileToBase64(request.artifact.code), 'base64'); 611 | } else { 612 | createVersionRequest.package = request.artifact.package as Package; 613 | } 614 | 615 | progress?.update(`Creating new version of function '${request.name}'`); 616 | const operation = await this.functions.createVersion( 617 | CloudApiFunctionsService.CreateFunctionVersionRequest.fromPartial(createVersionRequest), 618 | ); 619 | 620 | return this.check(operation); 621 | } 622 | 623 | // noinspection JSUnusedLocalSymbols 624 | async getFunctionLogs(id: string) { 625 | throw new Error('not implemented'); 626 | } 627 | 628 | async createFunction(request: CreateFunctionRequest, progress?: ProgressReporter) { 629 | progress?.update(`Creating function '${request.name}'`); 630 | const operation = await this.functions.create(CloudApiFunctionsService.CreateFunctionRequest.fromPartial({ 631 | name: request.name, 632 | folderId: this.folderId, 633 | })); 634 | 635 | const successfulOperation = await this.check(operation); 636 | 637 | const fn = decodeMessage(successfulOperation.response); 638 | 639 | request.id = fn.id; 640 | 641 | await this.updateFunction(request, progress); 642 | 643 | return request; 644 | } 645 | 646 | async getMessageQueueId(url: string) { 647 | const response = await this.ymq 648 | .getQueueAttributes({ 649 | QueueUrl: url, 650 | AttributeNames: ['QueueArn'], 651 | }) 652 | .promise(); 653 | 654 | return response.Attributes?.QueueArn; 655 | } 656 | 657 | parseQueueName(url: string) { 658 | return url.slice(url.lastIndexOf('/') + 1); 659 | } 660 | 661 | async getMessageQueues(): Promise { 662 | const response = await this.ymq.listQueues().promise(); 663 | const result = []; 664 | 665 | for (const url of (response.QueueUrls || [])) { 666 | const mqId = await this.getMessageQueueId(url); 667 | 668 | if (mqId) { 669 | result.push({ 670 | id: mqId, 671 | name: this.parseQueueName(url), 672 | url, 673 | }); 674 | } else { 675 | log.warning(`Unable to resolve ID of Message Queue ${url}`); 676 | } 677 | } 678 | 679 | return result; 680 | } 681 | 682 | async createMessageQueue(request: CreateMessageQueueRequest) { 683 | const createRequest: AWS.SQS.CreateQueueRequest = { 684 | QueueName: request.name, 685 | }; 686 | 687 | if (request.fifo) { 688 | createRequest.Attributes = { 689 | FifoQueue: 'true', 690 | ContentBasedDeduplication: request.fifoContentDeduplication ? 'true' : 'false', 691 | }; 692 | } 693 | 694 | const createResponse = await this.ymq.createQueue(createRequest).promise(); 695 | 696 | const url = createResponse.QueueUrl; 697 | 698 | if (!url) { 699 | throw new Error('Unable to get URL of created queue'); 700 | } 701 | 702 | const getAttrsResponse = await this.ymq 703 | .getQueueAttributes( 704 | { 705 | QueueUrl: url, 706 | AttributeNames: ['QueueArn'], 707 | }, 708 | ) 709 | .promise(); 710 | 711 | return { 712 | name: request.name, 713 | id: getAttrsResponse.Attributes?.QueueArn, 714 | url, 715 | }; 716 | } 717 | 718 | async removeMessageQueue(url: string) { 719 | return this.ymq.deleteQueue({ QueueUrl: url }).promise(); 720 | } 721 | 722 | async getS3Buckets(): Promise { 723 | const result: S3BucketInfo[] = []; 724 | const response = await this.s3.listBuckets().promise(); 725 | const buckets = response.Buckets || []; 726 | 727 | for (const bucket of buckets) { 728 | if (bucket.Name) { 729 | result.push({ 730 | name: bucket.Name, 731 | }); 732 | } 733 | } 734 | 735 | return result; 736 | } 737 | 738 | async checkS3Bucket(name: string) { 739 | return this.s3.headBucket({ Bucket: name }).promise(); 740 | } 741 | 742 | async createS3Bucket(request: CreateS3BucketRequest) { 743 | return this.s3.createBucket({ Bucket: request.name }).promise(); 744 | } 745 | 746 | async removeS3Bucket(name: string) { 747 | return this.s3.deleteBucket({ Bucket: name }).promise(); 748 | } 749 | 750 | async putS3Object(request: S3.Types.PutObjectRequest) { 751 | return this.s3.putObject(request).promise(); 752 | } 753 | 754 | async getContainerRegistries() { 755 | const result = []; 756 | 757 | let nextPageToken: string | undefined; 758 | 759 | do { 760 | const request = CloudApiRegistryService.ListRegistriesRequest.fromJSON({ 761 | folderId: this.folderId, 762 | pageToken: nextPageToken, 763 | }); 764 | const response = await this.containerRegistryService.list(request); 765 | 766 | if (response.registries) { 767 | for (const registry of response.registries) { 768 | result.push({ 769 | name: registry.name, 770 | id: registry.id, 771 | }); 772 | } 773 | } 774 | 775 | nextPageToken = response.nextPageToken; 776 | } while (nextPageToken); 777 | 778 | return result; 779 | } 780 | 781 | async createContainerRegistry(request: CreateContainerRegistryRequest) { 782 | const operation = await this.containerRegistryService.create(CloudApiRegistryService.CreateRegistryRequest.fromPartial({ 783 | folderId: this.folderId, 784 | name: request.name, 785 | })); 786 | 787 | const successfulOperation = await this.check(operation); 788 | 789 | const data = decodeMessage(successfulOperation.response); 790 | 791 | return { 792 | id: data.id, 793 | name: request.name, 794 | }; 795 | } 796 | 797 | async removeContainerRegistry(id: string) { 798 | const operation = await this.containerRegistryService.delete(CloudApiRegistryService.DeleteRegistryRequest.fromPartial({ 799 | registryId: id, 800 | })); 801 | 802 | return this.check(operation); 803 | } 804 | 805 | async getLockboxSecretKey(secretId: string): Promise> { 806 | const result: Record = {}; 807 | const response = await this.lockboxPayloadService.get(CloudApiLockboxPayloadService.GetPayloadRequest.fromPartial({ 808 | secretId, 809 | })); 810 | 811 | for (const entry of response.entries) { 812 | result[entry.key] = entry.textValue; 813 | } 814 | 815 | return result; 816 | } 817 | 818 | private async check(operation: Operation): Promise { 819 | try { 820 | const awaitedOperation = await waitForOperation(operation, this.session); 821 | 822 | return awaitedOperation as SuccessfulOperation; 823 | } catch (error: any) { 824 | const errorOperation = error as FailedOperation; 825 | 826 | for (const x of errorOperation.error.details) { 827 | log.debug(JSON.stringify(x)); 828 | } 829 | throw new Error(errorOperation.error.message); 830 | } 831 | } 832 | } 833 | -------------------------------------------------------------------------------- /src/provider/types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/indent */ 2 | import { PayloadFormatVersion } from '../types/common'; 3 | 4 | export interface InvokeFunctionRequest { 5 | functionId: string; 6 | serviceAccount?: string; 7 | retry?: { 8 | attempts: number; 9 | interval: number; 10 | }; 11 | dlqId?: string; 12 | dlqAccountId?: string; 13 | } 14 | 15 | export type CodeOrPackage = { code: string } | { package: { bucketName: string, objectName: string } }; 16 | 17 | export interface UpdateFunctionRequest { 18 | id: string; 19 | runtime: string; 20 | handler: string; 21 | name?: string; 22 | memorySize?: number; 23 | timeout?: number; 24 | serviceAccount?: string; 25 | artifact: CodeOrPackage; 26 | environment?: Record; 27 | } 28 | 29 | // TODO: get rid of 'any' 30 | export type CreateFunctionRequest = any; 31 | // TODO: get rid of 'any' 32 | export type CreateCronTriggerRequest = any; 33 | 34 | export interface CreateS3TriggerRequest extends InvokeFunctionRequest { 35 | name: string; 36 | events: string[]; 37 | bucket?: string; 38 | prefix?: string; 39 | suffix?: string; 40 | } 41 | 42 | // TODO: get rid of 'any' 43 | export type CreateYmqTriggerRequest = any; 44 | 45 | export interface CreateYdsTriggerRequest extends InvokeFunctionRequest { 46 | name: string; 47 | stream: string; 48 | database: string; 49 | streamServiceAccount: string; 50 | batch?: number; 51 | cutoff?: number; 52 | } 53 | 54 | export interface CreateCrTriggerRequest extends InvokeFunctionRequest { 55 | name: string; 56 | events: string[]; 57 | registryId?: string; 58 | imageName?: string; 59 | tag?: string; 60 | } 61 | 62 | // TODO: get rid of 'any' 63 | export type CreateServiceAccountRequest = any; 64 | // TODO: get rid of 'any' 65 | export type CreateMessageQueueRequest = { 66 | name: string; 67 | fifo?: boolean; 68 | fifoContentDeduplication?: boolean; 69 | }; 70 | // TODO: get rid of 'any' 71 | export type CreateS3BucketRequest = any; 72 | // TODO: get rid of 'any' 73 | export type CreateContainerRegistryRequest = any; 74 | 75 | export type CreateApiGatewayRequest = { 76 | name: string; 77 | openapiSpec: string; 78 | }; 79 | 80 | export type UpdateApiGatewayRequest = { 81 | id: string; 82 | name: string; 83 | openapiSpec: string; 84 | }; 85 | 86 | export interface ProviderConfig { 87 | name: string; 88 | stage: string; 89 | versionFunctions: boolean; 90 | credentials: string; 91 | project: string; 92 | region: 'ru-central1', 93 | httpApi: { 94 | payload: PayloadFormatVersion.V0 | PayloadFormatVersion.V1 95 | }, 96 | runtime: 'nodejs10' | 'nodejs12' | 'nodejs14' | 'nodejs16' | 'python27' | 'python37' | 'python38' 97 | | 'python39' | 'golang114' | 'golang116' | 'golang117' | 'java11' | 'dotnet31' | 'bash' 98 | | 'php74' | 'php8' | 'r4.0.2', 99 | memorySize: number, // Can be overridden by function configuration 100 | timeout: string, // Can be overridden by function configuration 101 | environment: { [key: string]: string }, // Can be overridden by function configuration 102 | vpc: string, // Can be overridden by function configuration 103 | labels: { [label: string]: string }, // Can be overridden by function configuration 104 | deploymentBucket: string | undefined; 105 | deploymentPrefix: string | undefined; 106 | } 107 | -------------------------------------------------------------------------------- /src/remove/remove.test.ts: -------------------------------------------------------------------------------- 1 | import { YandexCloudRemove } from './remove'; 2 | import Serverless from '../types/serverless'; 3 | 4 | describe('Remove', () => { 5 | let providerMock: any; 6 | let serverlessMock: any; 7 | 8 | const mockOptions: Serverless.Options = { 9 | region: 'ru-central1', 10 | stage: 'prod', 11 | }; 12 | 13 | beforeEach(() => { 14 | providerMock = { 15 | getFunctions: jest.fn(), 16 | removeFunction: jest.fn(), 17 | getTriggers: jest.fn(), 18 | removeTrigger: jest.fn(), 19 | getServiceAccounts: jest.fn(), 20 | removeServiceAccount: jest.fn(), 21 | getS3Buckets: jest.fn(), 22 | getMessageQueues: jest.fn(), 23 | getApiGateway: jest.fn(), 24 | removeApiGateway: jest.fn(), 25 | }; 26 | 27 | providerMock.getS3Buckets.mockReturnValue([]); 28 | providerMock.getMessageQueues.mockReturnValue([]); 29 | 30 | serverlessMock = { 31 | getProvider: () => providerMock, 32 | }; 33 | }); 34 | 35 | afterEach(() => { 36 | providerMock = null; 37 | serverlessMock = null; 38 | }); 39 | 40 | test('remove service', async () => { 41 | serverlessMock.service = { 42 | functions: { 43 | func1: { 44 | name: 'yc-nodejs-dev-func1', 45 | events: [ 46 | { 47 | cron: { 48 | name: 'yc-nodejs-dev-func1-cron', 49 | id: 'id2', 50 | }, 51 | }, 52 | ], 53 | }, 54 | func2: { 55 | name: 'yc-nodejs-dev-func2', 56 | events: [], 57 | }, 58 | }, 59 | package: { artifact: 'codePath' }, 60 | provider: { runtime: 'runtime' }, 61 | resources: { 62 | triggerSA: { 63 | type: 'yc::ServiceAccount', 64 | roles: ['serverless.functions.invoker'], 65 | }, 66 | }, 67 | }; 68 | serverlessMock.cli = { log: jest.fn() }; 69 | 70 | providerMock.getFunctions.mockReturnValue([{ 71 | name: 'yc-nodejs-dev-func1', 72 | id: 'id1', 73 | }]); 74 | providerMock.getTriggers.mockReturnValue([{ 75 | name: 'yc-nodejs-dev-func1-cron', 76 | id: 'id2', 77 | }]); 78 | providerMock.getServiceAccounts.mockReturnValue([{ 79 | name: 'triggerSA', 80 | id: 'id3', 81 | }]); 82 | providerMock.getApiGateway.mockReturnValue({ 83 | name: 'apiGw', 84 | id: 'id4', 85 | }); 86 | const remove = new YandexCloudRemove(serverlessMock, mockOptions); 87 | 88 | await remove.remove(); 89 | expect(providerMock.removeFunction).toBeCalledTimes(1); 90 | expect(providerMock.removeFunction.mock.calls[0][0]).toBe('id1'); 91 | expect(providerMock.removeTrigger).toBeCalledTimes(1); 92 | expect(providerMock.removeTrigger.mock.calls[0][0]).toBe('id2'); 93 | expect(providerMock.removeServiceAccount).toBeCalledTimes(1); 94 | expect(providerMock.removeServiceAccount.mock.calls[0][0]).toBe('id3'); 95 | expect(providerMock.removeApiGateway).toBeCalledTimes(1); 96 | expect(providerMock.removeApiGateway.mock.calls[0][0]).toBe('id4'); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /src/remove/remove.ts: -------------------------------------------------------------------------------- 1 | import ServerlessPlugin from 'serverless/classes/Plugin'; 2 | 3 | import { Trigger } from '../entities/trigger'; 4 | import { YandexCloudProvider } from '../provider/provider'; 5 | import { 6 | ApiGatewayInfo, 7 | FunctionInfo, 8 | MessageQueueInfo, 9 | S3BucketInfo, 10 | ServiceAccountInfo, 11 | TriggerInfo, 12 | } from '../types/common'; 13 | import { log } from '../utils/logging'; 14 | import Serverless from '../types/serverless'; 15 | 16 | export class YandexCloudRemove implements ServerlessPlugin { 17 | hooks: ServerlessPlugin.Hooks; 18 | private readonly serverless: Serverless; 19 | private readonly options: Serverless.Options; 20 | private readonly provider: YandexCloudProvider; 21 | private existingQueues: MessageQueueInfo[] | undefined = undefined; 22 | private existingBuckets: S3BucketInfo[] | undefined = undefined; 23 | 24 | constructor(serverless: Serverless, options: Serverless.Options) { 25 | this.serverless = serverless; 26 | this.options = options; 27 | this.provider = this.serverless.getProvider('yandex-cloud') as YandexCloudProvider; 28 | 29 | this.hooks = { 30 | 'remove:remove': async () => { 31 | await this.remove(); 32 | }, 33 | }; 34 | } 35 | 36 | async removeFunction(describedFunctionName: string, existingFunctions: FunctionInfo[]) { 37 | const functionFound = existingFunctions.find((func) => func.name === describedFunctionName); 38 | 39 | if (functionFound) { 40 | await this.provider.removeFunction(functionFound.id); 41 | 42 | log.success(`Function "${describedFunctionName}" removed`); 43 | } else { 44 | log.notice.skip(`Function "${describedFunctionName}" not found`); 45 | } 46 | } 47 | 48 | async removeTrigger(describedTriggerName: string, existingTriggers: TriggerInfo[]) { 49 | const triggerFound = existingTriggers.find((trigger) => trigger.name === describedTriggerName); 50 | 51 | if (triggerFound) { 52 | await this.provider.removeTrigger(triggerFound.id); 53 | 54 | log.success(`Trigger "${describedTriggerName}" removed`); 55 | } else { 56 | log.notice.skip(`Trigger "${describedTriggerName}" not found`); 57 | } 58 | } 59 | 60 | async removeServiceAccount(describedSaName: string, existingAccounts: ServiceAccountInfo[]) { 61 | const accFound = existingAccounts.find((acc) => acc.name === describedSaName); 62 | 63 | if (accFound) { 64 | await this.provider.removeServiceAccount(accFound.id); 65 | 66 | log.success(`Service account "${describedSaName}" removed`); 67 | } else { 68 | log.notice.skip(`Service account "${describedSaName}" not found`); 69 | } 70 | } 71 | 72 | async removeMessageQueue(describesQueueName: string, existingQueues: MessageQueueInfo[]) { 73 | const found = existingQueues.find((q) => q.name === describesQueueName); 74 | 75 | if (found) { 76 | await this.provider.removeMessageQueue(found.url); 77 | log.success(`Message queue "${describesQueueName}" removed`); 78 | } else { 79 | log.notice.skip(`Message queue "${describesQueueName}" not found`); 80 | } 81 | } 82 | 83 | async removeS3Bucket(describesBucketName: string, existingBuckets: S3BucketInfo[]) { 84 | const found = existingBuckets.find((b) => b.name === describesBucketName); 85 | 86 | if (found) { 87 | await this.provider.removeS3Bucket(describesBucketName); 88 | log.success(`S3 bucket "${describesBucketName}" removed`); 89 | } else { 90 | log.notice.skip(`S3 bucket "${describesBucketName}" not found`); 91 | } 92 | } 93 | 94 | async removeApiGateway(existingApiGateway: ApiGatewayInfo) { 95 | const found = existingApiGateway.id; 96 | 97 | if (found) { 98 | await this.provider.removeApiGateway(found); 99 | log.success(`API Gateway "${existingApiGateway.name}" removed`); 100 | } else { 101 | log.notice.skip(`API Gateway "${existingApiGateway.name}" not found`); 102 | } 103 | } 104 | 105 | async getMessageQueuesCached() { 106 | if (!this.existingQueues) { 107 | this.existingQueues = await this.provider.getMessageQueues(); 108 | } 109 | 110 | return this.existingQueues; 111 | } 112 | 113 | async getS3BucketsCached() { 114 | if (!this.existingBuckets) { 115 | this.existingBuckets = await this.provider.getS3Buckets(); 116 | } 117 | 118 | return this.existingBuckets; 119 | } 120 | 121 | async remove() { 122 | const existingFunctions = await this.provider.getFunctions(); 123 | const existingTriggers = await this.provider.getTriggers(); 124 | const describedFunctions = this.serverless.service.functions; 125 | const existingAccounts = await this.provider.getServiceAccounts(); 126 | const existingApiGateway = await this.provider.getApiGateway(); 127 | 128 | for (const descFunc of Object.values(describedFunctions)) { 129 | for (const triggerType of Trigger.supportedTriggers()) { 130 | // @ts-ignore 131 | if (descFunc.events.some((e) => e[triggerType])) { 132 | this.removeTrigger(`${descFunc.name}-${triggerType}`, existingTriggers); 133 | } 134 | } 135 | 136 | if (descFunc.name) { 137 | this.removeFunction(descFunc.name, existingFunctions); 138 | } 139 | } 140 | for (const [name, params] of Object.entries(this.serverless.service.resources || [])) { 141 | try { 142 | // eslint-disable-next-line default-case 143 | switch (params.type) { 144 | case 'yc::MessageQueue': 145 | this.removeMessageQueue(name, await this.getMessageQueuesCached()); 146 | break; 147 | case 'yc::ObjectStorageBucket': 148 | this.removeS3Bucket(name, await this.getS3BucketsCached()); 149 | break; 150 | } 151 | } catch (error) { 152 | log.error(`${error} Maybe you should set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY environment variables`); 153 | } 154 | } 155 | 156 | for (const [name, params] of Object.entries(this.serverless.service.resources || [])) { 157 | if (params.type === 'yc::ServiceAccount') { 158 | this.removeServiceAccount(name, existingAccounts); 159 | } 160 | } 161 | this.removeApiGateway(existingApiGateway); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/types/common.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIV3 } from 'openapi-types'; 2 | import { Event } from './events'; 3 | import { Package } from './serverless'; 4 | 5 | export const X_YC_API_GATEWAY_AUTHORIZER = 'x-yc-apigateway-authorizer'; 6 | export const X_YC_API_GATEWAY_INTEGRATION = 'x-yc-apigateway-integration'; 7 | export const X_YC_API_GATEWAY_ANY_METHOD = 'x-yc-apigateway-any-method'; 8 | export const X_YC_API_GATEWAY = 'x-yc-apigateway'; 9 | 10 | export enum HttpMethods { 11 | GET = 'get', 12 | PUT = 'put', 13 | POST = 'post', 14 | DELETE = 'delete', 15 | OPTIONS = 'options', 16 | HEAD = 'head', 17 | PATCH = 'patch', 18 | TRACE = 'trace', 19 | ANY = 'x-yc-apigateway-any-method', 20 | } 21 | 22 | export type HttpMethod = 23 | | HttpMethods.GET 24 | | HttpMethods.PUT 25 | | HttpMethods.POST 26 | | HttpMethods.DELETE 27 | | HttpMethods.OPTIONS 28 | | HttpMethods.HEAD 29 | | HttpMethods.PATCH 30 | | HttpMethods.TRACE 31 | | HttpMethods.ANY; 32 | 33 | export enum PayloadFormatVersion { 34 | V0 = '0.1', 35 | V1 = '1.0', 36 | } 37 | 38 | // export type ExtendedSecuritySchemeObject = OpenAPIV3.SecuritySchemeObject & {[prop: string]: unknown}; 39 | // export type ExtendedDocument = OpenAPIV3.Document & {[prop: string]: unknown}; 40 | 41 | export interface ApiGatewayObject { 42 | service_account_id?: string; 43 | service_account?: string; 44 | } 45 | 46 | export enum IntegrationType { 47 | dummy = 'dummy', 48 | cloud_functions = 'cloud_functions', 49 | _cloud_functions = 'cloud-functions', 50 | http = 'http', 51 | object_storage = 'object_storage', 52 | _object_storage = 'object-storage', 53 | cloud_datasphere = 'cloud_datasphere', 54 | serverless_containers = 'serverless_containers', 55 | cloud_ymq = 'cloud_ymq', 56 | cloud_datastreams = 'cloud_datastreams', 57 | } 58 | 59 | interface BaseIntegrationObject { 60 | type: IntegrationType; 61 | } 62 | 63 | export interface DummyIntegrationObject extends BaseIntegrationObject { 64 | type: IntegrationType.dummy; 65 | /** 66 | * HTTP response code 67 | * 68 | * @minimum 100 69 | * @maximum 599 70 | * @TJS-type integer 71 | */ 72 | http_code: number; 73 | /** 74 | * List of headers to be sent in response. Parameters are substituted in `http_headers`. 75 | */ 76 | http_headers: { [header: string]: string | string[] }; 77 | /** 78 | * Data to be sent in response. 79 | * Can be either real content or mapping from the requested `Content-Type` into data. 80 | * This lets you send errors in the requested format: JSON or XML. 81 | * The `*` key is used for the default value. Parameters are substituted in `content`. 82 | */ 83 | content: { [header: string]: string }; 84 | } 85 | 86 | export interface HttpIntegrationTimeouts { 87 | /** 88 | * @minimum 0 89 | */ 90 | read: number; 91 | /** 92 | * @minimum 0 93 | */ 94 | connect: number; 95 | } 96 | 97 | export interface HttpIntegrationsObject extends BaseIntegrationObject { 98 | type: IntegrationType.http; 99 | /** 100 | * URL to redirect the invocation to (must be accessible from the internet). Parameters are substituted in `url`. 101 | */ 102 | url: string; 103 | /** 104 | * Optional parameter. HTTP method used for the invocation. If the parameter is omitted, 105 | * it defaults to the method of request to API Gateway. 106 | */ 107 | method: HttpMethods.GET | HttpMethods.POST | HttpMethods.PUT | HttpMethods.PATCH | HttpMethods.DELETE; 108 | /** 109 | * HTTP headers to be passed. Original request headers are not passed. Parameters are substituted in `headers`. 110 | */ 111 | headers: { [header: string]: string }; 112 | /** 113 | * Optional parameter. The `read` and `connect` invocation timeouts, in seconds. 114 | */ 115 | timeouts: HttpIntegrationTimeouts; 116 | 117 | } 118 | 119 | export interface FunctionIntegrationObject extends BaseIntegrationObject { 120 | type: IntegrationType.cloud_functions | IntegrationType._cloud_functions; 121 | /** 122 | * Function ID. 123 | */ 124 | function_id: string; 125 | /** 126 | * Optional parameter. Version tag. The default value is `$latest`. Parameters are substituted in tag. 127 | */ 128 | tag?: '$latest' | string; 129 | /** 130 | * Function call format version. Legal values: 0.1 and 1.0. Default version: 0.1. 131 | */ 132 | payload_format_version?: PayloadFormatVersion; 133 | /** 134 | * Optional parameter. Operation context is an arbitrary object in YAML or JSON format. 135 | * Passed to a function inside a request using the `requestContext.apiGateway.operationContext` field. 136 | * Parameter substitution takes place in `context`. 137 | */ 138 | context?: object; 139 | /** 140 | * Service account ID used for authorization when accessing a container. 141 | * If the parameter is omitted, the value of the top-level `service_account_id` parameter is used. 142 | */ 143 | service_account_id?: string; 144 | } 145 | 146 | export interface DatasphereIntegrationObject extends BaseIntegrationObject { 147 | type: IntegrationType.cloud_datasphere; 148 | /** 149 | * ID of the folder containing the created DataSphere project and the deployed node. 150 | */ 151 | folder_id: string; 152 | /** 153 | * DataSphere node ID. 154 | */ 155 | node_id: string; 156 | /** 157 | * ID of the service account. Used for authorization when calling a DataSphere node. 158 | * If you omit the parameter, the value used is that of the top-level parameter called `service_account_id`. 159 | */ 160 | service_account_id?: string; 161 | 162 | } 163 | 164 | export interface ContainerIntegrationObject extends BaseIntegrationObject { 165 | type: IntegrationType.serverless_containers; 166 | /** 167 | * Container ID. 168 | */ 169 | container_id: string; 170 | /** 171 | * Optional parameter. Operation context is an arbitrary object in YAML or JSON format. 172 | * Encoded in Base64 and passed to the container in the `X-Yc-ApiGateway-Operation-Context` header. 173 | * `context` is where parameter substitution takes place. 174 | */ 175 | context?: object; 176 | /** 177 | * Service account ID used for authorization when accessing a container. 178 | * If the parameter is omitted, the value of the top-level `service_account_id` parameter is used. 179 | */ 180 | service_account_id?: string; 181 | } 182 | 183 | export interface S3IntegrationObject extends BaseIntegrationObject { 184 | type: IntegrationType.object_storage | IntegrationType._object_storage; 185 | /** 186 | * Name of the bucket. 187 | */ 188 | bucket: string; 189 | /** 190 | * Object name. Supports parameter standardization from the path of the original request. 191 | * Parameters are substituted in `object`. 192 | */ 193 | object: string; 194 | 195 | /** 196 | * Optional parameter. Object name returned if HTTP error code 4xx is received instead of object. 197 | */ 198 | error_object?: string; 199 | /** 200 | * If the value is true, a pre-signed URL is generated and a redirect is returned to the client. 201 | */ 202 | presigned_redirect?: boolean; 203 | /** 204 | * Service account ID used for authorization when accessing a container. 205 | * If the parameter is omitted, the value of the top-level `service_account_id` parameter is used. 206 | */ 207 | service_account_id?: string; 208 | } 209 | 210 | export interface YmqIntegrationObject extends BaseIntegrationObject { 211 | type: IntegrationType.cloud_ymq; 212 | /** 213 | * The type of operation to perform. 214 | */ 215 | action: 'SendMessage'; 216 | /** 217 | * Queue address. 218 | */ 219 | queue_url: string; 220 | 221 | /** 222 | * ID of the folder containing the queue. 223 | */ 224 | folder_id: string; 225 | /** 226 | * The number of seconds to delay the message from being available for processing. 227 | * @minimum 0 228 | * @TJS-type integer 229 | */ 230 | delay_seconds: number; 231 | /** 232 | * ID of the service account. Used for authorization when performing a queue operation. 233 | * If you omit the parameter, the value used is that of the top-level parameter `service_account_id`. 234 | */ 235 | service_account_id?: string; 236 | } 237 | 238 | export interface DatastreamIntegrationObject extends BaseIntegrationObject { 239 | type: IntegrationType.cloud_datastreams; 240 | /** 241 | * The type of operation to perform. 242 | */ 243 | action: 'PutRecord'; 244 | /** 245 | * Data Streams stream name. 246 | */ 247 | stream_name: string; 248 | 249 | /** 250 | * Shard key. `partition_key` is where parameter substitution takes place. 251 | */ 252 | partition_key: string; 253 | 254 | /** 255 | * ID of the service account. Used for authorization when performing Data Streams stream operations. 256 | * If you omit the parameter, the value used is that of the top-level parameter called `service_account_id`. 257 | */ 258 | service_account_id?: string; 259 | } 260 | 261 | export type ApiGatewayIntegrationObject = 262 | | S3IntegrationObject 263 | | ContainerIntegrationObject 264 | | DatasphereIntegrationObject 265 | | FunctionIntegrationObject 266 | | DummyIntegrationObject 267 | | HttpIntegrationsObject 268 | | YmqIntegrationObject 269 | | DatastreamIntegrationObject; 270 | 271 | export enum AuthorizerType { 272 | function = 'function', 273 | jwt = 'jwt', 274 | iam = 'iam', 275 | } 276 | 277 | export interface AuthorizerObject { 278 | type: AuthorizerType; 279 | /** 280 | * Function ID. 281 | */ 282 | function_id: string; 283 | /** 284 | * Optional parameter. Version tag. Default value: `$latest`. Parameters are substituted in `tag`. 285 | */ 286 | tag: string; 287 | /** 288 | * ID of the service account. Used for authorization when invoking a function. 289 | * If you omit the parameter, the value used is that of the top-level parameter `service_account_id`. 290 | * If there is no top-level parameter, the function is invoked without authorization. 291 | */ 292 | service_account_id: string; 293 | /** 294 | * Optional parameter. A time limit on keeping the function response stored in the local API Gateway cache. 295 | * If the parameter is omitted, the response is not cached. 296 | * @minimum 0 297 | * @TJS-type integer 298 | */ 299 | authorizer_result_ttl_in_seconds?: number; 300 | } 301 | 302 | export type YcPathItemObject = OpenAPIV3.PathItemObject<{ 'x-yc-apigateway-integration': ApiGatewayIntegrationObject; } & T>; 303 | 304 | export interface YcPathsObject = Record, 305 | P extends Record = Record> { 306 | [pattern: string]: (YcPathItemObject & P) | undefined; 307 | } 308 | 309 | export interface YcOpenAPI3 extends OpenAPIV3.Document { 310 | paths: YcPathsObject; 311 | } 312 | 313 | export interface MessageQueueInfo { 314 | id: string; 315 | name: string; 316 | url: string; 317 | } 318 | 319 | export interface AttachedDomain { 320 | domainId: string; 321 | certificateId: string; 322 | enabled: boolean; 323 | domain: string; 324 | } 325 | 326 | export interface ApiGatewayInfo { 327 | id?: string; 328 | domain?: string; 329 | name: string; 330 | domains?: AttachedDomain[]; 331 | openapiSpec?: string; 332 | } 333 | 334 | export interface FunctionInfo { 335 | id: string; 336 | name: string; 337 | } 338 | 339 | export interface TriggerInfo { 340 | id: string; 341 | name: string; 342 | } 343 | 344 | export interface ServiceAccountInfo { 345 | id: string; 346 | name: string; 347 | roles: string[]; 348 | } 349 | 350 | export interface S3BucketInfo { 351 | name: string; 352 | } 353 | 354 | export enum HttpMethodAliases { 355 | GET = 'get', 356 | PUT = 'put', 357 | POST = 'post', 358 | DELETE = 'delete', 359 | OPTIONS = 'options', 360 | HEAD = 'head', 361 | PATCH = 'patch', 362 | TRACE = 'trace', 363 | ANY = 'any', 364 | } 365 | 366 | export type HttpMethodAlias = 367 | HttpMethodAliases.GET 368 | | HttpMethodAliases.PUT 369 | | HttpMethodAliases.POST 370 | | HttpMethodAliases.DELETE 371 | | HttpMethodAliases.OPTIONS 372 | | HttpMethodAliases.HEAD 373 | | HttpMethodAliases.PATCH 374 | | HttpMethodAliases.TRACE 375 | | HttpMethodAliases.ANY; 376 | 377 | type Parameters = { [param: string]: boolean }; 378 | export type RequestParameters = { 379 | querystrings?: Parameters, 380 | headers?: Parameters, 381 | paths?: Parameters, 382 | }; 383 | 384 | export interface ApiGatewayEvent { 385 | http: string | 386 | { 387 | path: string; 388 | method: HttpMethodAlias; 389 | authorizer?: any; 390 | cors?: any; 391 | integration?: string | undefined; 392 | eventFormat?: PayloadFormatVersion.V0 | PayloadFormatVersion.V1, 393 | request?: { 394 | parameters?: RequestParameters; 395 | }, 396 | context?: object, 397 | schemas?: { [schema: string]: string | object } 398 | }; 399 | } 400 | 401 | export interface ServerlessFunc { 402 | account: string; 403 | handler: string; 404 | name?: string | undefined; 405 | package?: Package | undefined; 406 | reservedConcurrency?: number | undefined; 407 | runtime?: string | undefined; 408 | timeout?: number | undefined; 409 | memorySize?: number | undefined; 410 | environment?: { [name: string]: string } | undefined; 411 | events: Event[]; 412 | tags?: { [key: string]: string } | undefined; 413 | } 414 | 415 | export enum TriggerType { 416 | CRON = 'cron', 417 | S3 = 's3', 418 | YMQ = 'ymq', 419 | CR = 'cr', 420 | YDS = 'yds', 421 | } 422 | 423 | export enum EventType { 424 | HTTP = 'http', 425 | } 426 | -------------------------------------------------------------------------------- /src/types/events.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HttpMethodAlias, 3 | PayloadFormatVersion, 4 | } from './common'; 5 | 6 | interface HttpAuthorizer { 7 | name?: string | undefined; 8 | resultTtlInSeconds?: number | string | undefined; 9 | identitySource?: string | undefined; 10 | identityValidationExpression?: string | undefined; 11 | type?: string | undefined; 12 | } 13 | 14 | interface HttpRequestParametersValidation { 15 | querystrings?: { [key: string]: boolean } | undefined; 16 | headers?: { [key: string]: boolean } | undefined; 17 | paths?: { [key: string]: boolean } | undefined; 18 | } 19 | 20 | interface HttpRequestValidation { 21 | parameters?: HttpRequestParametersValidation | undefined; 22 | schema?: { [key: string]: Record } | undefined; 23 | } 24 | 25 | interface Http { 26 | path: string; 27 | method: HttpMethodAlias; 28 | eventFormat?: PayloadFormatVersion.V0 | PayloadFormatVersion.V1; 29 | authorizer?: HttpAuthorizer | string; 30 | request?: HttpRequestValidation; 31 | context?: Record; 32 | } 33 | 34 | export enum S3Event { 35 | CREATE = 'create.object', 36 | DELETE = 'delete.object', 37 | UPDATE = 'update.object', 38 | } 39 | 40 | interface S3 { 41 | bucket: string; 42 | account: string; 43 | events: S3Event[]; 44 | prefix: string | undefined; 45 | suffix: string | undefined; 46 | retry: EventRetryPolicy | undefined; 47 | dlq: string | undefined; 48 | dlqId: string | undefined; 49 | dlqAccountId: string | undefined; 50 | dlqAccount: string | undefined; 51 | } 52 | 53 | interface EventRetryPolicy { 54 | attempts: number; 55 | interval: number; 56 | } 57 | 58 | interface YMQ { 59 | queue: string; 60 | queueId: string | undefined; 61 | queueAccount: string; 62 | account: string; 63 | retry: EventRetryPolicy | undefined; 64 | } 65 | 66 | export interface Event { 67 | http?: Http | undefined | string; 68 | s3?: S3 | undefined; 69 | ymq?: YMQ | undefined; 70 | } 71 | -------------------------------------------------------------------------------- /src/types/plugin-manager.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-misused-new */ 2 | import Serverless from './serverless'; 3 | import Plugin from './plugin'; 4 | 5 | export default interface PluginManager { 6 | cliOptions: Record; 7 | cliCommands: Record; 8 | serverless: Serverless; 9 | plugins: Plugin[]; 10 | commands: Record; 11 | hooks: Record; 12 | deprecatedEvents: Record; 13 | 14 | new(serverless: Serverless): PluginManager; 15 | 16 | setCliOptions(options: Serverless.Options): void; 17 | 18 | setCliCommands(commands: Record): void; 19 | 20 | addPlugin(plugin: Plugin.PluginStatic): void; 21 | 22 | loadAllPlugins(servicePlugins: Record): void; 23 | 24 | loadPlugins(plugins: Record): void; 25 | 26 | loadCorePlugins(): void; 27 | 28 | loadServicePlugins(servicePlugins: Record): void; 29 | 30 | loadCommand(pluginName: string, details: Record, key: string): Record; 31 | 32 | loadCommands(pluginInstance: Plugin): void; 33 | 34 | spawn(commandsArray: string | string[], options?: any): Promise; 35 | } 36 | -------------------------------------------------------------------------------- /src/types/plugin.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/indent, @typescript-eslint/no-namespace */ 2 | import Serverless from './serverless'; 3 | 4 | declare namespace Plugin { 5 | interface Hooks { 6 | [event: string]: (...rest: any[]) => any; 7 | } 8 | 9 | interface Commands { 10 | [command: string]: { 11 | usage?: string | undefined; 12 | lifecycleEvents?: string[] | undefined; 13 | commands?: { [command: string]: Record } | undefined; 14 | options?: 15 | | { 16 | [option: string]: { 17 | usage?: string | undefined; 18 | required?: boolean | undefined; 19 | shortcut?: string | undefined; 20 | }; 21 | } 22 | | undefined; 23 | }; 24 | } 25 | 26 | type VariableResolver = (variableSource: string) => Promise; 27 | 28 | interface VariableResolvers { 29 | [variablePrefix: string]: 30 | | VariableResolver 31 | | { 32 | resolver: VariableResolver; 33 | isDisabledAtPrepopulation?: boolean | undefined; 34 | serviceName?: string | undefined; 35 | }; 36 | } 37 | 38 | type ConfigurationVariablesSource = (variableSource: any) => Promise; 39 | 40 | interface ConfigurationVariablesSources { 41 | [variablePrefix: string]: 42 | | ConfigurationVariablesSource 43 | | { 44 | resolve: ConfigurationVariablesSource; 45 | isDisabledAtPrepopulation?: boolean | undefined; 46 | serviceName?: string | undefined; 47 | }; 48 | } 49 | 50 | interface Logging { 51 | log: { 52 | error: (text: string) => void; 53 | warning: (text: string) => void; 54 | notice: (text: string) => void; 55 | info: (text: string) => void; 56 | debug: (text: string) => void; 57 | verbose: (text: string) => void; 58 | success: (text: string) => void; 59 | }; 60 | writeText: (text: string | string[]) => void; 61 | } 62 | 63 | interface PluginStatic { 64 | new(serverless: Serverless, options: Serverless.Options, logging: Logging): Plugin; 65 | } 66 | } 67 | 68 | interface Plugin { 69 | hooks: Plugin.Hooks; 70 | commands?: Plugin.Commands | undefined; 71 | variableResolvers?: Plugin.VariableResolvers | undefined; 72 | configurationVariablesSources?: Plugin.ConfigurationVariablesSources | undefined; 73 | } 74 | 75 | export = Plugin; 76 | -------------------------------------------------------------------------------- /src/types/serverless.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-namespace */ 2 | 3 | import Utils = require('serverless/classes/Utils'); 4 | import YamlParser = require('serverless/classes/YamlParser'); 5 | import { YandexCloudProvider } from '../provider/provider'; 6 | import * as events from './events'; 7 | import PluginManager from './plugin-manager'; 8 | import Service from './service'; 9 | 10 | // I had to redeclare Serverless typings here to eliminate typecasting for the `getProvider` method and 11 | // remove unnecessary inheriting YandexCloudProvider from AwsProvider. 12 | // Unfortunately, Serverless typings are handwritten and do not fully reflect Serverless complexity. 13 | 14 | declare namespace Serverless { 15 | interface Options { 16 | function?: string | undefined; 17 | watch?: boolean | undefined; 18 | verbose?: boolean | undefined; 19 | extraServicePath?: string | undefined; 20 | stage: string | null; 21 | region: string | null; 22 | noDeploy?: boolean | undefined; 23 | } 24 | 25 | interface Config { 26 | servicePath: string; 27 | } 28 | 29 | interface FunctionDefinition { 30 | name?: string | undefined; 31 | package?: Package | undefined; 32 | reservedConcurrency?: number | undefined; 33 | runtime?: string | undefined; 34 | timeout?: number | undefined; 35 | memorySize?: number | undefined; 36 | environment?: { [name: string]: string } | undefined; 37 | events: events.Event[]; 38 | tags?: { [key: string]: string } | undefined; 39 | } 40 | 41 | interface LogOptions { 42 | color?: string | undefined; 43 | bold?: boolean | undefined; 44 | underline?: boolean | undefined; 45 | entity?: string | undefined; 46 | } 47 | 48 | interface FunctionDefinitionHandler extends FunctionDefinition { 49 | handler: string; 50 | } 51 | 52 | interface FunctionDefinitionImage extends FunctionDefinition { 53 | image: string; 54 | } 55 | 56 | interface Package { 57 | /** @deprecated use `patterns` instead */ 58 | include?: string[] | undefined; 59 | /** @deprecated use `patterns` instead */ 60 | exclude?: string[] | undefined; 61 | patterns?: string[] | undefined; 62 | artifact?: string | undefined; 63 | individually?: boolean | undefined; 64 | } 65 | 66 | } 67 | 68 | declare class Serverless { 69 | cli: { 70 | /** 71 | * @deprecated starting from Serverless V3, this method is deprecated, 72 | * see https://www.serverless.com/framework/docs/guides/plugins/cli-output 73 | */ 74 | log(message: string, entity?: string, options?: Serverless.LogOptions): null; 75 | }; 76 | providers: Record; 77 | utils: Utils; 78 | variables: { 79 | populateService(): Promise; 80 | }; 81 | yamlParser: YamlParser; 82 | pluginManager: PluginManager; 83 | config: Serverless.Config; 84 | serverlessDirPath: string; 85 | service: Service; 86 | version: string; 87 | resources: object; 88 | configSchemaHandler: { 89 | defineCustomProperties(schema: unknown): void; 90 | defineFunctionEvent(provider: string, event: string, schema: Record): void; 91 | defineFunctionEventProperties(provider: string, existingEvent: string, schema: unknown): void; 92 | defineFunctionProperties(provider: string, schema: unknown): void; 93 | defineProvider(provider: string, options?: Record): void; 94 | defineTopLevelProperty(provider: string, schema: Record): void; 95 | }; 96 | 97 | constructor(config?: Record); 98 | 99 | init(): Promise; 100 | 101 | run(): Promise; 102 | 103 | setProvider(name: 'yandex-cloud', provider: YandexCloudProvider): null; 104 | 105 | getProvider(name: 'yandex-cloud'): YandexCloudProvider; 106 | 107 | getVersion(): string; 108 | } 109 | 110 | export = Serverless; 111 | -------------------------------------------------------------------------------- /src/types/service.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-namespace, @typescript-eslint/no-misused-new */ 2 | import Serverless from './serverless'; 3 | import { Event } from './events'; 4 | import { ProviderConfig } from '../provider/types'; 5 | 6 | declare namespace Service { 7 | interface Custom { 8 | [key: string]: any; 9 | } 10 | } 11 | 12 | export default interface Service { 13 | custom: Service.Custom; 14 | 15 | provider: ProviderConfig; 16 | serverless: Serverless; 17 | service: string | null; 18 | plugins: string[]; 19 | pluginsData: { [key: string]: any }; 20 | functions: { [key: string]: Serverless.FunctionDefinitionHandler | Serverless.FunctionDefinitionImage }; 21 | resources: | { 22 | Resources: { 23 | [key: string]: any; 24 | }; 25 | } 26 | | { [key: string]: any }; 27 | package: { [key: string]: any }; 28 | configValidationMode: string; 29 | disabledDeprecations?: any[] | undefined; 30 | serviceFilename?: string | undefined; 31 | app?: any; 32 | tenant?: any; 33 | org?: any; 34 | layers: { [key: string]: any }; 35 | outputs?: any; 36 | initialServerlessConfig: any; 37 | 38 | new(serverless: Serverless, data: Record): Service; 39 | 40 | load(rawOptions: Record): Promise; 41 | 42 | setFunctionNames(rawOptions: Record): void; 43 | 44 | getServiceName(): string; 45 | 46 | getAllFunctions(): string[]; 47 | 48 | getAllFunctionsNames(): string[]; 49 | 50 | getFunction(functionName: string): Serverless.FunctionDefinitionHandler | Serverless.FunctionDefinitionImage; 51 | 52 | getEventInFunction(eventName: string, functionName: string): Event; 53 | 54 | getAllEventsInFunction(functionName: string): Event[]; 55 | 56 | mergeResourceArrays(): void; 57 | 58 | validate(): Service; 59 | 60 | update(data: Record): Record; 61 | } 62 | -------------------------------------------------------------------------------- /src/utils/formatting.ts: -------------------------------------------------------------------------------- 1 | export const humanFileSize = (bytes: number): `${number} ${'B' | 'KB' | 'MB' | 'GB' | 'TB'}` => { 2 | const index = Math.floor(Math.log(bytes) / Math.log(1024)); 3 | 4 | return `${(Number((bytes / 1024 ** index).toFixed(2)))} ${(['B', 'KB', 'MB', 'GB', 'TB'] as const)[index]}`; 5 | }; 6 | -------------------------------------------------------------------------------- /src/utils/get-env.ts: -------------------------------------------------------------------------------- 1 | export const getEnvStrict = (envName: string, defaultValue?: string): string => { 2 | const envValue = process.env[envName] || defaultValue; 3 | 4 | if (!envValue) { 5 | throw new Error(`Env variable ${envName} is not defined`); 6 | } 7 | 8 | return envValue; 9 | }; 10 | 11 | export const getEnv = (envName: string): string | undefined => process.env[envName]; 12 | -------------------------------------------------------------------------------- /src/utils/logging.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import * as util from '@serverless/utils/log'; 3 | 4 | export interface ProgressReporter { 5 | update: (message: string) => void; 6 | remove: () => void; 7 | } 8 | 9 | export interface Progress { 10 | create: (options: { 11 | message?: string, 12 | name?: string, 13 | }) => ProgressReporter; 14 | } 15 | 16 | export interface Log { 17 | error: (text: string) => void; 18 | warning: (text: string) => void; 19 | notice: { 20 | skip: (text: string) => void; 21 | success: (text: string) => void; 22 | (text: string): void; 23 | }; 24 | info: (text: string) => void; 25 | debug: (text: string) => void; 26 | verbose: (text: string) => void; 27 | success: (text: string) => void; 28 | } 29 | 30 | const progress = util.progress as Progress; 31 | const log = util.log as Log; 32 | const writeText = util.writeText as (text: string | string[]) => void; 33 | 34 | export { 35 | progress, 36 | log, 37 | writeText, 38 | }; 39 | -------------------------------------------------------------------------------- /src/utils/yc-config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import yaml from 'yaml'; 3 | import fs from 'fs'; 4 | import * as os from 'os'; 5 | import { getEnv } from './get-env'; 6 | import { log } from './logging'; 7 | 8 | interface YcConfigBase { 9 | cloudId: string; 10 | folderId: string; 11 | endpoint?: string; 12 | } 13 | 14 | interface YcConfigOauth extends YcConfigBase { 15 | token: string; 16 | } 17 | 18 | interface YcConfigIam extends YcConfigBase { 19 | iamToken: string; 20 | } 21 | 22 | type YcConfig = YcConfigOauth | YcConfigIam; 23 | 24 | const YC_OAUTH_TOKEN_ENV = 'YC_OAUTH_TOKEN'; 25 | const YC_IAM_TOKEN_ENV = 'YC_IAM_TOKEN'; 26 | const YC_CLOUD_ID = 'YC_CLOUD_ID'; 27 | const YC_FOLDER_ID = 'YC_FOLDER_ID'; 28 | const YC_ENDPOINT = 'YC_ENDPOINT'; 29 | const YC_CONFIG_PATH = path.join(os.homedir(), '.config/yandex-cloud/config.yaml'); 30 | 31 | const readYcConfigFile = () => { 32 | let config; 33 | 34 | try { 35 | config = yaml.parse(fs.readFileSync(YC_CONFIG_PATH, 'utf8')); 36 | } catch (error) { 37 | throw new Error(`Failed to read config ${YC_CONFIG_PATH}: ${error}`); 38 | } 39 | 40 | const { current, profiles } = config; 41 | 42 | if (!current) { 43 | throw new Error(`Invalid config in ${YC_CONFIG_PATH}: no current profile selected`); 44 | } 45 | 46 | if (!profiles[current]) { 47 | throw new Error(`Invalid config in ${YC_CONFIG_PATH}: no profile named ${current} exists`); 48 | } 49 | 50 | return profiles[current]; 51 | }; 52 | 53 | export const getYcConfig = (): YcConfig => { 54 | const oauthTokenFromEnv = getEnv(YC_OAUTH_TOKEN_ENV); 55 | const iamTokenFromEnv = getEnv(YC_IAM_TOKEN_ENV); 56 | const cloudIdFromEnv = getEnv(YC_CLOUD_ID); 57 | const folderIdFromEnv = getEnv(YC_FOLDER_ID); 58 | const endpointFromEnv = getEnv(YC_ENDPOINT); 59 | const isTokenDefined = Boolean(iamTokenFromEnv || oauthTokenFromEnv); 60 | 61 | if (isTokenDefined && cloudIdFromEnv && folderIdFromEnv) { 62 | log.info('Found YC configuration in environment variables, using it'); 63 | 64 | if (iamTokenFromEnv) { 65 | return { 66 | iamToken: iamTokenFromEnv, 67 | cloudId: cloudIdFromEnv, 68 | folderId: folderIdFromEnv, 69 | endpoint: endpointFromEnv, 70 | }; 71 | } 72 | 73 | if (oauthTokenFromEnv) { 74 | return { 75 | token: oauthTokenFromEnv, 76 | cloudId: cloudIdFromEnv, 77 | folderId: folderIdFromEnv, 78 | endpoint: endpointFromEnv, 79 | }; 80 | } 81 | } 82 | 83 | log.info(`YC configuration in environment variables not found, reading yc config file: ${YC_CONFIG_PATH}`); 84 | 85 | const config = readYcConfigFile(); 86 | const { 87 | token, 'cloud-id': cloudId, 'folder-id': folderId, endpoint, 88 | } = config; 89 | 90 | if (!token) { 91 | throw new Error(`Token is not defined in ${YC_CONFIG_PATH}`); 92 | } 93 | 94 | if (!cloudId) { 95 | throw new Error(`Cloud ID is not defined in ${YC_CONFIG_PATH}`); 96 | } 97 | 98 | if (!folderId) { 99 | throw new Error(`Folder ID is not defined in ${YC_CONFIG_PATH}`); 100 | } 101 | 102 | return { 103 | token, 104 | cloudId, 105 | folderId, 106 | endpoint, 107 | }; 108 | }; 109 | -------------------------------------------------------------------------------- /templates/nodejs/.nvmrc: -------------------------------------------------------------------------------- 1 | 16.13.1 2 | -------------------------------------------------------------------------------- /templates/nodejs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serverless-yandex-cloud-template", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "build": "tsc -p .", 6 | "deploy": "npm run build && npx --no-install serverless deploy" 7 | }, 8 | "devDependencies": { 9 | "@yandex-cloud/serverless-plugin": "^1.7.18", 10 | "@yandex-cloud/function-types": "^2.1.1", 11 | "serverless": "^3.38.0", 12 | "typescript": "^4.9.5" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /templates/nodejs/serverless.yml: -------------------------------------------------------------------------------- 1 | service: yandex-cloud-nodejs 2 | frameworkVersion: "3" 3 | 4 | provider: 5 | name: yandex-cloud 6 | runtime: nodejs16 7 | httpApi: 8 | payload: '1.0' 9 | 10 | 11 | plugins: 12 | - "@yandex-cloud/serverless-plugin" 13 | 14 | package: 15 | patterns: 16 | - '!**' 17 | - package.json 18 | - package-lock.json 19 | - dist/*.js 20 | 21 | functions: 22 | simple: 23 | handler: dist/index.hello 24 | memorySize: 128 25 | timeout: '5' 26 | account: function-sa 27 | events: 28 | - http: 29 | method: post 30 | path: /post/just/to/this/path 31 | 32 | timer: 33 | handler: dist/index.hello 34 | memorySize: 128 35 | timeout: '5' 36 | events: 37 | - cron: 38 | expression: "* * * * ? *" 39 | account: trigger-sa 40 | retry: 41 | attempts: 1 42 | interval: 10 43 | 44 | resources: 45 | trigger-sa: 46 | type: yc::ServiceAccount 47 | roles: 48 | - serverless.functions.invoker 49 | function-sa: 50 | type: yc::ServiceAccount 51 | roles: 52 | - editor 53 | -------------------------------------------------------------------------------- /templates/nodejs/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Handler } from '@yandex-cloud/function-types'; 2 | 3 | export const hello: Handler.Http = async (event, context) => { 4 | return { 5 | statusCode: 200, 6 | headers: { 7 | 'Content-Type': 'text/plain', 8 | }, 9 | body: 'Hello world!' 10 | } 11 | } -------------------------------------------------------------------------------- /templates/nodejs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "allowJs": true, 6 | "declaration": false, 7 | "outDir": "./dist", 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "strict": true, 11 | "skipLibCheck": true, 12 | "resolveJsonModule": true, 13 | }, 14 | "include": [ 15 | "./src" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | "./src", 5 | "./config", 6 | "./templates/nodejs" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true, 4 | "target": "es6", 5 | "module": "commonjs", 6 | "allowJs": true, 7 | "declaration": true, 8 | "outDir": "./dist", 9 | "esModuleInterop": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "strict": true, 12 | "skipLibCheck": true, 13 | "resolveJsonModule": true, 14 | "lib": ["es6", "ES2019.Object"], 15 | "typeRoots": [ 16 | "./node_modules/@types", 17 | "./src/types" 18 | ] 19 | }, 20 | "include": [ 21 | "./src" 22 | ] 23 | } 24 | --------------------------------------------------------------------------------