タケユー・ウェブ日報

Ruby on Rails や Flutter といったWeb・モバイルアプリ技術を武器にお客様のビジネス立ち上げを支援する、タケユー・ウェブ株式会社の技術ブログです。

Ruby で Firebase User を作成

動機

  • Firebase Auth を使ったWebアプリの Rails System Spec を書きたい
  • フロントエンドで Firebase JavaScript SDK を使っているため、バックエンドでのstubでは書けない

サンプルコード(一部抜粋)

  • Google::Apis::IdentitytoolkitV3::IdentityToolkitService Google::Apis::IdentitytoolkitV3::SignupNewUserRequest で作成できる
  • メールアドレスは実際に受信するなら MailSlurp あたりを使って用意する
require 'rails_helper'
require 'google/apis/identitytoolkit_v3'

RSpec.feature 'login', type: :system, js: true do
  def sign_in(email, password)
    visit root_path
    within(:css, '#login_modal') do
      fill_in('login_email', with: email)
      fill_in('login_password', with: password)
      find_button('Login', wait: 10).click  # Firebase JavaScript SDK によるメールアドレス認証
    end
    expect(page).to have_button('Logout', wait: 10)
  end

  def sign_out
    find_button('Logout', wait: 10).click
  end

  before do
    # Create Firebase User
    # https://qiita.com/asflash8/items/17775895e35272ae7ec8
    # https://firebase.google.com/docs/auth/admin/manage-users?hl=ja
    firebase_service_account_json_key = Rails.application.credentials.dig(:raw, :firebase_service_account_json_key)
    @identity_toolkit_service = Google::Apis::IdentitytoolkitV3::IdentityToolkitService.new
    @identity_toolkit_service.authorization = Google::Auth::ServiceAccountCredentials.make_creds(
      json_key_io: StringIO.new(firebase_service_account_json_key),
      scope: 'https://www.googleapis.com/auth/identitytoolkit'
    )
    user_data = {
      email: 'test@takeyuweb.co.jp',
      email_verified: true,
      password: 'password1234',
      disabled: false
    }
    request = Google::Apis::IdentitytoolkitV3::SignupNewUserRequest.new(user_data)
    @account = @identity_toolkit_service.signup_new_user(request)

    # Signin
    sign_in(user_data.fetch(:email), user_data.fetch(:password))

    @current_user = User.find_by(email: user_data.fetch(:email))
    @password = user_data.fetch(:password)
  end

  after do
    if @account
      request = Google::Apis::IdentitytoolkitV3::DeleteAccountRequest.new(local_id: @account.local_id)
      @identity_toolkit_service.delete_account(request)
    end
  rescue Google::Apis::ClientError => e
    Rails.logger.error(e.message)
  end

  scenario "edit profile" do
    visit root_path
    find_link('Profile').click

    expect(page).to have_field('Name')
    expect(page).to have_field('Telephone')

    fill_in 'Name', with: 'Yuichi Takeuchi'
    fill_in 'Telephone', with: '81487003094'

    # (snip)
  end
end

参考

qiita.com

firebase.google.com

rspec で Firebase ID Token を stub

class SessionsController < ApplicationController
  def create
    id_token = params.required(:id_token)
    user = User.from_firebase(id_token)
    # (snip)
  end
end

