Open11

CircleCI Config SDKの学習と感想

Kesin11Kesin11

https://circleci.com/blog/config-sdk/

ブログをチラ見した感じではAWS CDKのようにプログラミング言語のオブジェクトで定義したものをYAMLに変換するというアプローチっぽい。

ブログやREADMEのサンプルだと非常に簡単なconfig.ymlの例しかないので、複雑なconfig.ymlを表現するためにはどれぐらい大変なのかを実験してみる。

実験用に書いたコードと感想を雑に書いていくが、気が向いたら清書してブログにすると思う。
実験用のリポジトリ https://github.com/Kesin11/circleci-config-sdk-sandbox

TODO

Kesin11Kesin11

まず最初はサンプルをちょっとだけ変更して2つのジョブを持つワークフロー

const CircleCI = require("@circleci/circleci-config-sdk")

const main = async () => {
  const config = new CircleCI.Config()
  const workflow = new CircleCI.Workflow('build')
  config.addWorkflow(workflow)

  const nodeExecutor = new CircleCI.executors.DockerExecutor('cimg/node:lts', 'small')
  const buildJob = new CircleCI.Job('build', nodeExecutor, [
    new CircleCI.commands.Checkout(),
    new CircleCI.commands.Run({
      name: 'install',
      command: 'npm ci',
    }),
    new CircleCI.commands.Run({
      name: 'build',
      command: 'npm run build',
    })
  ])
  config.addJob(buildJob)
  workflow.addJob(buildJob)

  const testJob = new CircleCI.Job('test', nodeExecutor, [
    new CircleCI.commands.Checkout(),
    new CircleCI.commands.Run({
      name: 'install',
      command: 'npm ci',
    }),
    new CircleCI.commands.Run({
      name: 'test',
      command: 'npm run test',
    })
  ])
  config.addJob(testJob)
  workflow.addJob(testJob)

  console.log(config.stringify())
  console.warn(config.stringify())
}
main()
version: 2.1
setup: false
jobs:
  build:
    docker:
      - &a1
        image: cimg/node:lts
    resource_class: small
    steps:
      - checkout
      - run:
          name: install
          command: npm ci
      - run:
          name: build
          command: npm run build
  test:
    docker:
      - *a1
    resource_class: small
    steps:
      - checkout
      - run:
          name: install
          command: npm ci
      - run:
          name: test
          command: npm run test
workflows:
  build:
    jobs:
      - build
      - test

js上は nodeExecutor を使いまわしているだけだが、YAMLにしたときに自動的にアンカーに変換されて2重定義されないようになっているのが偉い

Kesin11Kesin11

https://circleci-public.github.io/circleci-config-sdk-ts/modules/reusable.html
API referenceを見ると Reusable という気になる文字列が見えたので試す。ここからブログにもREADMEにも載っていないので手探りで試していく。

const CircleCI = require("@circleci/circleci-config-sdk")

const main = async () => {
  const config = new CircleCI.Config()
  const workflow = new CircleCI.Workflow('build')
  config.addWorkflow(workflow)

  // Define executor
  const nodeExecutor = new CircleCI.executors.DockerExecutor('cimg/node:lts', 'small')
  const reusableExecutor = new CircleCI.reusable.ReusableExecutor('node', nodeExecutor)
  config.addReusableExecutor(reusableExecutor)

  // Define command
  const reusableInstallCommand = new CircleCI.reusable.ReusableCommand('install', [
    new CircleCI.commands.Checkout(),
    new CircleCI.commands.Run({
      name: 'Install',
      command: 'npm ci',
    }),
  ])
  config.addReusableCommand(reusableInstallCommand)

  // Define reusing executor and command
  const reusedExecutor = new CircleCI.reusable.ReusedExecutor(reusableExecutor)
  const reusedInstallCommand =  new CircleCI.reusable.ReusedCommand(reusableInstallCommand)

  // build job
  const buildJob = new CircleCI.Job('build', reusedExecutor, [
    reusedInstallCommand,
    new CircleCI.commands.Run({
      name: 'build',
      command: 'npm run build',
    })
  ])
  config.addJob(buildJob)
  workflow.addJob(buildJob)

  // test job
  const testJob = new CircleCI.Job('test', reusedExecutor, [
    reusedInstallCommand,
    new CircleCI.commands.Run({
      name: 'test',
      command: 'npm run test',
    })
  ])
  config.addJob(testJob)
  workflow.addJob(testJob)

  // Output
  console.log(config.stringify())
  console.warn(config.stringify())
}
main()
version: 2.1
setup: false
commands:
  install:
    steps:
      - checkout
      - run:
          name: Install
          command: npm ci
