From d4a79e33663256533417cf1d262541a22a6bab79 Mon Sep 17 00:00:00 2001 From: Helio Machado <0x2b3bfa0+git@googlemail.com> Date: Tue, 21 Jun 2022 15:18:15 +0000 Subject: [PATCH] Extract subcommands --- .gitignore | 1 - bin/cml.js | 13 +- bin/cml.test.js | 30 +- bin/cml/attachment.js | 8 + bin/cml/{ => attachment}/publish.js | 8 +- bin/cml/{ => attachment}/publish.test.js | 2 +- bin/cml/check.js | 8 + .../{send-github-check.js => check/create.js} | 8 +- .../create.test.js} | 18 +- bin/cml/pr.js | 69 +-- bin/cml/pr/create.js | 61 ++ bin/cml/{pr.test.js => pr/create.test.js} | 22 +- bin/cml/report.js | 8 + bin/cml/{send-comment.js => report/create.js} | 11 +- .../create.test.js} | 18 +- bin/cml/repository.js | 8 + bin/cml/{ci.js => repository/configure.js} | 12 +- .../configure.test.js} | 15 +- bin/cml/rerun-workflow.js | 20 - bin/cml/runner.js | 556 +----------------- bin/cml/runner/start.js | 548 +++++++++++++++++ .../{runner.test.js => runner/start.test.js} | 22 +- bin/cml/tensorboard.js | 8 + .../start.js} | 11 +- .../start.test.js} | 20 +- bin/cml/workflow.js | 8 + bin/cml/workflow/restart.js | 20 + .../restart.test.js} | 15 +- bin/legacy/ci.js | 6 + bin/legacy/publish.js | 6 + bin/legacy/rerun-workflow.js | 6 + bin/legacy/send-comment.js | 6 + bin/legacy/send-github-check.js | 6 + bin/legacy/tensorboard-dev.js | 6 + 34 files changed, 839 insertions(+), 745 deletions(-) create mode 100644 bin/cml/attachment.js rename bin/cml/{ => attachment}/publish.js (87%) rename bin/cml/{ => attachment}/publish.test.js (98%) create mode 100644 bin/cml/check.js rename bin/cml/{send-github-check.js => check/create.js} (84%) rename bin/cml/{send-github-check.test.js => check/create.test.js} (78%) mode change 100755 => 100644 bin/cml/pr.js create mode 100755 bin/cml/pr/create.js rename bin/cml/{pr.test.js => pr/create.test.js} (70%) create mode 100644 bin/cml/report.js rename bin/cml/{send-comment.js => report/create.js} (77%) rename bin/cml/{send-comment.test.js => report/create.test.js} (77%) create mode 100644 bin/cml/repository.js rename bin/cml/{ci.js => repository/configure.js} (62%) rename bin/cml/{ci.test.js => repository/configure.test.js} (67%) delete mode 100644 bin/cml/rerun-workflow.js mode change 100755 => 100644 bin/cml/runner.js create mode 100755 bin/cml/runner/start.js rename bin/cml/{runner.test.js => runner/start.test.js} (94%) create mode 100644 bin/cml/tensorboard.js rename bin/cml/{tensorboard-dev.js => tensorboard/start.js} (92%) rename bin/cml/{tensorboard-dev.test.js => tensorboard/start.test.js} (85%) create mode 100644 bin/cml/workflow.js create mode 100644 bin/cml/workflow/restart.js rename bin/cml/{rerun-workflow.test.js => workflow/restart.test.js} (62%) create mode 100644 bin/legacy/ci.js create mode 100644 bin/legacy/publish.js create mode 100644 bin/legacy/rerun-workflow.js create mode 100644 bin/legacy/send-comment.js create mode 100644 bin/legacy/send-github-check.js create mode 100644 bin/legacy/tensorboard-dev.js diff --git a/.gitignore b/.gitignore index e57c16df42..1052816e01 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ node_modules/ -runner/ .terraform/ .cml/ .DS_Store diff --git a/bin/cml.js b/bin/cml.js index c201f2930a..d824391310 100755 --- a/bin/cml.js +++ b/bin/cml.js @@ -51,7 +51,7 @@ const handleError = (message, error) => { process.exit(1); }; -const options = { +exports.options = { log: { type: 'string', description: 'Maximum log level', @@ -63,17 +63,17 @@ const options = { type: 'string', choices: ['github', 'gitlab', 'bitbucket'], description: - 'Platform where the repository is hosted. If not specified, it will be inferred from the environment' + 'Forge where the repository is hosted. If not specified, it will be inferred from the environment' }, repo: { type: 'string', description: - 'Repository to be used for registering the runner. If not specified, it will be inferred from the environment' + 'Repository. If not specified, it will be inferred from the environment' }, token: { type: 'string', description: - 'Personal access token to register a self-hosted runner on the repository. If not specified, it will be inferred from the environment' + 'Personal access token. If not specified, it will be inferred from the environment' } }; @@ -97,8 +97,9 @@ for (const [oldName, newName] of Object.entries(legacyEnvironmentVariables)) { yargs .fail(handleError) .env('CML') - .options(options) - .commandDir('./cml', { exclude: /\.test\.js$/ }) + .options(exports.options) + .commandDir('./cml') + .commandDir('./legacy') .command('$0 ', false, (builder) => builder.strict(false), runPlugin) .recommendCommands() .demandCommand() diff --git a/bin/cml.test.js b/bin/cml.test.js index 2b3007d5bb..265e4766f2 100644 --- a/bin/cml.test.js +++ b/bin/cml.test.js @@ -8,30 +8,26 @@ describe('command-line interface tests', () => { "cml.js Commands: - cml.js ci Fixes specific CI setups - cml.js pr Create a pull request with the - specified files - cml.js rerun-workflow Reruns a workflow given the jobId or - workflow Id - cml.js runner Launch and register a self-hosted - runner - cml.js send-comment Comment on a commit - cml.js send-github-check Create a check report - cml.js tensorboard-dev Get a tensorboard link + cml.js check Manage continuous integration checks + cml.js pr Manage pull requests + cml.js report Manage reports + cml.js repository Manage repository settings + cml.js runner Manage self-hosted continuous integration runners + cml.js tensorboard Manage tensorboard.dev agents + cml.js workflow Manage continuous integration workflows Options: --help Show help [boolean] --version Show version number [boolean] --log Maximum log level [string] [choices: \\"error\\", \\"warn\\", \\"info\\", \\"debug\\"] [default: \\"info\\"] - --driver Platform where the repository is hosted. If not specified, it will - be inferred from the environment + --driver Forge where the repository is hosted. If not specified, it will be + inferred from the environment [string] [choices: \\"github\\", \\"gitlab\\", \\"bitbucket\\"] - --repo Repository to be used for registering the runner. If not specified, - it will be inferred from the environment [string] - --token Personal access token to register a self-hosted runner on the - repository. If not specified, it will be inferred from the - environment [string]" + --repo Repository. If not specified, it will be inferred from the + environment [string] + --token Personal access token. If not specified, it will be inferred from + the environment [string]" `); }); }); diff --git a/bin/cml/attachment.js b/bin/cml/attachment.js new file mode 100644 index 0000000000..a68ce0616c --- /dev/null +++ b/bin/cml/attachment.js @@ -0,0 +1,8 @@ +exports.command = 'attachment'; +exports.description = false; +exports.builder = (yargs) => + yargs + .commandDir('./attachment', { exclude: /\.test\.js$/ }) + .recommendCommands() + .demandCommand() + .strict(); diff --git a/bin/cml/publish.js b/bin/cml/attachment/publish.js similarity index 87% rename from bin/cml/publish.js rename to bin/cml/attachment/publish.js index 4012ea3bdb..d59796b559 100644 --- a/bin/cml/publish.js +++ b/bin/cml/attachment/publish.js @@ -2,10 +2,10 @@ const fs = require('fs').promises; const kebabcaseKeys = require('kebabcase-keys'); const winston = require('winston'); -const CML = require('../../src/cml').default; +const CML = require('../../../src/cml').default; exports.command = 'publish '; -exports.description = false; +exports.description = 'publish an asset'; exports.handler = async (opts) => { if (opts.gitlabUploads) { @@ -29,9 +29,9 @@ exports.handler = async (opts) => { else await fs.writeFile(file, output); }; -exports.builder = (yargs) => yargs.env('CML_PUBLISH').options(options); +exports.builder = (yargs) => yargs.env('CML_PUBLISH').options(exports.options); -const options = kebabcaseKeys({ +exports.options = kebabcaseKeys({ md: { type: 'boolean', description: 'Output in markdown format [title || name](url).' diff --git a/bin/cml/publish.test.js b/bin/cml/attachment/publish.test.js similarity index 98% rename from bin/cml/publish.test.js rename to bin/cml/attachment/publish.test.js index e3d7de4fa3..3914ae7c43 100644 --- a/bin/cml/publish.test.js +++ b/bin/cml/attachment/publish.test.js @@ -1,5 +1,5 @@ const fs = require('fs'); -const { exec } = require('../../src/utils'); +const { exec } = require('../../../src/utils'); describe('CML e2e', () => { test('cml publish --help', async () => { diff --git a/bin/cml/check.js b/bin/cml/check.js new file mode 100644 index 0000000000..985e4041b3 --- /dev/null +++ b/bin/cml/check.js @@ -0,0 +1,8 @@ +exports.command = 'check'; +exports.description = 'Manage continuous integration checks'; +exports.builder = (yargs) => + yargs + .commandDir('./check', { exclude: /\.test\.js$/ }) + .recommendCommands() + .demandCommand() + .strict(); diff --git a/bin/cml/send-github-check.js b/bin/cml/check/create.js similarity index 84% rename from bin/cml/send-github-check.js rename to bin/cml/check/create.js index 0b8ff271a5..9db3e4074b 100755 --- a/bin/cml/send-github-check.js +++ b/bin/cml/check/create.js @@ -1,8 +1,8 @@ const fs = require('fs').promises; const kebabcaseKeys = require('kebabcase-keys'); -const CML = require('../../src/cml').default; +const CML = require('../../../src/cml').default; -exports.command = 'send-github-check '; +exports.command = 'create '; exports.description = 'Create a check report'; exports.handler = async (opts) => { @@ -13,9 +13,9 @@ exports.handler = async (opts) => { }; exports.builder = (yargs) => - yargs.env('CML_SEND_GITHUB_CHECK').options(options); + yargs.env('CML_SEND_GITHUB_CHECK').options(exports.options); -const options = kebabcaseKeys({ +exports.options = kebabcaseKeys({ commitSha: { type: 'string', alias: 'head-sha', diff --git a/bin/cml/send-github-check.test.js b/bin/cml/check/create.test.js similarity index 78% rename from bin/cml/send-github-check.test.js rename to bin/cml/check/create.test.js index 3f1aa3a800..4280b7b6f0 100644 --- a/bin/cml/send-github-check.test.js +++ b/bin/cml/check/create.test.js @@ -1,4 +1,4 @@ -const { exec } = require('../../src/utils'); +const { exec } = require('../../../src/utils'); const fs = require('fs').promises; describe('CML e2e', () => { @@ -34,24 +34,20 @@ describe('CML e2e', () => { const output = await exec(`node ./bin/cml.js send-github-check --help`); expect(output).toMatchInlineSnapshot(` - "cml.js send-github-check - - Create a check report + "cml.js send-github-check Options: --help Show help [boolean] --version Show version number [boolean] --log Maximum log level [string] [choices: \\"error\\", \\"warn\\", \\"info\\", \\"debug\\"] [default: \\"info\\"] - --driver Platform where the repository is hosted. If not + --driver Forge where the repository is hosted. If not specified, it will be inferred from the environment [string] [choices: \\"github\\", \\"gitlab\\", \\"bitbucket\\"] - --repo Repository to be used for registering the runner. If - not specified, it will be inferred from the - environment [string] - --token Personal access token to register a self-hosted - runner on the repository. If not specified, it will - be inferred from the environment [string] + --repo Repository. If not specified, it will be inferred + from the environment [string] + --token Personal access token. If not specified, it will be + inferred from the environment [string] --commit-sha, --head-sha Commit SHA linked to this comment. Defaults to HEAD. [string] --conclusion Sets the conclusion status of the check. diff --git a/bin/cml/pr.js b/bin/cml/pr.js old mode 100755 new mode 100644 index 0f90901fd1..c7c543cb6a --- a/bin/cml/pr.js +++ b/bin/cml/pr.js @@ -1,57 +1,12 @@ -const kebabcaseKeys = require('kebabcase-keys'); - -const { GIT_REMOTE, GIT_USER_NAME, GIT_USER_EMAIL } = require('../../src/cml'); -const CML = require('../../src/cml').default; - -exports.command = 'pr '; -exports.description = 'Create a pull request with the specified files'; - -exports.handler = async (opts) => { - const cml = new CML(opts); - const link = await cml.prCreate({ ...opts, globs: opts.globpath }); - if (link) console.log(link); -}; - -exports.builder = (yargs) => yargs.env('CML_PR').options(options); - -const options = kebabcaseKeys({ - md: { - type: 'boolean', - description: 'Output in markdown format [](url).' - }, - skipCI: { - type: 'boolean', - description: 'Force skip CI for the created commit (if any)' - }, - merge: { - type: 'boolean', - alias: 'auto-merge', - conflicts: ['rebase', 'squash'], - description: 'Try to merge the pull request upon creation.' - }, - rebase: { - type: 'boolean', - conflicts: ['merge', 'squash'], - description: 'Try to rebase-merge the pull request upon creation.' - }, - squash: { - type: 'boolean', - conflicts: ['merge', 'rebase'], - description: 'Try to squash-merge the pull request upon creation.' - }, - remote: { - type: 'string', - default: GIT_REMOTE, - description: 'Sets git remote.' - }, - userEmail: { - type: 'string', - default: GIT_USER_EMAIL, - description: 'Sets git user email.' - }, - userName: { - type: 'string', - default: GIT_USER_NAME, - description: 'Sets git user name.' - } -}); +const { options, handler } = require('./pr/create'); + +exports.command = 'pr'; +exports.description = 'Manage pull requests'; +exports.handler = handler; +exports.builder = (yargs) => + yargs + .commandDir('./pr', { exclude: /\.test\.js$/ }) + .recommendCommands() + .env('CML_PR') + .options(options) + .strict(); diff --git a/bin/cml/pr/create.js b/bin/cml/pr/create.js new file mode 100755 index 0000000000..e963a15be5 --- /dev/null +++ b/bin/cml/pr/create.js @@ -0,0 +1,61 @@ +const kebabcaseKeys = require('kebabcase-keys'); + +const { + GIT_REMOTE, + GIT_USER_NAME, + GIT_USER_EMAIL +} = require('../../../src/cml'); +const CML = require('../../../src/cml').default; + +exports.command = 'create '; +exports.description = 'Create a pull request with the specified files'; + +exports.handler = async (opts) => { + const cml = new CML(opts); + const link = await cml.prCreate({ ...opts, globs: opts.globpath }); + if (link) console.log(link); +}; + +exports.builder = (yargs) => yargs.env('CML_PR').options(exports.options); + +exports.options = kebabcaseKeys({ + md: { + type: 'boolean', + description: 'Output in markdown format [](url).' + }, + skipCI: { + type: 'boolean', + description: 'Force skip CI for the created commit (if any)' + }, + merge: { + type: 'boolean', + alias: 'auto-merge', + conflicts: ['rebase', 'squash'], + description: 'Try to merge the pull request upon creation.' + }, + rebase: { + type: 'boolean', + conflicts: ['merge', 'squash'], + description: 'Try to rebase-merge the pull request upon creation.' + }, + squash: { + type: 'boolean', + conflicts: ['merge', 'rebase'], + description: 'Try to squash-merge the pull request upon creation.' + }, + remote: { + type: 'string', + default: GIT_REMOTE, + description: 'Sets git remote.' + }, + userEmail: { + type: 'string', + default: GIT_USER_EMAIL, + description: 'Sets git user email.' + }, + userName: { + type: 'string', + default: GIT_USER_NAME, + description: 'Sets git user name.' + } +}); diff --git a/bin/cml/pr.test.js b/bin/cml/pr/create.test.js similarity index 70% rename from bin/cml/pr.test.js rename to bin/cml/pr/create.test.js index eeabb6cc25..4b1cc25473 100644 --- a/bin/cml/pr.test.js +++ b/bin/cml/pr/create.test.js @@ -1,27 +1,29 @@ -const { exec } = require('../../src/utils'); +const { exec } = require('../../../src/utils'); describe('CML e2e', () => { test('cml-pr --help', async () => { const output = await exec(`echo none | node ./bin/cml.js pr --help`); expect(output).toMatchInlineSnapshot(` - "cml.js pr + "cml.js pr - Create a pull request with the specified files + Manage pull requests + + Commands: + cml.js pr create Create a pull request with the specified + files Options: --help Show help [boolean] --version Show version number [boolean] --log Maximum log level [string] [choices: \\"error\\", \\"warn\\", \\"info\\", \\"debug\\"] [default: \\"info\\"] - --driver Platform where the repository is hosted. If not - specified, it will be inferred from the environment + --driver Forge where the repository is hosted. If not specified, + it will be inferred from the environment [string] [choices: \\"github\\", \\"gitlab\\", \\"bitbucket\\"] - --repo Repository to be used for registering the runner. If - not specified, it will be inferred from the environment - [string] - --token Personal access token to register a self-hosted runner - on the repository. If not specified, it will be + --repo Repository. If not specified, it will be inferred from + the environment [string] + --token Personal access token. If not specified, it will be inferred from the environment [string] --md Output in markdown format [](url). [boolean] --skip-ci Force skip CI for the created commit (if any) [boolean] diff --git a/bin/cml/report.js b/bin/cml/report.js new file mode 100644 index 0000000000..e14ca8ac1b --- /dev/null +++ b/bin/cml/report.js @@ -0,0 +1,8 @@ +exports.command = 'report'; +exports.description = 'Manage reports'; +exports.builder = (yargs) => + yargs + .commandDir('./report', { exclude: /\.test\.js$/ }) + .recommendCommands() + .demandCommand() + .strict(); diff --git a/bin/cml/send-comment.js b/bin/cml/report/create.js similarity index 77% rename from bin/cml/send-comment.js rename to bin/cml/report/create.js index f23002be37..eb438421ae 100644 --- a/bin/cml/send-comment.js +++ b/bin/cml/report/create.js @@ -1,10 +1,10 @@ const fs = require('fs').promises; const kebabcaseKeys = require('kebabcase-keys'); -const CML = require('../../src/cml').default; +const CML = require('../../../src/cml').default; -exports.command = 'send-comment '; -exports.description = 'Comment on a commit'; +exports.command = 'create '; +exports.description = 'Create a report'; exports.handler = async (opts) => { const path = opts.markdownfile; @@ -13,9 +13,10 @@ exports.handler = async (opts) => { console.log(await cml.commentCreate({ ...opts, report })); }; -exports.builder = (yargs) => yargs.env('CML_SEND_COMMENT').options(options); +exports.builder = (yargs) => + yargs.env('CML_SEND_COMMENT').options(exports.options); -const options = kebabcaseKeys({ +exports.options = kebabcaseKeys({ pr: { type: 'boolean', description: diff --git a/bin/cml/send-comment.test.js b/bin/cml/report/create.test.js similarity index 77% rename from bin/cml/send-comment.test.js rename to bin/cml/report/create.test.js index 5d6f58a748..81e403eac3 100644 --- a/bin/cml/send-comment.test.js +++ b/bin/cml/report/create.test.js @@ -1,4 +1,4 @@ -const { exec } = require('../../src/utils'); +const { exec } = require('../../../src/utils'); const fs = require('fs').promises; describe('Comment integration tests', () => { @@ -14,24 +14,20 @@ describe('Comment integration tests', () => { const output = await exec(`node ./bin/cml.js send-comment --help`); expect(output).toMatchInlineSnapshot(` - "cml.js send-comment - - Comment on a commit + "cml.js send-comment Options: --help Show help [boolean] --version Show version number [boolean] --log Maximum log level [string] [choices: \\"error\\", \\"warn\\", \\"info\\", \\"debug\\"] [default: \\"info\\"] - --driver Platform where the repository is hosted. If not + --driver Forge where the repository is hosted. If not specified, it will be inferred from the environment [string] [choices: \\"github\\", \\"gitlab\\", \\"bitbucket\\"] - --repo Repository to be used for registering the runner. If - not specified, it will be inferred from the - environment [string] - --token Personal access token to register a self-hosted - runner on the repository. If not specified, it will - be inferred from the environment [string] + --repo Repository. If not specified, it will be inferred + from the environment [string] + --token Personal access token. If not specified, it will be + inferred from the environment [string] --pr Post to an existing PR/MR associated with the specified commit [boolean] --commit-sha, --head-sha Commit SHA linked to this comment diff --git a/bin/cml/repository.js b/bin/cml/repository.js new file mode 100644 index 0000000000..08a1206454 --- /dev/null +++ b/bin/cml/repository.js @@ -0,0 +1,8 @@ +exports.command = 'repository'; +exports.description = 'Manage repository settings'; +exports.builder = (yargs) => + yargs + .commandDir('./repository', { exclude: /\.test\.js$/ }) + .recommendCommands() + .demandCommand() + .strict(); diff --git a/bin/cml/ci.js b/bin/cml/repository/configure.js similarity index 62% rename from bin/cml/ci.js rename to bin/cml/repository/configure.js index fb758177cf..3d9c55dfc9 100644 --- a/bin/cml/ci.js +++ b/bin/cml/repository/configure.js @@ -1,19 +1,19 @@ const kebabcaseKeys = require('kebabcase-keys'); -const { GIT_USER_NAME, GIT_USER_EMAIL } = require('../../src/cml'); -const CML = require('../../src/cml').default; +const { GIT_USER_NAME, GIT_USER_EMAIL } = require('../../../src/cml'); +const CML = require('../../../src/cml').default; -exports.command = 'ci'; -exports.description = 'Fixes specific CI setups'; +exports.command = 'configure'; +exports.description = 'Configure the cloned repository'; exports.handler = async (opts) => { const cml = new CML(opts); console.log((await cml.ci(opts)) || ''); }; -exports.builder = (yargs) => yargs.env('CML_CI').options(options); +exports.builder = (yargs) => yargs.env('CML_CI').options(exports.options); -const options = kebabcaseKeys({ +exports.options = kebabcaseKeys({ unshallow: { type: 'boolean', description: diff --git a/bin/cml/ci.test.js b/bin/cml/repository/configure.test.js similarity index 67% rename from bin/cml/ci.test.js rename to bin/cml/repository/configure.test.js index 055bce937e..ca43381cae 100644 --- a/bin/cml/ci.test.js +++ b/bin/cml/repository/configure.test.js @@ -1,4 +1,4 @@ -const { exec } = require('../../src/utils'); +const { exec } = require('../../../src/utils'); describe('CML e2e', () => { test('cml-ci --help', async () => { @@ -7,21 +7,18 @@ describe('CML e2e', () => { expect(output).toMatchInlineSnapshot(` "cml.js ci - Fixes specific CI setups - Options: --help Show help [boolean] --version Show version number [boolean] --log Maximum log level [string] [choices: \\"error\\", \\"warn\\", \\"info\\", \\"debug\\"] [default: \\"info\\"] - --driver Platform where the repository is hosted. If not specified, it - will be inferred from the environment + --driver Forge where the repository is hosted. If not specified, it will + be inferred from the environment [string] [choices: \\"github\\", \\"gitlab\\", \\"bitbucket\\"] - --repo Repository to be used for registering the runner. If not - specified, it will be inferred from the environment [string] - --token Personal access token to register a self-hosted runner on the - repository. If not specified, it will be inferred from the + --repo Repository. If not specified, it will be inferred from the environment [string] + --token Personal access token. If not specified, it will be inferred + from the environment [string] --unshallow Fetch as much as possible, converting a shallow repository to a complete one. [boolean] --user-email Set Git user email. [string] [default: \\"olivaw@iterative.ai\\"] diff --git a/bin/cml/rerun-workflow.js b/bin/cml/rerun-workflow.js deleted file mode 100644 index 54470fa6b4..0000000000 --- a/bin/cml/rerun-workflow.js +++ /dev/null @@ -1,20 +0,0 @@ -const kebabcaseKeys = require('kebabcase-keys'); - -const CML = require('../../src/cml').default; - -exports.command = 'rerun-workflow'; -exports.description = 'Reruns a workflow given the jobId or workflow Id'; - -exports.handler = async (opts) => { - const cml = new CML(opts); - await cml.pipelineRerun(opts); -}; - -exports.builder = (yargs) => yargs.env('CML_CI').options(options); - -const options = kebabcaseKeys({ - id: { - type: 'string', - description: 'Specifies the run Id to be rerun.' - } -}); diff --git a/bin/cml/runner.js b/bin/cml/runner.js old mode 100755 new mode 100644 index 6ba4bfea14..fe862ee5ea --- a/bin/cml/runner.js +++ b/bin/cml/runner.js @@ -1,548 +1,12 @@ -const { join } = require('path'); -const { homedir } = require('os'); -const fs = require('fs').promises; -const { SpotNotifier } = require('ec2-spot-notification'); -const kebabcaseKeys = require('kebabcase-keys'); -const timestring = require('timestring'); -const winston = require('winston'); -const CML = require('../../src/cml').default; -const { randid, sleep } = require('../../src/utils'); -const tf = require('../../src/terraform'); - -let cml; -let RUNNER; -let RUNNER_JOBS_RUNNING = []; -let RUNNER_SHUTTING_DOWN = false; -let RUNNER_TIMER = 0; -const GH_5_MIN_TIMEOUT = (72 * 60 - 5) * 60 * 1000; - -const shutdown = async (opts) => { - if (RUNNER_SHUTTING_DOWN) return; - RUNNER_SHUTTING_DOWN = true; - - const { error, cloud } = opts; - const { - name, - workdir = '', - tfResource, - noRetry, - reason, - destroyDelay - } = opts; - const tfPath = workdir; - - const unregisterRunner = async () => { - if (!RUNNER) return; - - try { - winston.info(`Unregistering runner ${name}...`); - await cml.unregisterRunner({ name }); - RUNNER && RUNNER.kill('SIGINT'); - winston.info('\tSuccess'); - } catch (err) { - winston.error(`\tFailed: ${err.message}`); - } - }; - - const retryWorkflows = async () => { - try { - if (!noRetry) { - if (RUNNER_JOBS_RUNNING.length > 0) { - await Promise.all( - RUNNER_JOBS_RUNNING.map( - async (job) => await cml.pipelineRestart({ jobId: job.id }) - ) - ); - } - } - } catch (err) { - winston.error(err); - } - }; - - const destroyTerraform = async () => { - if (!tfResource) return; - - winston.info(`Waiting ${destroyDelay} seconds to destroy`); - await sleep(destroyDelay); - - try { - winston.debug(await tf.destroy({ dir: tfPath })); - } catch (err) { - winston.error(`\tFailed destroying terraform: ${err.message}`); - } - }; - - if (error) { - winston.error(error, { status: 'terminated' }); - } else { - winston.info('runner status', { reason, status: 'terminated' }); - } - - if (!cloud) { - try { - await unregisterRunner(); - await retryWorkflows(); - } catch (err) { - winston.error(`Error connecting the SCM: ${err.message}`); - } - } - - await destroyTerraform(); - - process.exit(error ? 1 : 0); -}; - -const runCloud = async (opts) => { - const runTerraform = async (opts) => { - winston.info('Terraform apply...'); - - const { token, repo, driver } = cml; - const { - tpiVersion, - labels, - idleTimeout, - name, - cmlVersion, - single, - dockerVolumes, - cloud, - cloudRegion: region, - cloudType: type, - cloudPermissionSet: permissionSet, - cloudMetadata: metadata, - cloudGpu: gpu, - cloudHddSize: hddSize, - cloudSshPrivate: sshPrivate, - cloudSpot: spot, - cloudSpotPrice: spotPrice, - cloudStartupScript: startupScript, - cloudAwsSecurityGroup: awsSecurityGroup, - cloudAwsSubnet: awsSubnet, - workdir - } = opts; - - await tf.checkMinVersion(); - - if (gpu === 'tesla') - winston.warn( - 'GPU model "tesla" has been deprecated; please use "v100" instead.' - ); - - const tfPath = workdir; - const tfMainPath = join(tfPath, 'main.tf'); - - const tpl = tf.iterativeCmlRunnerTpl({ - tpiVersion, - repo, - token, - driver, - labels, - cmlVersion, - idleTimeout, - name, - single, - cloud, - region, - type, - permissionSet, - metadata, - gpu: gpu === 'tesla' ? 'v100' : gpu, - hddSize, - sshPrivate, - spot, - spotPrice, - startupScript, - awsSecurityGroup, - awsSubnet, - dockerVolumes - }); - - await fs.writeFile(tfMainPath, tpl); - - await tf.init({ dir: tfPath }); - await tf.apply({ dir: tfPath }); - - const tfStatePath = join(tfPath, 'terraform.tfstate'); - const tfstate = await tf.loadTfState({ path: tfStatePath }); - - return tfstate; - }; - - winston.info('Deploying cloud runner plan...'); - const tfstate = await runTerraform(opts); - const { resources } = tfstate; - for (const resource of resources) { - if (resource.type.startsWith('iterative_')) { - for (const { attributes } of resource.instances) { - const nonSensitiveValues = { - awsSecurityGroup: attributes.aws_security_group, - awsSubnetId: attributes.aws_subnet_id, - cloud: attributes.cloud, - driver: attributes.driver, - id: attributes.id, - idleTimeout: attributes.idle_timeout, - image: attributes.image, - instanceGpu: attributes.instance_gpu, - instanceHddSize: attributes.instance_hdd_size, - instanceIp: attributes.instance_ip, - instanceLaunchTime: attributes.instance_launch_time, - instanceType: attributes.instance_type, - instancePermissionSet: attributes.instance_permission_set, - labels: attributes.labels, - cmlVersion: attributes.cml_version, - metadata: attributes.metadata, - name: attributes.name, - region: attributes.region, - repo: attributes.repo, - single: attributes.single, - spot: attributes.spot, - spotPrice: attributes.spot_price, - timeouts: attributes.timeouts - }; - winston.info(JSON.stringify(nonSensitiveValues)); - } - } - } -}; - -const runLocal = async (opts) => { - winston.info(`Launching ${cml.driver} runner`); - const { - workdir, - name, - labels, - single, - idleTimeout, - noRetry, - dockerVolumes, - tfResource, - tpiVersion - } = opts; - - if (tfResource) { - await tf.checkMinVersion(); - - const tfPath = workdir; - await fs.mkdir(tfPath, { recursive: true }); - const tfMainPath = join(tfPath, 'main.tf'); - const tpl = tf.iterativeProviderTpl({ tpiVersion }); - await fs.writeFile(tfMainPath, tpl); - - await tf.init({ dir: tfPath }); - await tf.apply({ dir: tfPath }); - - const path = join(tfPath, 'terraform.tfstate'); - const tfstate = await tf.loadTfState({ path }); - tfstate.resources = [ - JSON.parse(Buffer.from(tfResource, 'base64').toString('utf-8')) - ]; - await tf.saveTfState({ tfstate, path }); - } - - const dataHandler = async (data) => { - const logs = await cml.parseRunnerLog({ data }); - for (const log of logs) { - winston.info('runner status', log); - - if (log.status === 'job_started') { - RUNNER_JOBS_RUNNING.push({ id: log.job, date: log.date }); - } - - if (log.status === 'job_ended') { - const { job: jobId } = log; - RUNNER_JOBS_RUNNING = RUNNER_JOBS_RUNNING.filter( - (job) => job.id !== jobId - ); - - if (single) await shutdown({ ...opts, reason: 'single job' }); - } - } - }; - - const proc = await cml.startRunner({ - workdir, - name, - labels, - single, - idleTimeout, - dockerVolumes - }); - - proc.stderr.on('data', dataHandler); - proc.stdout.on('data', dataHandler); - proc.on('disconnect', () => - shutdown({ ...opts, error: new Error('runner proccess lost') }) - ); - proc.on('close', (exit) => { - const reason = `runner closed with exit code ${exit}`; - if (exit === 0) shutdown({ ...opts, reason }); - else shutdown({ ...opts, error: new Error(reason) }); - }); - - RUNNER = proc; - if (idleTimeout > 0) { - const watcher = setInterval(async () => { - const idle = RUNNER_JOBS_RUNNING.length === 0; - - if (RUNNER_TIMER >= idleTimeout) { - shutdown({ ...opts, reason: `timeout:${idleTimeout}` }); - clearInterval(watcher); - } - - RUNNER_TIMER = idle ? RUNNER_TIMER + 1 : 0; - }, 1000); - } - - if (!noRetry) { - try { - winston.info(`EC2 id ${await SpotNotifier.instanceId()}`); - SpotNotifier.on('termination', () => - shutdown({ ...opts, reason: 'spot_termination' }) - ); - SpotNotifier.start(); - } catch (err) { - winston.warn('SpotNotifier can not be started.'); - } - - if (cml.driver === 'github') { - const watcherSeventyTwo = setInterval(() => { - RUNNER_JOBS_RUNNING.forEach((job) => { - if ( - new Date().getTime() - new Date(job.date).getTime() > - GH_5_MIN_TIMEOUT - ) { - shutdown({ ...opts, reason: 'timeout:72h' }); - clearInterval(watcherSeventyTwo); - } - }); - }, 60 * 1000); - } - } -}; - -const run = async (opts) => { - process.on('unhandledRejection', (reason) => - shutdown({ ...opts, error: new Error(reason) }) - ); - process.on('uncaughtException', (error) => shutdown({ ...opts, error })); - - ['SIGTERM', 'SIGINT', 'SIGQUIT'].forEach((signal) => { - process.on(signal, () => shutdown({ ...opts, reason: signal })); - }); - - opts.workdir = opts.workdir || `${homedir()}/.cml/${opts.name}`; - const { - driver, - repo, - token, - workdir, - cloud, - labels, - name, - reuse, - dockerVolumes - } = opts; - - cml = new CML({ driver, repo, token }); - - await cml.repoTokenCheck(); - - if (dockerVolumes.length && cml.driver !== 'gitlab') - winston.warn('Parameters --docker-volumes is only supported in gitlab'); - - const runners = await cml.runners(); - - const runner = await cml.runnerByName({ name, runners }); - if (runner) { - if (!reuse) - throw new Error( - `Runner name ${name} is already in use. Please change the name or terminate the existing runner.` - ); - winston.info(`Reusing existing runner named ${name}...`); - process.exit(0); - } - - if ( - reuse && - (await cml.runnersByLabels({ labels, runners })).find( - (runner) => runner.online - ) - ) { - winston.info( - `Reusing existing online runners with the ${labels} labels...` - ); - process.exit(0); - } - - winston.info(`Preparing workdir ${workdir}...`); - await fs.mkdir(workdir, { recursive: true }); - await fs.chmod(workdir, '766'); - - if (cloud) await runCloud(opts); - else await runLocal(opts); -}; +const { options, handler } = require('./runner/start'); exports.command = 'runner'; -exports.description = 'Launch and register a self-hosted runner'; - -exports.handler = async (opts) => { - if (process.env.RUNNER_NAME) { - winston.warn( - 'ignoring RUNNER_NAME environment variable, use CML_RUNNER_NAME or --name instead' - ); - } - try { - await run(opts); - } catch (error) { - await shutdown({ ...opts, error }); - } -}; - -exports.builder = (yargs) => yargs.env('CML_RUNNER').options(options); - -const options = kebabcaseKeys({ - labels: { - type: 'string', - default: 'cml', - description: - 'One or more user-defined labels for this runner (delimited with commas)' - }, - idleTimeout: { - type: 'string', - default: '5 minutes', - coerce: (val) => (/^-?\d+$/.test(val) ? parseInt(val) : timestring(val)), - description: - 'Time to wait for jobs before shutting down (e.g. "5min"). Use "never" to disable' - }, - name: { - type: 'string', - default: `cml-${randid()}`, - defaultDescription: 'cml-{ID}', - description: 'Name displayed in the repository once registered' - }, - noRetry: { - type: 'boolean', - description: - 'Do not restart workflow terminated due to instance disposal or GitHub Actions timeout' - }, - single: { - type: 'boolean', - description: 'Exit after running a single job' - }, - reuse: { - type: 'boolean', - description: - "Don't launch a new runner if an existing one has the same name or overlapping labels" - }, - workdir: { - type: 'string', - hidden: true, - alias: 'path', - description: 'Runner working directory' - }, - dockerVolumes: { - type: 'array', - default: [], - description: 'Docker volumes. This feature is only supported in GitLab' - }, - cloud: { - type: 'string', - choices: ['aws', 'azure', 'gcp', 'kubernetes'], - description: 'Cloud to deploy the runner' - }, - cloudRegion: { - type: 'string', - default: 'us-west', - description: - 'Region where the instance is deployed. Choices: [us-east, us-west, eu-west, eu-north]. Also accepts native cloud regions' - }, - cloudType: { - type: 'string', - description: - 'Instance type. Choices: [m, l, xl]. Also supports native types like i.e. t2.micro' - }, - cloudPermissionSet: { - type: 'string', - default: '', - description: - 'Specifies the instance profile in AWS or instance service account in GCP' - }, - cloudMetadata: { - type: 'array', - string: true, - default: [], - coerce: (items) => { - const keyValuePairs = items.map((item) => [...item.split(/=(.+)/), null]); - return Object.fromEntries(keyValuePairs); - }, - description: - 'Key Value pairs to associate cml-runner instance on the provider i.e. tags/labels "key=value"' - }, - cloudGpu: { - type: 'string', - description: - 'GPU type. Choices: k80, v100, or native types e.g. nvidia-tesla-t4', - coerce: (val) => (val === 'nogpu' ? undefined : val) - }, - cloudHddSize: { - type: 'number', - description: 'HDD size in GB' - }, - cloudSshPrivate: { - type: 'string', - coerce: (val) => val && val.replace(/\n/g, '\\n'), - description: - 'Custom private RSA SSH key. If not provided an automatically generated throwaway key will be used' - }, - cloudSpot: { - type: 'boolean', - description: 'Request a spot instance' - }, - cloudSpotPrice: { - type: 'number', - default: -1, - description: - 'Maximum spot instance bidding price in USD. Defaults to the current spot bidding price' - }, - cloudStartupScript: { - type: 'string', - description: - 'Run the provided Base64-encoded Linux shell script during the instance initialization' - }, - cloudAwsSecurityGroup: { - type: 'string', - default: '', - description: 'Specifies the security group in AWS' - }, - cloudAwsSubnet: { - type: 'string', - default: '', - description: 'Specifies the subnet to use within AWS', - alias: 'cloud-aws-subnet-id' - }, - tpiVersion: { - type: 'string', - default: '>= 0.9.10', - description: - 'Pin the iterative/iterative terraform provider to a specific version. i.e. "= 0.10.4" See: https://www.terraform.io/language/expressions/version-constraints', - hidden: true - }, - cmlVersion: { - type: 'string', - default: require('../../package.json').version, - description: 'CML version to load on TPI instance', - hidden: true - }, - tfResource: { - hidden: true, - alias: 'tf_resource' - }, - destroyDelay: { - type: 'number', - default: 10, - hidden: true, - description: - 'Seconds to wait for collecting logs on failure (https://github.com/iterative/cml/issues/413)' - } -}); +exports.description = 'Manage continuous integration self-hosted runners'; +exports.handler = handler; +exports.builder = (yargs) => + yargs + .commandDir('./runner', { exclude: /\.test\.js$/ }) + .recommendCommands() + .env('CML_RUNNER') + .options(options) + .strict(); diff --git a/bin/cml/runner/start.js b/bin/cml/runner/start.js new file mode 100755 index 0000000000..39660df67f --- /dev/null +++ b/bin/cml/runner/start.js @@ -0,0 +1,548 @@ +const { join } = require('path'); +const { homedir } = require('os'); +const fs = require('fs').promises; +const { SpotNotifier } = require('ec2-spot-notification'); +const kebabcaseKeys = require('kebabcase-keys'); +const timestring = require('timestring'); +const winston = require('winston'); +const CML = require('../../../src/cml').default; +const { randid, sleep } = require('../../../src/utils'); +const tf = require('../../../src/terraform'); + +let cml; +let RUNNER; +let RUNNER_JOBS_RUNNING = []; +let RUNNER_SHUTTING_DOWN = false; +let RUNNER_TIMER = 0; +const GH_5_MIN_TIMEOUT = (72 * 60 - 5) * 60 * 1000; + +const shutdown = async (opts) => { + if (RUNNER_SHUTTING_DOWN) return; + RUNNER_SHUTTING_DOWN = true; + + const { error, cloud } = opts; + const { + name, + workdir = '', + tfResource, + noRetry, + reason, + destroyDelay + } = opts; + const tfPath = workdir; + + const unregisterRunner = async () => { + if (!RUNNER) return; + + try { + winston.info(`Unregistering runner ${name}...`); + await cml.unregisterRunner({ name }); + RUNNER && RUNNER.kill('SIGINT'); + winston.info('\tSuccess'); + } catch (err) { + winston.error(`\tFailed: ${err.message}`); + } + }; + + const retryWorkflows = async () => { + try { + if (!noRetry) { + if (RUNNER_JOBS_RUNNING.length > 0) { + await Promise.all( + RUNNER_JOBS_RUNNING.map( + async (job) => await cml.pipelineRestart({ jobId: job.id }) + ) + ); + } + } + } catch (err) { + winston.error(err); + } + }; + + const destroyTerraform = async () => { + if (!tfResource) return; + + winston.info(`Waiting ${destroyDelay} seconds to destroy`); + await sleep(destroyDelay); + + try { + winston.debug(await tf.destroy({ dir: tfPath })); + } catch (err) { + winston.error(`\tFailed destroying terraform: ${err.message}`); + } + }; + + if (error) { + winston.error(error, { status: 'terminated' }); + } else { + winston.info('runner status', { reason, status: 'terminated' }); + } + + if (!cloud) { + try { + await unregisterRunner(); + await retryWorkflows(); + } catch (err) { + winston.error(`Error connecting the SCM: ${err.message}`); + } + } + + await destroyTerraform(); + + process.exit(error ? 1 : 0); +}; + +const runCloud = async (opts) => { + const runTerraform = async (opts) => { + winston.info('Terraform apply...'); + + const { token, repo, driver } = cml; + const { + tpiVersion, + labels, + idleTimeout, + name, + cmlVersion, + single, + dockerVolumes, + cloud, + cloudRegion: region, + cloudType: type, + cloudPermissionSet: permissionSet, + cloudMetadata: metadata, + cloudGpu: gpu, + cloudHddSize: hddSize, + cloudSshPrivate: sshPrivate, + cloudSpot: spot, + cloudSpotPrice: spotPrice, + cloudStartupScript: startupScript, + cloudAwsSecurityGroup: awsSecurityGroup, + cloudAwsSubnet: awsSubnet, + workdir + } = opts; + + await tf.checkMinVersion(); + + if (gpu === 'tesla') + winston.warn( + 'GPU model "tesla" has been deprecated; please use "v100" instead.' + ); + + const tfPath = workdir; + const tfMainPath = join(tfPath, 'main.tf'); + + const tpl = tf.iterativeCmlRunnerTpl({ + tpiVersion, + repo, + token, + driver, + labels, + cmlVersion, + idleTimeout, + name, + single, + cloud, + region, + type, + permissionSet, + metadata, + gpu: gpu === 'tesla' ? 'v100' : gpu, + hddSize, + sshPrivate, + spot, + spotPrice, + startupScript, + awsSecurityGroup, + awsSubnet, + dockerVolumes + }); + + await fs.writeFile(tfMainPath, tpl); + + await tf.init({ dir: tfPath }); + await tf.apply({ dir: tfPath }); + + const tfStatePath = join(tfPath, 'terraform.tfstate'); + const tfstate = await tf.loadTfState({ path: tfStatePath }); + + return tfstate; + }; + + winston.info('Deploying cloud runner plan...'); + const tfstate = await runTerraform(opts); + const { resources } = tfstate; + for (const resource of resources) { + if (resource.type.startsWith('iterative_')) { + for (const { attributes } of resource.instances) { + const nonSensitiveValues = { + awsSecurityGroup: attributes.aws_security_group, + awsSubnetId: attributes.aws_subnet_id, + cloud: attributes.cloud, + driver: attributes.driver, + id: attributes.id, + idleTimeout: attributes.idle_timeout, + image: attributes.image, + instanceGpu: attributes.instance_gpu, + instanceHddSize: attributes.instance_hdd_size, + instanceIp: attributes.instance_ip, + instanceLaunchTime: attributes.instance_launch_time, + instanceType: attributes.instance_type, + instancePermissionSet: attributes.instance_permission_set, + labels: attributes.labels, + cmlVersion: attributes.cml_version, + metadata: attributes.metadata, + name: attributes.name, + region: attributes.region, + repo: attributes.repo, + single: attributes.single, + spot: attributes.spot, + spotPrice: attributes.spot_price, + timeouts: attributes.timeouts + }; + winston.info(JSON.stringify(nonSensitiveValues)); + } + } + } +}; + +const runLocal = async (opts) => { + winston.info(`Launching ${cml.driver} runner`); + const { + workdir, + name, + labels, + single, + idleTimeout, + noRetry, + dockerVolumes, + tfResource, + tpiVersion + } = opts; + + if (tfResource) { + await tf.checkMinVersion(); + + const tfPath = workdir; + await fs.mkdir(tfPath, { recursive: true }); + const tfMainPath = join(tfPath, 'main.tf'); + const tpl = tf.iterativeProviderTpl({ tpiVersion }); + await fs.writeFile(tfMainPath, tpl); + + await tf.init({ dir: tfPath }); + await tf.apply({ dir: tfPath }); + + const path = join(tfPath, 'terraform.tfstate'); + const tfstate = await tf.loadTfState({ path }); + tfstate.resources = [ + JSON.parse(Buffer.from(tfResource, 'base64').toString('utf-8')) + ]; + await tf.saveTfState({ tfstate, path }); + } + + const dataHandler = async (data) => { + const logs = await cml.parseRunnerLog({ data }); + for (const log of logs) { + winston.info('runner status', log); + + if (log.status === 'job_started') { + RUNNER_JOBS_RUNNING.push({ id: log.job, date: log.date }); + } + + if (log.status === 'job_ended') { + const { job: jobId } = log; + RUNNER_JOBS_RUNNING = RUNNER_JOBS_RUNNING.filter( + (job) => job.id !== jobId + ); + + if (single) await shutdown({ ...opts, reason: 'single job' }); + } + } + }; + + const proc = await cml.startRunner({ + workdir, + name, + labels, + single, + idleTimeout, + dockerVolumes + }); + + proc.stderr.on('data', dataHandler); + proc.stdout.on('data', dataHandler); + proc.on('disconnect', () => + shutdown({ ...opts, error: new Error('runner proccess lost') }) + ); + proc.on('close', (exit) => { + const reason = `runner closed with exit code ${exit}`; + if (exit === 0) shutdown({ ...opts, reason }); + else shutdown({ ...opts, error: new Error(reason) }); + }); + + RUNNER = proc; + if (idleTimeout > 0) { + const watcher = setInterval(async () => { + const idle = RUNNER_JOBS_RUNNING.length === 0; + + if (RUNNER_TIMER >= idleTimeout) { + shutdown({ ...opts, reason: `timeout:${idleTimeout}` }); + clearInterval(watcher); + } + + RUNNER_TIMER = idle ? RUNNER_TIMER + 1 : 0; + }, 1000); + } + + if (!noRetry) { + try { + winston.info(`EC2 id ${await SpotNotifier.instanceId()}`); + SpotNotifier.on('termination', () => + shutdown({ ...opts, reason: 'spot_termination' }) + ); + SpotNotifier.start(); + } catch (err) { + winston.warn('SpotNotifier can not be started.'); + } + + if (cml.driver === 'github') { + const watcherSeventyTwo = setInterval(() => { + RUNNER_JOBS_RUNNING.forEach((job) => { + if ( + new Date().getTime() - new Date(job.date).getTime() > + GH_5_MIN_TIMEOUT + ) { + shutdown({ ...opts, reason: 'timeout:72h' }); + clearInterval(watcherSeventyTwo); + } + }); + }, 60 * 1000); + } + } +}; + +const run = async (opts) => { + process.on('unhandledRejection', (reason) => + shutdown({ ...opts, error: new Error(reason) }) + ); + process.on('uncaughtException', (error) => shutdown({ ...opts, error })); + + ['SIGTERM', 'SIGINT', 'SIGQUIT'].forEach((signal) => { + process.on(signal, () => shutdown({ ...opts, reason: signal })); + }); + + opts.workdir = opts.workdir || `${homedir()}/.cml/${opts.name}`; + const { + driver, + repo, + token, + workdir, + cloud, + labels, + name, + reuse, + dockerVolumes + } = opts; + + cml = new CML({ driver, repo, token }); + + await cml.repoTokenCheck(); + + if (dockerVolumes.length && cml.driver !== 'gitlab') + winston.warn('Parameters --docker-volumes is only supported in gitlab'); + + const runners = await cml.runners(); + + const runner = await cml.runnerByName({ name, runners }); + if (runner) { + if (!reuse) + throw new Error( + `Runner name ${name} is already in use. Please change the name or terminate the existing runner.` + ); + winston.info(`Reusing existing runner named ${name}...`); + process.exit(0); + } + + if ( + reuse && + (await cml.runnersByLabels({ labels, runners })).find( + (runner) => runner.online + ) + ) { + winston.info( + `Reusing existing online runners with the ${labels} labels...` + ); + process.exit(0); + } + + winston.info(`Preparing workdir ${workdir}...`); + await fs.mkdir(workdir, { recursive: true }); + await fs.chmod(workdir, '766'); + + if (cloud) await runCloud(opts); + else await runLocal(opts); +}; + +exports.command = 'start'; +exports.description = 'Start and register a self-hosted runner'; + +exports.handler = async (opts) => { + if (process.env.RUNNER_NAME) { + winston.warn( + 'ignoring RUNNER_NAME environment variable, use CML_RUNNER_NAME or --name instead' + ); + } + try { + await run(opts); + } catch (error) { + await shutdown({ ...opts, error }); + } +}; + +exports.builder = (yargs) => yargs.env('CML_RUNNER').options(exports.options); + +exports.options = kebabcaseKeys({ + labels: { + type: 'string', + default: 'cml', + description: + 'One or more user-defined labels for this runner (delimited with commas)' + }, + idleTimeout: { + type: 'string', + default: '5 minutes', + coerce: (val) => (/^-?\d+$/.test(val) ? parseInt(val) : timestring(val)), + description: + 'Time to wait for jobs before shutting down (e.g. "5min"). Use "never" to disable' + }, + name: { + type: 'string', + default: `cml-${randid()}`, + defaultDescription: 'cml-{ID}', + description: 'Name displayed in the repository once registered' + }, + noRetry: { + type: 'boolean', + description: + 'Do not restart workflow terminated due to instance disposal or GitHub Actions timeout' + }, + single: { + type: 'boolean', + description: 'Exit after running a single job' + }, + reuse: { + type: 'boolean', + description: + "Don't launch a new runner if an existing one has the same name or overlapping labels" + }, + workdir: { + type: 'string', + hidden: true, + alias: 'path', + description: 'Runner working directory' + }, + dockerVolumes: { + type: 'array', + default: [], + description: 'Docker volumes. This feature is only supported in GitLab' + }, + cloud: { + type: 'string', + choices: ['aws', 'azure', 'gcp', 'kubernetes'], + description: 'Cloud to deploy the runner' + }, + cloudRegion: { + type: 'string', + default: 'us-west', + description: + 'Region where the instance is deployed. Choices: [us-east, us-west, eu-west, eu-north]. Also accepts native cloud regions' + }, + cloudType: { + type: 'string', + description: + 'Instance type. Choices: [m, l, xl]. Also supports native types like i.e. t2.micro' + }, + cloudPermissionSet: { + type: 'string', + default: '', + description: + 'Specifies the instance profile in AWS or instance service account in GCP' + }, + cloudMetadata: { + type: 'array', + string: true, + default: [], + coerce: (items) => { + const keyValuePairs = items.map((item) => [...item.split(/=(.+)/), null]); + return Object.fromEntries(keyValuePairs); + }, + description: + 'Key Value pairs to associate cml-runner instance on the provider i.e. tags/labels "key=value"' + }, + cloudGpu: { + type: 'string', + description: + 'GPU type. Choices: k80, v100, or native types e.g. nvidia-tesla-t4', + coerce: (val) => (val === 'nogpu' ? undefined : val) + }, + cloudHddSize: { + type: 'number', + description: 'HDD size in GB' + }, + cloudSshPrivate: { + type: 'string', + coerce: (val) => val && val.replace(/\n/g, '\\n'), + description: + 'Custom private RSA SSH key. If not provided an automatically generated throwaway key will be used' + }, + cloudSpot: { + type: 'boolean', + description: 'Request a spot instance' + }, + cloudSpotPrice: { + type: 'number', + default: -1, + description: + 'Maximum spot instance bidding price in USD. Defaults to the current spot bidding price' + }, + cloudStartupScript: { + type: 'string', + description: + 'Run the provided Base64-encoded Linux shell script during the instance initialization' + }, + cloudAwsSecurityGroup: { + type: 'string', + default: '', + description: 'Specifies the security group in AWS' + }, + cloudAwsSubnet: { + type: 'string', + default: '', + description: 'Specifies the subnet to use within AWS', + alias: 'cloud-aws-subnet-id' + }, + tpiVersion: { + type: 'string', + default: '>= 0.9.10', + description: + 'Pin the iterative/iterative terraform provider to a specific version. i.e. "= 0.10.4" See: https://www.terraform.io/language/expressions/version-constraints', + hidden: true + }, + cmlVersion: { + type: 'string', + default: require('../../../package.json').version, + description: 'CML version to load on TPI instance', + hidden: true + }, + tfResource: { + hidden: true, + alias: 'tf_resource' + }, + destroyDelay: { + type: 'number', + default: 10, + hidden: true, + description: + 'Seconds to wait for collecting logs on failure (https://github.com/iterative/cml/issues/413)' + } +}); diff --git a/bin/cml/runner.test.js b/bin/cml/runner/start.test.js similarity index 94% rename from bin/cml/runner.test.js rename to bin/cml/runner/start.test.js index b09d10d6c5..d800c971c1 100644 --- a/bin/cml/runner.test.js +++ b/bin/cml/runner/start.test.js @@ -1,8 +1,8 @@ jest.setTimeout(2000000); const isIp = require('is-ip'); -const { CML } = require('../..//src/cml'); -const { exec, sshConnection, randid, sleep } = require('../../src/utils'); +const { CML } = require('../../../src/cml'); +const { exec, sshConnection, randid, sleep } = require('../../../src/utils'); const IDLE_TIMEOUT = 15; const { @@ -56,26 +56,26 @@ describe('CML e2e', () => { expect(output).toMatchInlineSnapshot(` "cml.js runner - Launch and register a self-hosted runner + Manage self-hosted continuous integration runners + + Commands: + cml.js runner start Start and register a self-hosted runner Options: --help Show help [boolean] --version Show version number [boolean] --log Maximum log level [string] [choices: \\"error\\", \\"warn\\", \\"info\\", \\"debug\\"] [default: \\"info\\"] - --driver Platform where the repository is + --driver Forge where the repository is hosted. If not specified, it will be inferred from the environment [string] [choices: \\"github\\", \\"gitlab\\", \\"bitbucket\\"] - --repo Repository to be used for - registering the runner. If not - specified, it will be inferred from - the environment [string] - --token Personal access token to register a - self-hosted runner on the - repository. If not specified, it + --repo Repository. If not specified, it will be inferred from the environment [string] + --token Personal access token. If not + specified, it will be inferred from + the environment [string] --labels One or more user-defined labels for this runner (delimited with commas) [string] [default: \\"cml\\"] diff --git a/bin/cml/tensorboard.js b/bin/cml/tensorboard.js new file mode 100644 index 0000000000..7b264e41c0 --- /dev/null +++ b/bin/cml/tensorboard.js @@ -0,0 +1,8 @@ +exports.command = 'tensorboard'; +exports.description = 'Manage tensorboard.dev agents'; +exports.builder = (yargs) => + yargs + .commandDir('./tensorboard', { exclude: /\.test\.js$/ }) + .recommendCommands() + .demandCommand() + .strict(); diff --git a/bin/cml/tensorboard-dev.js b/bin/cml/tensorboard/start.js similarity index 92% rename from bin/cml/tensorboard-dev.js rename to bin/cml/tensorboard/start.js index 1702e9f564..aae9a443e5 100644 --- a/bin/cml/tensorboard-dev.js +++ b/bin/cml/tensorboard/start.js @@ -5,7 +5,7 @@ const { homedir } = require('os'); const tempy = require('tempy'); const winston = require('winston'); -const { exec, watermarkUri, sleep } = require('../../src/utils'); +const { exec, watermarkUri, sleep } = require('../../../src/utils'); const closeFd = (fd) => { try { @@ -41,8 +41,8 @@ exports.tbLink = async (opts = {}) => { throw new Error(`Tensorboard took too long. ${error}`); }; -exports.command = 'tensorboard-dev'; -exports.description = 'Get a tensorboard link'; +exports.command = 'start'; +exports.description = 'Start the tensorboard agent and get a link'; exports.handler = async (opts) => { const { @@ -105,9 +105,10 @@ exports.handler = async (opts) => { process.exit(0); }; -exports.builder = (yargs) => yargs.env('CML_TENSORBOARD_DEV').options(options); +exports.builder = (yargs) => + yargs.env('CML_TENSORBOARD_DEV').options(exports.options); -const options = kebabcaseKeys({ +exports.options = kebabcaseKeys({ credentials: { type: 'string', alias: 'c', diff --git a/bin/cml/tensorboard-dev.test.js b/bin/cml/tensorboard/start.test.js similarity index 85% rename from bin/cml/tensorboard-dev.test.js rename to bin/cml/tensorboard/start.test.js index 7229867f48..ae47e99fcc 100644 --- a/bin/cml/tensorboard-dev.test.js +++ b/bin/cml/tensorboard/start.test.js @@ -1,7 +1,7 @@ const fs = require('fs').promises; const tempy = require('tempy'); -const { exec, isProcRunning, sleep } = require('../../src/utils'); -const { tbLink } = require('./tensorboard-dev'); +const { exec, isProcRunning, sleep } = require('../../../src/utils'); +const { tbLink } = require('./start'); const CREDENTIALS = '{"refresh_token": "1//03FiVnGk2xhnNCgYIARAAGAMSNwF-L9IrPH8FOOVWEYUihFDToqxyLArxfnbKFmxEfhzys_KYVVzBisYlAy225w4HaX3ais5TV_Q", "token_uri": "https://oauth2.googleapis.com/token", "client_id": "373649185512-8v619h5kft38l4456nm2dj4ubeqsrvh6.apps.googleusercontent.com", "client_secret": "pOyAuU2yq2arsM98Bw5hwYtr", "scopes": ["openid", "https://www.googleapis.com/auth/userinfo.email"], "type": "authorized_user"}'; @@ -57,22 +57,18 @@ describe('CML e2e', () => { expect(output).toMatchInlineSnapshot(` "cml.js tensorboard-dev - Get a tensorboard link - Options: --help Show help [boolean] --version Show version number [boolean] --log Maximum log level [string] [choices: \\"error\\", \\"warn\\", \\"info\\", \\"debug\\"] [default: \\"info\\"] - --driver Platform where the repository is hosted. If not specified, - it will be inferred from the environment + --driver Forge where the repository is hosted. If not specified, it + will be inferred from the environment [string] [choices: \\"github\\", \\"gitlab\\", \\"bitbucket\\"] - --repo Repository to be used for registering the runner. If not - specified, it will be inferred from the environment - [string] - --token Personal access token to register a self-hosted runner on - the repository. If not specified, it will be inferred from - the environment [string] + --repo Repository. If not specified, it will be inferred from the + environment [string] + --token Personal access token. If not specified, it will be + inferred from the environment [string] -c, --credentials TB credentials as json. Usually found at ~/.config/tensorboard/credentials/uploader-creds.json. If not specified will look for the json at the env variable diff --git a/bin/cml/workflow.js b/bin/cml/workflow.js new file mode 100644 index 0000000000..f5f9016992 --- /dev/null +++ b/bin/cml/workflow.js @@ -0,0 +1,8 @@ +exports.command = 'workflow'; +exports.description = 'Manage continuous integration workflows'; +exports.builder = (yargs) => + yargs + .commandDir('./workflow', { exclude: /\.test\.js$/ }) + .recommendCommands() + .demandCommand() + .strict(); diff --git a/bin/cml/workflow/restart.js b/bin/cml/workflow/restart.js new file mode 100644 index 0000000000..d25eb3d4c2 --- /dev/null +++ b/bin/cml/workflow/restart.js @@ -0,0 +1,20 @@ +const kebabcaseKeys = require('kebabcase-keys'); + +const CML = require('../../../src/cml').default; + +exports.command = 'restart'; +exports.description = 'Restarts a workflow given the jobId or workflowId'; + +exports.handler = async (opts) => { + const cml = new CML(opts); + await cml.pipelineRerun(opts); +}; + +exports.builder = (yargs) => yargs.env('CML_CI').options(exports.options); + +exports.options = kebabcaseKeys({ + id: { + type: 'string', + description: 'Specifies the run Id to be rerun.' + } +}); diff --git a/bin/cml/rerun-workflow.test.js b/bin/cml/workflow/restart.test.js similarity index 62% rename from bin/cml/rerun-workflow.test.js rename to bin/cml/workflow/restart.test.js index 271ef61862..057369c740 100644 --- a/bin/cml/rerun-workflow.test.js +++ b/bin/cml/workflow/restart.test.js @@ -1,4 +1,4 @@ -const { exec } = require('../../src/utils'); +const { exec } = require('../../../src/utils'); describe('CML e2e', () => { test('cml-ci --help', async () => { @@ -9,21 +9,18 @@ describe('CML e2e', () => { expect(output).toMatchInlineSnapshot(` "cml.js rerun-workflow - Reruns a workflow given the jobId or workflow Id - Options: --help Show help [boolean] --version Show version number [boolean] --log Maximum log level [string] [choices: \\"error\\", \\"warn\\", \\"info\\", \\"debug\\"] [default: \\"info\\"] - --driver Platform where the repository is hosted. If not specified, it will - be inferred from the environment + --driver Forge where the repository is hosted. If not specified, it will be + inferred from the environment [string] [choices: \\"github\\", \\"gitlab\\", \\"bitbucket\\"] - --repo Repository to be used for registering the runner. If not specified, - it will be inferred from the environment [string] - --token Personal access token to register a self-hosted runner on the - repository. If not specified, it will be inferred from the + --repo Repository. If not specified, it will be inferred from the environment [string] + --token Personal access token. If not specified, it will be inferred from + the environment [string] --id Specifies the run Id to be rerun. [string]" `); }); diff --git a/bin/legacy/ci.js b/bin/legacy/ci.js new file mode 100644 index 0000000000..e94cb175fb --- /dev/null +++ b/bin/legacy/ci.js @@ -0,0 +1,6 @@ +const { builder, handler } = require('../cml/repository/configure'); + +exports.command = 'ci'; +exports.description = false; +exports.handler = handler; +exports.builder = builder; diff --git a/bin/legacy/publish.js b/bin/legacy/publish.js new file mode 100644 index 0000000000..61762c3c7a --- /dev/null +++ b/bin/legacy/publish.js @@ -0,0 +1,6 @@ +const { builder, handler } = require('../cml/attachment/publish'); + +exports.command = 'publish'; +exports.description = false; +exports.handler = handler; +exports.builder = builder; diff --git a/bin/legacy/rerun-workflow.js b/bin/legacy/rerun-workflow.js new file mode 100644 index 0000000000..2ca8dba25c --- /dev/null +++ b/bin/legacy/rerun-workflow.js @@ -0,0 +1,6 @@ +const { builder, handler } = require('../cml/workflow/restart'); + +exports.command = 'rerun-workflow'; +exports.description = false; +exports.handler = handler; +exports.builder = builder; diff --git a/bin/legacy/send-comment.js b/bin/legacy/send-comment.js new file mode 100644 index 0000000000..723623aa53 --- /dev/null +++ b/bin/legacy/send-comment.js @@ -0,0 +1,6 @@ +const { builder, handler } = require('../cml/report/create'); + +exports.command = 'send-comment'; +exports.description = false; +exports.handler = handler; +exports.builder = builder; diff --git a/bin/legacy/send-github-check.js b/bin/legacy/send-github-check.js new file mode 100644 index 0000000000..6af346f5c5 --- /dev/null +++ b/bin/legacy/send-github-check.js @@ -0,0 +1,6 @@ +const { builder, handler } = require('../cml/check/create'); + +exports.command = 'send-github-check'; +exports.description = false; +exports.handler = handler; +exports.builder = builder; diff --git a/bin/legacy/tensorboard-dev.js b/bin/legacy/tensorboard-dev.js new file mode 100644 index 0000000000..a2fef945bf --- /dev/null +++ b/bin/legacy/tensorboard-dev.js @@ -0,0 +1,6 @@ +const { builder, handler } = require('../cml/tensorboard/start'); + +exports.command = 'tensorboard-dev'; +exports.description = false; +exports.handler = handler; +exports.builder = builder;