class User < ApplicationRecord

  CIRTIFICATE_URL = 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com'
  EXP_LEEWAY = 30.seconds

  def self.from_firebase(id_token)
    firebase_project_id = Rails.application.credentials.dig(:firebase, :project_id)
    valid_iss = "https://securetoken.google.com/#{firebase_project_id}"

    # https://firebase.google.com/docs/auth/admin/verify-id-tokens?hl=ja
    a, decoded_token_header = JWT.decode(id_token, nil, false)

    certificate = Rails.cache.fetch('firebase_securetoken_cirtificate', expires: 1.hour) do
      uri = URI.parse(CIRTIFICATE_URL)
      JSON.parse(Net::HTTP.get_response(uri).body).fetch(decoded_token_header["kid"])
    end

    public_key = OpenSSL::X509::Certificate.new(certificate).public_key
    decoded_token_payload, _ = JWT.decode(
      id_token,
      public_key,
      true,
      exp_leeway: EXP_LEEWAY,   # 有効期限の検証をするが、ゆるめに。 EXP_LEEWAY 秒は大目に見る。
      verify_iat: true,         # 発行時の検証をする
      aud: firebase_project_id,
      verify_aud: true,         # 対象の検証をする
      iss: valid_iss,
      verify_iss: true,         # 発行元の検証をする
      verify_sub: true,         # 件名の存在を検証する
      algorithm: decoded_token_header["alg"]
    )

    Rails.logger.debug("decoded_token_payload: #{decoded_token_payload.inspect}")

    # decoded_token_payload = {
    #   "name"=>"Takeuchi Yuichi",
    #   "picture"=>"https://lh3.googleusercontent.com/a-/AAuE7mAZU7Rh7lIFStzfWGe3tC24qDIX4UIoEWR8426flA",
    #   "iss"=>"https://securetoken.google.com/rails-firebase-sample",
    #   "aud"=>"rails-firebase-sample",
    #   "auth_time"=>1580712233,
    #   "user_id"=>"Qgk3sd1HgoPLVbSy8uXAWnRmWmx1",
    #   "sub"=>"Qgk3sd1HgoPLVbSy8uXAWnRmWmx1",
    #   "iat"=>1580712233,
    #   "exp"=>1580715833,
    #   "email"=>"takeyuweb@gmail.com",
    #   "email_verified"=>true,
    #   "firebase"=>{
    #     "identities"=>{
    #       "google.com"=>["100008179958237311525"],
    #       "email"=>["takeyuweb@gmail.com"]
    #     },
    #     "sign_in_provider"=>"google.com"
    #   }
    # }

    where(uid: decoded_token_payload.fetch("sub")).first_or_create
  end
end
require 'rails_helper'

RSpec.describe "/session", type: :request do
  describe 'create session' do
    context "with valid id_token" do
      it "returns Created" do
        stub_id_token do |id_token|
          post "/session", params: { id_token: id_token }
          expect(response).to have_http_status(:created)
        end
      end
    end

    context "with invalid id_token" do
      it "returns Unauthorized " do
        stub_id_token("exp" => 0) do |id_token|
          post "/session", params: { id_token: id_token }
          expect(response).to have_http_status(:unauthorized)
        end
      end
  end
end
# spec/support/firebase.rb

module FirebaseIdTokenGenerator
  def stub_id_token(override = {}, &block)
    unless defined?(@pkey)
      @pkey, @cert = generate_key_pair
      @kid = 'thekeyid'

      certificates = {
        'dummy' => generate_key_pair[1].to_pem,
        @kid => @cert.to_pem
      }
      WebMock.stub_request(:get, User::CIRTIFICATE_URL).to_return(status: 200, body: certificates.to_json)
    end

    block.call(get_id_token(generate_payload(override)))
  end

  def get_id_token(payload)
    JWT.encode(payload, @pkey, 'RS256', { kid: @kid, typ: 'JWT' })
  end

  def generate_payload(override = {})
    # https://firebase.google.com/docs/auth/admin/verify-id-tokens?hl=ja
    firebase_project_id = Rails.application.credentials.dig(:firebase, :project_id)
    {
      "name"=>"Takeuchi Yuichi",
      "picture"=>"https://test.host/picture.jpeg",
      "iss" => "https://securetoken.google.com/#{firebase_project_id}",
      "aud" => firebase_project_id,
      "auth_time"=> Time.current.to_i,
      "user_id"=>"theuserid",
      "sub"=>"theuserid",
      "iat"=> 1.hour.ago.to_i,
      "exp"=> 1.hour.from_now.to_i,
      "email"=>"yuichi.takeuchi@takeyuweb.co.jp",
      "email_verified"=>true,
      "firebase"=>{
        "identities"=>{
          "google.com"=>["000000000000000000000"],
          "email"=>["yuichi.takeuchi@takeyuweb.co.jp"]
        },
        "sign_in_provider"=>"google.com"
      }
    }.deep_merge(override)
  end

  def generate_key_pair
    ca_passphrase = SecureRandom.alphanumeric
    digest = OpenSSL::Digest::SHA1.new

    issu = OpenSSL::X509::Name.new
    issu.add_entry('C' , 'JP')
    issu.add_entry('ST', 'Saitama')
    issu.add_entry('DC', 'Omiya-ku')
    issu.add_entry('O' , 'TakeyuWeb, Inc.')
    issu.add_entry('CN', 'MexiCasita Test CA')

    issu_rsa = OpenSSL::PKey::RSA.generate(2048)

    issu_cer = OpenSSL::X509::Certificate.new
    issu_cer.not_before = Time.current
    issu_cer.not_after  = 10.years.from_now
    issu_cer.public_key = issu_rsa.public_key
    issu_cer.serial  = 1
    issu_cer.issuer  = issu
    issu_cer.subject = issu
    ex = OpenSSL::X509::Extension.new('basicConstraints', OpenSSL::ASN1.Sequence([OpenSSL::ASN1::Boolean(true)]))
    issu_cer.add_extension(ex)
    issu_cer.sign(issu_rsa, digest)

    return [issu_rsa, issu_cer]
  end