executors:
  node:
    docker:
      - image: cimg/node:lts
    resource_class: small
jobs:
  build:
    executor:
      name: node
    steps:
      - install
      - run:
          name: build
          command: npm run build
  test:
    executor:
      name: node
    steps:
      - install
      - run:
          name: test
          command: npm run test
workflows:
  build:
    jobs:
      - build
      - test

ReusableExecutorReusableCommand を定義すると executorscommands が作られる。そのために config.addReusable{Executor, Comamnd} も必要なので注意。

jobの中でReusableなものを使うためには ReusedExecutor, ReusedCommand を使う必要がある。これによって生成されるyamlのコードはアンカーは使わず、config 2.1からのローカルのorbsという扱いで参照させるコードになる。

Kesin11Kesin11

引き続きReusableからExecutorとCommandの一部をパラメータ化したパターン
Executorの cimg/node のタグをパラメータ化して外から渡せるようにしたのと、Commandは npm run で実行するタスクをパラメータ指定できるように汎用化してjobから呼び出している。

そもそもyamlで書いても記述量が多く面倒な部類だが、jsでもyamlの複雑さに比例してコード量もだいぶ増える印象。

const CircleCI = require("@circleci/circleci-config-sdk")

const main = async () => {
  const config = new CircleCI.Config()
  const workflow = new CircleCI.Workflow('build')
  config.addWorkflow(workflow)

  // Define executor
  const nodeExecutor = new CircleCI.executors.DockerExecutor('cimg/node:<< parameters.tag >>', 'small')
  const reusableExecutor = new CircleCI.reusable.ReusableExecutor(
    'node',
    nodeExecutor,
    new CircleCI.parameters.CustomParametersList([
      new CircleCI.parameters.CustomParameter('tag', 'string', 'lts')
    ])
  )
  config.addReusableExecutor(reusableExecutor)

  // Define commands
  const reusableInstallCommand = new CircleCI.reusable.ReusableCommand('install', [
    new CircleCI.commands.Checkout(),
    new CircleCI.commands.Run({
      name: 'Install',
      command: 'npm ci',
    }),
  ])
  config.addReusableCommand(reusableInstallCommand)

  const allowedNpmTasks = ['build', 'test']
  const reusableNpmCommand = new CircleCI.reusable.ReusableCommand('npm_run', [
    new CircleCI.commands.Run({
      name: 'npm << parameters.task >>',
      command: 'npm run <<parameters.task >>',
    }),
  ],
  new CircleCI.parameters.CustomParametersList([
    new CircleCI.parameters.CustomEnumParameter('task', allowedNpmTasks)
  ])
  )
  config.addReusableCommand(reusableNpmCommand)

  // Define reusing executor and command
  const reusedExecutor = new CircleCI.reusable.ReusedExecutor(reusableExecutor)
  const reusedInstallCommand =  new CircleCI.reusable.ReusedCommand(reusableInstallCommand)

  // build job
  // Using ParameterizedJob for define job parameters
  const buildJob = new CircleCI.reusable.ParameterizedJob('build', reusedExecutor)
  buildJob
    .addStep(reusedInstallCommand)
    .addStep(new CircleCI.reusable.ReusedCommand(reusableNpmCommand, { task: 'build' }))
    .defineParameter('tag', 'string')
  config.addJob(buildJob)
  workflow.addJob(buildJob, { 'tag': 'lts' })

  // test job
  const testJob = new CircleCI.reusable.ParameterizedJob('test', reusedExecutor)
  testJob
    .addStep(reusedInstallCommand)
    .addStep(new CircleCI.reusable.ReusedCommand(reusableNpmCommand, { task: 'test' }))
    .defineParameter('tag', 'string')
  config.addJob(testJob)
  workflow.addJob(testJob, { 'tag': '16' })

  // Output
  console.log(config.stringify())
  console.warn(config.stringify())
}
main()
version: 2.1
setup: false
commands:
  install:
    steps:
      - checkout
      - run:
          name: Install
          command: npm ci
  npm_run:
    parameters:
      task:
        type: enum
        enum:
          - build
          - test
    steps:
      - run:
          name: npm << parameters.task >>
          command: npm run <<parameters.task >>
executors:
  node:
    docker:
      - image: cimg/node:<< parameters.tag >>
    resource_class: small
    parameters:
      tag:
        type: string
        default: lts
jobs:
  build:
    parameters:
      tag:
        type: string
    executor:
      name: node
    steps:
      - install
      - npm_run:
          task: build
  test:
    parameters:
      tag:
        type: string
    executor:
      name: node
    steps:
      - install
      - npm_run:
          task: test