end

RSpec.configure do |config|
  config.include FirebaseIdTokenGenerator

  config.after(:all, type: :request) do
    remove_instance_variable(:@pkey) if defined?(@pkey)
  end
end

rspec で ActionMailer の deliver_later のジョブはすぐに処理する

ActiveJob へのキューインの spec で示したように ActiveJob::Base.queue_adapter = :test を使うと、 perform_later のジョブはキューに溜まるようになる。

しかし、 ActionMailer の deliver_later で非同期送信を使っている場合にまでキューインされてしまうと、 「メールを送信する」ことのテストを書きづらいことがある。

まとめ

  • ActiveJob::TestHelper に含まれる perform_enqueued_jobs を使うと、ブロック内ではエンキューされたジョブはすぐに実行される
  • only: ActionMailer::MailDeliveryJob と指定すれば、メール送信のみすぐに実行できる

サンプルコード

include ActiveJob::TestHelper

it "It sends the server key" do
  expect {
    perform_enqueued_jobs(only: ActionMailer::MailDeliveryJob) { NotifyJob.perform_now(user) }
  }.to change(ActionMailer::Base.deliveries, :count).by(1)
  
  mail = ActionMailer::Base.deliveries.last
  expect(mail.attachments.map(&:filename)).to match_array(["key.pem"])
end

ActiveJob::TestHelper

api.rubyonrails.org

ActiveJob へのキューインの spec

まとめ

  • ActiveJob::Base.queue_adapter = :test を使うと、 ActiveJob::Base.queue_adapter.enqueued_jobs にジョブが入る
  • have_enqueued_job マッチャーでジョブが入っていることを expect する

サンプルコード

it 'enqueues PaymentIntentSuccessedJob' do
  ActiveJob::Base.queue_adapter = :test

  event = StripeMock.mock_webhook_event('payment_intent.succeeded', customer: customer.id)
  expect {
    post "/webhook", params: event.to_h, as: :json
  }.to have_enqueued_job(PaymentIntentSuccessedJob).with(event.id)
end

aws-sdk-ruby で AWS CodeCommit の SSH キーを登録する

まとめ

  • SSHキーペア生成は OpenSSL::PKey::RSA#generate を使う
  • アップロードは Aws::IAM::Client#upload_ssh_public_key を使う

事の起こり

開発中のWebシステムでは、必要に応じてAWS上のリソースを生成しています。 CodeCommitへのコミットに使用するIAMユーザを作成、Git ssh接続用のキーペアを生成し、秘密鍵を使って接続できるようにする一連の作業を自動化することにしました。

実施内容

SSHキーペア生成

必要な鍵の仕様について、ドキュメントによれば

The public key must be encoded in ssh-rsa format or PEM format. The minimum bit-length of the public key is 2048 bits, and the maximum length is 16384 bits.

ということで、

  • 最低2048ビット
  • OpenSSH形式かPEM形式の鍵

を作ればよいようです。

docs.aws.amazon.com

PEM形式であればRuby標準のライブラリだけで生成できます。

docs.ruby-lang.org

rsa = OpenSSL::PKey::RSA.generate(2048)
public_key_body = rsa.public_key.to_pem
private_key_body = rsa.to_pem

なお、OpenSSH形式の場合は net-ssh gem が必要です。

stackoverflow.com

公開鍵アップロード

CodeCommitで使うものですが、IAMのSDKを使います。

iam = Aws::IAM::Client.new(region: aws_region, credentials: credentials)
ssh_public_key = iam.upload_ssh_public_key(
  user_name: user_name,  # IAM ユーザー名
  ssh_public_key_body: public_key_body,
).ssh_public_key

ssh_public_key_id = ssh_public_key.ssh_public_key_id # => "APKXXXXXXXXXXXXXXXXX"
File.open("#{ssh_public_key_id}.pem", "wb") do |file|
  file.write private_key_body
end
鍵を使う

sshユーザー名は Aws::IAM::Client#upload_ssh_public_key の応答に含まれる ssh_public_key_id を使います。

こんな .ssh/config を使うとよいでしょう。

Host user_name.codecommit
      HostName git-codecommit.ap-northeast-1.amazonaws.com
      User APKXXXXXXXXXXXXXXXXX
      Port 22
      IdentityFile ~/.ssh/APKXXXXXXXXXXXXXXXXX.pem
      IdentitiesOnly yes
$ git clone ssh://user_name.codecommit/v1/repos/repository-name

WSLのバックアップとインポート

まとめ

  • wsl --exportwsl --import
  • インポートした後はデフォルトのユーザーが root になってしまうので、設定してやる必要がある
  • .bat を書けばバックアップをとってからシャットダウン、もできる

forest.watch.impress.co.jp

事の起こり

  • WSL2をゴリゴリ仕事で使っている
  • 何かあったとき戻せるようにしたい
  • ツール類の再インストール作業などなるべくしたくない
  • プロビジョニングツールはツールの更新で動かなくなったりしてつらい
  • スナップショットとりたい

バッチファイルを書いた

wsl-backup.bat

@echo off

echo %date%
echo %time%

set yyyy=%date:~0,4%
set mm=%date:~5,2%
set dd=%date:~8,2%

set time2=%time: =0%

set hh=%time2:~0,2%
set mn=%time2:~3,2%
set ss=%time2:~6,2%

set timestamp=%yyyy%-%mm%-%dd%-%hh%-%mn%-%ss%
wsl --export Ubuntu D:\backups\wsl\Ubuntu_%timestamp%.tar

shutdown.bat

@echo off

call D:\wsl-backup.bat

shutdown /s /t 30 /c "Shut down after 30 seconds."

仕事終わりに shutdown.bat を「管理者で実行」

復元方法

WSL2のセットアップを終えた環境で

wsl --import <NAME> <PATH> <FILE>

wsl --import Ubuntu "C:\wsl\Ubuntu" D:\backups\wsl\Ubuntu_2020-05-30-11-19-12.tar

デフォルトのユーザーを設定

そのままだと root になるので、デフォルトのユーザーを指定します。

C:\Users\yuichi> wsl
Last login: Fri Apr 17 21:35:43 JST 2020 on pts/5
Welcome to Ubuntu 18.04.4 LTS (GNU/Linux 5.4.72-microsoft-standard-WSL2 x86_64)

root@PANDA:~#
Function WSL-SetDefaultUser ($distro, $user) { Get-ItemProperty Registry::HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Lxss\*\ DistributionName | Where-Object -Property DistributionName -eq $distro | Set-ItemProperty -Name DefaultUid -Value ((wsl -d $distro -u $user -e id -u) | Out-String); };
WSL-SetDefaultUser <DistroName> <UserName>

WSL-SetDefaultUser Ubuntu takeyuweb

github.com

AWS SDK for Ruby で EC2 ImageBuilder のパイプラインを作成する

まとめ

事の起こり

  • EC2 ImageBuilder でゴールデンAMIを作成している
  • EC2 ImageBuilder のコンポーネントを変更する際、それを使うようにパイプラインを更新するには、コンポーネント、レシピ、パイプラインとそれぞれ作り直す必要があり、面倒
  • AWSマネジメントコンソール上からだと設定できないオプション(たとえばイメージ作成に使用するEC2インスタンスのEBSボリュームサイズ)がある
  • CloudFormationでは作成できない

AWS SDKを用いてコード化し、管理可能かつ実行可能にすることで、作業を効率化したくなりました。

サンプルコード

CloudFormation

S3バケットやEC2インスタンスプロファイルなどはCloudFormationで作成していたのでそれを使いました。 スタックのOutputsでRubyスクリプトに渡します。