workflows:
  build:
    jobs:
      - build:
          tag: lts
      - test:
          tag: "16"
Kesin11Kesin11

conditionはstepとworkflowの2種類がある

stepのcondition
https://circleci.com/docs/ja/configuration-reference#the-when-step-requires-version-21
SDKにはこれに対応するメソッドやオプションが存在しない??

CircleCI.logic.Whenand, or などは存在するが、Conditionalのテストを見てもandやorなどの条件式パーツのテストしか存在しなかった
https://github.com/CircleCI-Public/circleci-config-sdk-ts/blob/main/tests/Conditional.test.ts

workflowでのwhenはテストが存在するのと、CircleCI.Workflow()の引数にWhenのオブジェクトを渡せるのでこれは現状でも間違いなく使えそう。
https://github.com/CircleCI-Public/circleci-config-sdk-ts/blob/c1ebea3e2216549d0c5e2c1e9fc359115c35929a/tests/Workflow.test.ts#L78-L115

Kesin11Kesin11

orbsも例によってドキュメントが一切無いのでテストコードから使い方を見るしかない。
https://github.com/CircleCI-Public/circleci-config-sdk-ts/blob/main/tests/Orb.test.ts

orbsのコードで最も謎、かつ面倒なのは OrbImport() の引数でOrbImportManifestのオブジェクトを渡すところ。
後でorbのexecutors, jobs, commandsを参照するときのキーは 予めここで最低限の定義をしておかないとエラーでyamlの生成すらできない っぽい。

先人の https://t28.dev/circleci-config-app-with-sdk/#fnref-6 の記事を見たときに何を言っているのか全然分からなかったが、自分で書いてみたら本当にそのような挙動になっていることを確認した。謎すぎる。

orbsで使いたい機能のキーを自分で毎回定義する必要があるのは面倒すぎないか?将来的にOrbsのyaml自体がこのSDKで生成されるようになり、対となるjsのコードも生成されてそこでOrbImportManifestもimportできるようになれば話は変わってくるが、そんな遠回りを必要とせずに任意のキーを入れたらそのままエラーを出さずにyamlの生成までは完走してほしい。

const CircleCI = require("@circleci/circleci-config-sdk")

const main = async () => {
  const config = new CircleCI.Config()
  const workflow = new CircleCI.Workflow('build')
  config.addWorkflow(workflow)

  const nodeOrb = new CircleCI.orb.OrbImport(
    'node',
    'circleci',
    'node',
    '5.0.3',
    'Description: circleci/node orbs',
    // OrbImportManifest
    {
      executors: {
        'default': new CircleCI.parameters.CustomParametersList([])
      },
      jobs: {
        'test': new CircleCI.parameters.CustomParametersList([])
      },
      commands: {
        'install-packages': new CircleCI.parameters.CustomParametersList([])
      }
    }
  )
  config.importOrb(nodeOrb)

  // Using orb executors and commands
  const nodeExecutor = new CircleCI.reusable.ReusedExecutor(nodeOrb.executors['default'], { tag: 'lts'})
  const buildJob = new CircleCI.Job('build', nodeExecutor, [
    new CircleCI.commands.Checkout(),
    new CircleCI.reusable.ReusedCommand(nodeOrb.commands['install-packages'], {
      'pkg-manager': 'npm',
      'cache-version': 'v2'
    }),
    new CircleCI.commands.Run({
      name: 'build',
      command: 'npm run build',
    })
  ])
  config.addJob(buildJob)
  workflow.addJob(buildJob)

  // Using orb jobs
  workflow.addJob(nodeOrb.jobs['test'], {
    'run-command': 'test:ci',
    'version': 'lts'
  })

  console.log(config.stringify())
  console.warn(config.stringify())
}
main()
version: 2.1
setup: false
jobs:
  build:
    executor:
      name: node/default
      tag: lts
    steps:
      - checkout
      - node/install-packages:
          pkg-manager: npm
          cache-version: v2
      - run:
          name: build
          command: npm run build
workflows:
  build:
    jobs:
      - build
      - node/test:
          run-command: test:ci
          version: lts
orbs:
  node: circleci/node@5.0.3

orbsを使うコードは circleci/node のサンプルなどを見ながらよく使いそうなパターンを実装してみた
https://circleci.com/developer/ja/orbs/orb/circleci/node

Kesin11Kesin11

実践編
過去の登壇のときに作成したdocker buildxでビルドするconfig.ymlの一部を再現してみる