ポイントはEC2インスタンスに割り当てるロールです。

Mappings:
  SubnetConfig:
    VPC:
      CIDR: "10.0.0.0/16"
    Private:
      CIDR: "10.0.1.0/24"
    Public:
      CIDR: "10.0.2.0/24"
Resources:
  ImageBuilderRole:
    Type: "AWS::IAM::Role"
    DeletionPolicy: Retain
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - ec2.amazonaws.com
            Action:
              - "sts:AssumeRole"
      Path: /
      ManagedPolicyArns:
        - "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
        - "arn:aws:iam::aws:policy/EC2InstanceProfileForImageBuilder"
      Policies:
        - PolicyName: allow-put-log
          PolicyDocument:
            Statement:
              - Effect: Allow
                Action:
                  - "s3:*"
                Resource:
                  - !GetAtt BuildLogBucket.Arn
                  - !Join ["/", [!GetAtt BuildLogBucket.Arn, "*"]]

  EIP:
    Type: AWS::EC2::EIP
    Properties:
      Domain: vpc

  VPC:
    Type: AWS::EC2::VPC
    Properties:
      EnableDnsSupport: true
      EnableDnsHostnames: true
      CidrBlock: !FindInMap [SubnetConfig, VPC, CIDR]
  PrivateSubnet:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone: !Select
        - 0
        - Fn::GetAZs: !Ref "AWS::Region"
      VpcId: !Ref "VPC"
      CidrBlock: !FindInMap ["SubnetConfig", "Private", "CIDR"]
  PublicSubnet:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone: !Select
        - 0
        - Fn::GetAZs: !Ref "AWS::Region"
      VpcId: !Ref "VPC"
      CidrBlock: !FindInMap ["SubnetConfig", "Public", "CIDR"]
  InternetGateway:
    Type: AWS::EC2::InternetGateway
  GatewayAttachement:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref VPC
      InternetGatewayId: !Ref InternetGateway
  NatGateway:
    Type: AWS::EC2::NatGateway
    Properties:
      AllocationId: !GetAtt EIP.AllocationId
      SubnetId: !Ref PublicSubnet
  PrivateRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref "VPC"
  PublicRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref "VPC"
  PrivateRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref "PrivateRouteTable"
      DestinationCidrBlock: "0.0.0.0/0"
      NatGatewayId: !Ref NatGateway
  PublicRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref "PublicRouteTable"
      DestinationCidrBlock: "0.0.0.0/0"
      GatewayId: !Ref "InternetGateway"
  PrivateSubnetRouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PrivateSubnet
      RouteTableId: !Ref PrivateRouteTable
  PublicSubnetRouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnet
      RouteTableId: !Ref PublicRouteTable

  EC2SecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Access to EC2 Instances
      VpcId: !Ref "VPC"

  ImageBuilderInstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      Path: "/"
      Roles:
        - !Ref ImageBuilderRole

  BuildLogBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: buildlog
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      LifecycleConfiguration:
        Rules:
          - Status: Enabled
            ExpirationInDays: 30
            AbortIncompleteMultipartUpload:
              DaysAfterInitiation: 7

Outputs:
  PrivateSubnetId:
    Value: !Ref PrivateSubnet
  EC2SecurityGroupId:
    Value: !Ref EC2SecurityGroup
  BuildLogBucket:
    Value: !Ref BuildLogBucket
  ImageBuilderInstanceProfile:
    Value: !Ref ImageBuilderInstanceProfile

Ruby

CloudFormationのスタックから必要な設定値を取得するようにしました。 ここはコマンド引数などで与えてもよいですし、アプリ等に組み込むなら関数にして引数として与えてもよいでしょう。

require "aws-sdk"
require "securerandom"

semantic_version = Time.now.strftime('0.%Y%m%d.%H%M%S')
imagebuilder = Aws::Imagebuilder::Client.new(region: AWS_REGION)

def get_stack_output(name)
  stack_outputs[name]
end

def stack_outputs
  return @stack_outputs if defined?(@stack_outputs)

  cloud_formation = Aws::CloudFormation::Client.new(region: AWS_REGION)
  stack = cloud_formation.describe_stacks(stack_name: STACK_NAME).stacks[0]
  @stack_outputs = stack.outputs.inject({}) { |memo, output| memo.tap { |memo| memo[output.output_key] = output.output_value } }