command に複数行のコマンドを書く場合に |-をどうやってjsに書けばいいのかと思ったが、改行を含む文字列を渡せば自動的に |- を追加してくれるらしい。

const CircleCI = require("@circleci/circleci-config-sdk")

const main = async () => {
  const config = new CircleCI.Config()
  const workflow = new CircleCI.Workflow('build')
  config.addWorkflow(workflow)

  // Define executor
  const dockerExecutor = new CircleCI.executors.DockerExecutor('cimg/base:2022.03', undefined, { environment: {
    IMAGE: "ghcr.io/kesin11/circleci-cli-sandbox"
  }})
  const reusableExecutor = new CircleCI.reusable.ReusableExecutor('cimg-docker', dockerExecutor)
  config.addReusableExecutor(reusableExecutor)

  // Define command
  const setupDockerCommand = new CircleCI.reusable.ReusableCommand('setup-docker', [
    new CircleCI.commands.SetupRemoteDocker({version: "20.10.11"}),
    new CircleCI.commands.Run("docker version")
  ])
  const setupBuildxCommand = new CircleCI.reusable.ReusableCommand('setup-buildx', [
    new CircleCI.commands.Run({
      name: "Show docker info",
      command: [
        "docker version",
        "docker buildx version",
        "docker context inspect"
      ].join('\n')
    }),
    new CircleCI.commands.Run({
      name: "Setup docker buildx",
      command: [
        "docker context circleci",
        "docker buildx create --use circleci",
        "docker buildx ls",
        "docker context inspect circleci",
      ].join('\n')
    })
  ])
  config.addReusableCommand(setupDockerCommand)
  config.addReusableCommand(setupBuildxCommand)

  // Define reusing executor and command
  const reusedExecutor = new CircleCI.reusable.ReusedExecutor(reusableExecutor)
  const reusedSetupDocker =  new CircleCI.reusable.ReusedCommand(setupDockerCommand)
  const reusedSetupBuildx =  new CircleCI.reusable.ReusedCommand(setupBuildxCommand)

  // build job
  const buildJob = new CircleCI.Job('docker-build-registry-cache', reusedExecutor, [
    new CircleCI.commands.Checkout(),
    reusedSetupDocker,
    reusedSetupBuildx,
    new CircleCI.commands.Run({
      name: 'docker login',
      command: 'echo $GITHUB_CR_PAT | docker login ghcr.io -u kesin11 --password-stdin',
    }),
    new CircleCI.commands.Run({
      name: 'Docker build with registry cache',
      command: 'docker buildx build --progress=plain -f Dockerfile --cache-to=type=registry,mode=max,ref=$IMAGE:cache --cache-from=type=registry,ref=$IMAGE:cache -t $IMAGE:latest .'}),
  ])
  config.addJob(buildJob)
  workflow.addJob(buildJob)

  // Output
  console.log(config.stringify())
  console.warn(config.stringify())
}
main()
version: 2.1
setup: false
commands:
  setup-docker:
    steps:
      - setup_remote_docker:
          version: 20.10.11
      - run: docker version
  setup-buildx:
    steps:
      - run:
          name: Show docker info
          command: |-
            docker version
            docker buildx version
            docker context inspect
      - run:
          name: Setup docker buildx
          command: |-
            docker context create circleci
            docker buildx create --use circleci
            docker buildx ls
            docker context inspect circleci
executors:
  cimg-docker:
    docker:
      - image: cimg/base:2022.03
        environment:
          IMAGE: ghcr.io/kesin11/circleci-cli-sandbox
    resource_class: medium
jobs:
  docker-build-registry-cache:
    executor:
      name: cimg-docker
    steps:
      - checkout
      - setup-docker
      - setup-buildx
      - run:
          name: docker login
          command: echo $GITHUB_CR_PAT | docker login ghcr.io -u kesin11 --password-stdin
      - run:
          name: Docker build with registry cache
          command: docker buildx build --progress=plain -f Dockerfile --cache-to=type=registry,mode=max,ref=$IMAGE:cache --cache-from=type=registry,ref=$IMAGE:cache -t $IMAGE:latest .
workflows:
  build:
    jobs:
      - docker-build-registry-cache
Kesin11Kesin11

実践編
CircleCIのAPIからデプロイジョブを手動でトリガーするジョブのパターン
自分がpublicにしているリポジトリにはいい感じのconfig.ymlが存在しなかったのでサンプルを見ながら適当に作った
https://support.circleci.com/hc/en-us/articles/360050351292-How-to-Trigger-a-Workflow-via-CircleCI-API-v2