end

component_build_version_arn = imagebuilder.create_component(
  name: COMPONENT_NAME, # required
  semantic_version: semantic_version, # required
  platform: "Linux", # required, accepts Windows, Linux
  data: COMPONENT_YAML,
  client_token: SecureRandom.uuid, # required
).component_build_version_arn

image_recipe_arn = imagebuilder.create_image_recipe(
  name: RECIPE_NAME,
  semantic_version: semantic_version, # required
  components: [ # required
    {
      component_arn: 'arn:aws:imagebuilder:ap-northeast-1:aws:component/update-linux/1.0.0',
    },
    {
      component_arn: component_build_version_arn,
    },
    {
      component_arn: 'arn:aws:imagebuilder:ap-northeast-1:aws:component/simple-boot-test-linux/1.0.0',
    },
    {
      component_arn: 'arn:aws:imagebuilder:ap-northeast-1:aws:component/reboot-test-linux/1.0.0',
    }
  ],
  parent_image: "arn:aws:imagebuilder:ap-northeast-1:aws:image/amazon-linux-2-x86/x.x.x", # required
  block_device_mappings: [
    {
      device_name: "/dev/xvda",
      ebs: {
        encrypted: false,
        delete_on_termination: true,
        volume_size: 16,
        volume_type: "gp2", # accepts standard, io1, gp2, sc1, st1
      }
    },
  ],
  client_token: SecureRandom.uuid, # required
).image_recipe_arn

infrastructure_configuration_arn = imagebuilder.create_infrastructure_configuration({
  name: INFRASTRUCTURE_CONFIGURATION_NAME, # required
  instance_types: ["m5a.large"],
  instance_profile_name: get_stack_output('ImageBuilderInstanceProfile'), # required
  security_group_ids: [get_stack_output('EC2SecurityGroupId')],
  subnet_id: get_stack_output('PrivateSubnetId'),
  logging: {
    s3_logs: {
      s3_bucket_name: get_stack_output('BuildLogBucket'),
      s3_key_prefix: "path/to/logs/",
    },
  },
  terminate_instance_on_failure: true,
  client_token: SecureRandom.uuid, # required
}).infrastructure_configuration_arn

distribution_configuration_arn = imagebuilder.create_distribution_configuration({
  name: DISTRIBUTION_CONFIGURATION_NAME, # required
  distributions: [ # required
    {
      region: "ap-northeast-1", # required
      ami_distribution_configuration: {
        name: IMAGE_NAME, # e.g. "my-app {{imagebuilder:buildDate}}"
        ami_tags: {
          "TagName" => "TagValue"
        },
      }
    },
    {
      region: "us-west-2", # required
      ami_distribution_configuration: {
        name: IMAGE_NAME,
        ami_tags: {
          "TagName" => "TagValue"
        },
      }
    },
  ],
  client_token: SecureRandom.uuid, # required
}).distribution_configuration_arn


image_pipeline_arn = imagebuilder.list_image_pipelines(
  filters: [
    {
      name: "name",
      values: [IMAGE_PIPELINE_NAME],
    },
  ],
).image_pipeline_list[0]&.arn
if image_pipeline_arn
  imagebuilder.delete_image_pipeline(image_pipeline_arn: image_pipeline_arn)
end
imagebuilder.create_image_pipeline({
  name: IMAGE_PIPELINE_NAME, # required
  image_recipe_arn: image_recipe_arn, # required
  infrastructure_configuration_arn: infrastructure_configuration_arn, # required
  distribution_configuration_arn: distribution_configuration_arn,
  image_tests_configuration: {
    image_tests_enabled: true,
    timeout_minutes: 60,
  },
  schedule: {
    schedule_expression: "cron(0 0 * * tue)",
    pipeline_execution_start_condition: "EXPRESSION_MATCH_AND_DEPENDENCY_UPDATES_AVAILABLE", # accepts EXPRESSION_MATCH_ONLY, EXPRESSION_MATCH_AND_DEPENDENCY_UPDATES_AVAILABLE
  },
  status: "DISABLED", # accepts DISABLED, ENABLED
  client_token: SecureRandom.uuid, # required
})