andやorを複数使う複雑な whenテストコードから見つかったが、単純にbooleanのパラメータのtrue/falseを比較するだけの when: << pipeline.parameters.manual_deploy >> を記述する方法がよく分からなかった。

他にそれっぽいものが存在しないかConditionを調べたところTruthyというものを見つけたので試したみたところビンゴだった

new CircleCI.logic.conditional.Truthy(`<< pipeline.parameters.${TRIGGER_NAME} >>`)

全体としてはこうなる

const CircleCI = require("@circleci/circleci-config-sdk")
// Manual trigger sample
// https://support.circleci.com/hc/en-us/articles/360050351292-How-to-Trigger-a-Workflow-via-CircleCI-API-v2

const main = async () => {
  const TRIGGER_NAME = 'manual_deploy'
  const config = new CircleCI.Config()

  // Workflow condition
  const when = new CircleCI.logic.When(
    new CircleCI.logic.conditional.Truthy(`<< pipeline.parameters.${TRIGGER_NAME} >>`)
  )
  const workflow = new CircleCI.Workflow('deploy', [], when)
  config.addWorkflow(workflow)

  // Pipeline parameters
  config.defineParameter(TRIGGER_NAME, 'boolean', false)

  const nodeExecutor = new CircleCI.executors.DockerExecutor('cimg/node:lts', 'small')
  const deployJob = new CircleCI.Job('deploy', nodeExecutor, [
    new CircleCI.commands.Checkout(),
    new CircleCI.commands.Run({
      name: 'install',
      command: 'npm ci',
    }),
    new CircleCI.commands.Run({
      name: 'deploy',
      command: 'npm run deploy',
    })
  ])
  config.addJob(deployJob)
  workflow.addJob(deployJob)

  // Output
  console.log(config.stringify())
  console.warn(config.stringify())
}
main()
version: 2.1
setup: false
parameters:
  manual_deploy:
    type: boolean
    default: false
jobs:
  deploy:
    docker:
      - image: cimg/node:lts
    resource_class: small
    steps:
      - checkout
      - run:
          name: install
          command: npm ci
      - run:
          name: deploy
          command: npm run deploy
workflows:
  deploy:
    when: << pipeline.parameters.manual_deploy >>
    jobs:
      - deploy
Kesin11Kesin11

一通りを試し終わったので感想。
率直に言ってどういうユーザー向けを目指しているのかよく分からなかった。少なくとも自分は今までのconfig.ymlを手書きする方法から積極的に乗り換えたいと思う要素はなかった。

まずyamlを手書きする方法に比べてどうしても記述量が増えるが、jsからyamlを生成するというアプローチなのでこれは仕方ない。
少なくともVSCodeを使えばjsのままでも型があるのは一見嬉しく思えるが、そもそもVSCodeのyamlの汎用extensionがインストールされていればyamlのままでもCircleCIのconfig.ymlとして有効なキーは補完が効くので特にメリットではない。

このSDKを使う最大のメリットはReusableで定義したexecutorやcommand、あるいはjobそのものをexportしてnpmパッケージ化してしまえば社内で共有したり、誰かがnpmにpublishしたものを使うことができる点だと思うが、それってOrbsで実現できることと同じでは?
もしもこのSDKがCircleCIにorbsというyamlの一部を実質的にimportする機能や、条件分岐のためのwhenが追加されるより前の時代に登場していたのであれば流行ったかもしれない。

現在ではconfig.ymlの一部をorbsとして共有可能だし、例えば社内利用のような一部のユーザーにだけ共有することもprivate orbによって可能になっている。
もし when が存在しなければDynamic Configurationと併用してjs側で条件分岐して生成されるconfig.ymlを変化させて柔軟性を持たせることが可能だったかもしれないが、今ではそもそも when とand, or, notを組み合わせることで複雑な条件分岐も大体可能になっている。

自分が思いつく限りではSDKで可能なことは既にyamlで実現可能なので、わざわざjsというレイヤーを1つ増やしてconfig.ymlをさらに複雑にすることのメリットを見いだせなかった。
特にこのSDKを使ってさらに外部のOrbsを使うようなコードの場合、最終的なyamlが生成されるロジックを完全に把握しようと思ったときに見る必要があるコードが多く、場所もバラバラなのでカオスになりそうな予感がある。

  • 自分がjsで書いたコード
  • npm installしたjsのコード
  • 使用するOrbsのyamlの中身
  • (Dynamic Configuratonで動的にconfig.ymlを生成する場合)setup: true を書いている元のconfig.yml
    • このSDKでDynamic Configuration用の setup: true のconfig.ymlを生成することも可能そうなので、その場合はそこもjsのコードを読むことになる