Salesforce B2C Commerce SFRAでCI/CDやってみる


※ これから記載する事項は、私が所属する会社とは一切関係のない事柄です。

この記事では前回紹介した [Salesforce B2C Commerce sfcc-ciについて](https://qiita.com/syunyama/items/bdb67b187df2e6c25972) と [Salesforce B2C Commerce カートリッジソースコードのテストについて](https://qiita.com/syunyama/items/506edc77a660f61252fa) を応用して[CircleCI](https://circleci.com/ja/)を利用し、子ブランチがstagingにマージされてからデプロイされるまでの流れを作ってみました。


## 今回目指す仕様

* stagingブランチへプッシュ(マージ)された時に実行される
* コンテナ内に依存関係のインストール
* ユニットテストの実行
* 静的リソースのビルド
* ソースコードのデプロイ
* ソースコードのアクティブ化
* インテグレーションテストの実行
* E2Eテストの実行
* `/cartridges` 内にカートリッジを追加していく
* `/sample_sitedata` にサイトにインポートするソースコード追加する
* インテグレーション又はE2Eテストでエラーだった場合はソースコードはサーバーに残りますが、以前アクティブだったソースコードバージョンに戻します
![スクリーンショット 2022-06-08 9.35.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2110894/faec5113-2ed3-296e-9035-96dfb9bcfc32.png)



## 今回取り扱わない内容

* PR前のコミット時のLintチェック
* feature/bugfixなどの子ブランチへプッシュ後のユニットテスト
* テストコードの内容。
* デプロイ前のstaging環境の確認
* コードのレプリケーション
* CircleCIの使い方
* プリビルドした image は使わないので実行に時間かかります
* 並列処理、キャッシュなどの高速化はしていません。[高速したい場合の参考記事](https://circleci.com/ja/blog/six-optimization-tips-for-your-config/)。
* キャッシュクリアや再インデックス作成



## 前提

* ローカルマシンとして、macOS Big Sur version 11.6.6 を利用しました
* sfcc-ciを利用するための設定はあらかじめ [Salesforce B2C Commerce sfcc-ciについて](https://qiita.com/syunyama/items/bdb67b187df2e6c25972) を参照してください
* SFRAにデフォルトで入っているRefArchサイトを利用しました。




## 実装内容

### 1. SFRAのソースコードのコピー

[SFRA](https://github.com/SalesforceCommerceCloud/storefront-reference-architecture) のソースコードをローカルにコピーします。



### 2. スクリプトの設置

今回は下記の４つのスクリプトを作成しました。このスクリプトをソースコードのルートディレクトリに置きます。

* cicd-install.sh
* cicd-environment.sh
* cicd-deploy.sh
* cicd-test.sh

![スクリーンショット 2022-06-08 9.01.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2110894/edafad5c-02e9-ea37-5eca-59bd3d94373a.png)


```sh:cicd-install.sh
#!/bin/sh
set -eu
# set -x

## 依存関係をインストール
sudo apt-get update
sudo apt-get install -y libgtk-3.0 libgbm-dev libnss3 libatk-bridge2.0-0 libasound2

## sfcc-ci をインストール
wget https://github.com/SalesforceCommerceCloud/sfcc-ci/releases/download/v2.9.1/sfcc-ci-linux
chmod +x ./sfcc-ci-linux
sudo mv ./sfcc-ci-linux /usr/local/bin/sfcc-ci

## NPM パッケージをインストール
npm install
npm install webpack-cli -D
npx create-codeceptjs .
npm install --save-dev puppeteer
npm audit fix
```


```sh:cicd-environment.sh
#!/bin/sh
set -eu
# set -x

case $CIRCLE_BRANCH in
    "staging")
        export SFCC_HOST=${SFCC_HOST_STAGING}
        export SFCC_OAUTH_CLIENT_ID=${SFCC_OAUTH_CLIENT_ID_STAGING}
        export SFCC_OAUTH_CLIENT_SECRET=${SFCC_OAUTH_CLIENT_SECRET_STAGING}
        ;;
esac

# 環境変数にセットする
echo 'export SFCC_HOST='$SFCC_HOST >> $BASH_ENV
echo 'export SFCC_OAUTH_CLIENT_ID='$SFCC_OAUTH_CLIENT_ID >> $BASH_ENV
echo 'export SFCC_OAUTH_CLIENT_SECRET='$SFCC_OAUTH_CLIENT_SECRET >> $BASH_ENV
```




```sh:cicd-deploy.sh
#!/bin/sh
set -eu
# set -x

if [ -z "$SFCC_HOST" ] || [ -z "$SFCC_OAUTH_CLIENT_ID"] || [ -z "$SFCC_OAUTH_CLIENT_SECRET" ]; then
  echo "No credential or host found."
  exit 1
fi

# シェルのあるディレクトリパスの取得
THIS_FILE_DIR=$(cd $(dirname $0); pwd)

# カートリッジとサイトデータのディレクトリ名の定義
CARTRIDGE_DIR_NAME="cartridges"
SITEDATA_DIR_NAME="sample_sitedata"

# カートリッジとサイトデータのディレクトリパスとバージョンの定義
CARTRIDGE_DIR=$THIS_FILE_DIR"/"$CARTRIDGE_DIR_NAME
SITEDATA_DIR=$THIS_FILE_DIR"/"$SITEDATA_DIR_NAME
NEW_VERSION="version_"`date +'%Y%m%d%H%M%S'`

# 一時ディレクトリの定義と作成
TMP_DIR=$THIS_FILE_DIR"/build"
mkdir -p $TMP_DIR

# 静的リソースのビルド
npx webpack

# 一時ディレクトリにソースを移動
cp -rf $CARTRIDGE_DIR $TMP_DIR
cp -rf $SITEDATA_DIR $TMP_DIR

# 一時ディレクトリでzip圧縮
cd $TMP_DIR
mv $CARTRIDGE_DIR_NAME $NEW_VERSION
zip -r $NEW_VERSION".zip" $NEW_VERSION
zip -r $SITEDATA_DIR_NAME".zip" $SITEDATA_DIR_NAME

# シェルのあるディレクトリパスに戻る
cd $THIS_FILE_DIR

# sfcc-ciのデバッグを利用
export DEBUG=true

# sfcc-ciの認証
sfcc-ci client:auth

# 現在最新のバージョンを取得
LATEST_VERSION=`sfcc-ci code:list -j -i $SFCC_HOST | jq -R 'fromjson?' | jq '[.data[]]' | jq 'map(select(.active==true))' | jq -r '.[0].id'`

# サイトデータのデプロイとソースコードのアクティブ化
sfcc-ci instance:upload $TMP_DIR"/"$SITEDATA_DIR_NAME".zip" -i $SFCC_HOST
sfcc-ci instance:import $SITEDATA_DIR_NAME".zip" -fjs -i $SFCC_HOST

# カートリッジのデプロイとソースコードのアクティブ化
sfcc-ci code:deploy $TMP_DIR"/"$NEW_VERSION".zip" -i $SFCC_HOST
sfcc-ci code:activate $NEW_VERSION -i $SFCC_HOST

# 一時ディレクトリの削除
rm -rf $TMP_DIR

# バージョンを環境変数にセットする
echo "=========新しいバージョン========="
echo $NEW_VERSION
echo "=========直近の最新バージョン========="
echo $LATEST_VERSION
echo 'export LATEST_VERSION='$LATEST_VERSION >> $BASH_ENV
echo 'export NEW_VERSION='$NEW_VERSION >> $BASH_ENV
```


```sh:cicd-test.sh
#!/bin/sh
set -eu
# set -x

catch(){
    if [ "$?" = 1 ]; then
        echo "テストエラーのため、"$LATEST_VERSION" バージョンを再度アクティブにします"
        sfcc-ci code:activate $LATEST_VERSION -i $SFCC_HOST
    fi
}
trap catch EXIT

# インテグレーションテスト
npm run test:integration -- --baseUrl $SFCC_HOST

# E2Eテスト
npx codeceptjs run --verbose --grep @happyPath
```



### 3. Webpackの設定

下記コマンドで `/cartridges` 内のカートリッジのSCSSとJSファイルをビルドするための設定を行います。

```sh
npx webpack
```

* `npm install webpack-cli -D` を実行してwebpack コマンドを利用できるようにする
* ` webpack.conf.js` を下記のように書き換えます。

```javascript:webpack.conf.js
"use strict";

var path = require("path");
var webpack = require("sgmf-scripts").webpack;
var ExtractTextPlugin = require("sgmf-scripts")["extract-text-webpack-plugin"];
const glob = require("glob");
const cwd = process.cwd();

var bootstrapPackages = {
  Alert: "exports-loader?Alert!bootstrap/js/src/alert",
  // Button: 'exports-loader?Button!bootstrap/js/src/button',
  Carousel: "exports-loader?Carousel!bootstrap/js/src/carousel",
  Collapse: "exports-loader?Collapse!bootstrap/js/src/collapse",
  // Dropdown: 'exports-loader?Dropdown!bootstrap/js/src/dropdown',
  Modal: "exports-loader?Modal!bootstrap/js/src/modal",
  // Popover: 'exports-loader?Popover!bootstrap/js/src/popover',
  Scrollspy: "exports-loader?Scrollspy!bootstrap/js/src/scrollspy",
  Tab: "exports-loader?Tab!bootstrap/js/src/tab",
  // Tooltip: 'exports-loader?Tooltip!bootstrap/js/src/tooltip',
  Util: "exports-loader?Util!bootstrap/js/src/util",
};

const idEmptyObj = (obj) => {
  return !Object.keys(obj).length;
};

const createJsPath = (cartridgeName) => {
  const result = {};
  const jsFiles = glob.sync(
    `./cartridges/${cartridgeName}/cartridge/client/default/js/*.js`
  );
  jsFiles.forEach((filePath) => {
    let location = path.relative(
      path.join(cwd, `./cartridges/${cartridgeName}/cartridge/client`),
      filePath
    );
    location = location.substr(0, location.length - 3);
    result[location] = filePath;
  });
  return result;
};

const constcreateScssPath = (cartridgeName) => {
  const result = {};
  const cssFiles = glob.sync(`./cartridges/${cartridgeName}/cartridge/client/default/scss/**/*.scss`);
  cssFiles.forEach((filePath) => {
    const name = path.basename(filePath, ".scss");
    if (name.indexOf("_") !== 0) {
      let location = path.relative(
        path.join(cwd, `./cartridges/${cartridgeName}/cartridge/client`),
        filePath
      );
      location = location.substr(0, location.length - 5).replace("scss", "css");
      result[location] = filePath;
    }
  });
  return result;
};

const getCSSConf = (cartridgeName) => {
  const scssFiles = constcreateScssPath(cartridgeName);
  if (idEmptyObj(scssFiles)) {
    return;
  }
  return {
    mode: "none",
    name: "scss",
    entry: scssFiles,
    output: {
      path: path.resolve(`./cartridges/${cartridgeName}/cartridge/static`),
      filename: "[name].css",
    },
    module: {
      rules: [
        {
          test: /\.scss$/,
          use: ExtractTextPlugin.extract({
            use: [
              {
                loader: "css-loader",
                options: {
                  url: false,
                  minimize: true,
                },
              },
              {
                loader: "postcss-loader",
                options: {
                  plugins: [require("autoprefixer")()],
                },
              },
              {
                loader: "sass-loader",
                options: {
                  includePaths: [
                    path.resolve("node_modules"),
                    path.resolve("node_modules/flag-icon-css/sass"),
                  ],
                },
              },
            ],
          }),
        },
      ],
    },
    plugins: [new ExtractTextPlugin({ filename: "[name].css" })],
  };
};



const getJSConf = (cartridgeName) => {
  const jsFiles = createJsPath(cartridgeName);
  if (idEmptyObj(jsFiles)) {
    return;
  }
  return {
    mode: "production",
    name: "js",
    entry: jsFiles,
    output: {
      path: path.resolve(`./cartridges/${cartridgeName}/cartridge/static`),
      filename: "[name].js",
    },
    module: {
      rules: [
        {
          test: /bootstrap(.)*\.js$/,
          use: {
            loader: "babel-loader",
            options: {
              presets: ["@babel/env"],
              plugins: ["@babel/plugin-proposal-object-rest-spread"],
              cacheDirectory: true,
            },
          },
        },
      ],
    },
    plugins: [new webpack.ProvidePlugin(bootstrapPackages)],
  };
};

let configs = [];

var cartridgeDirs = glob.sync("cartridges/*/", {
  matchBase: true,
  ignore: "**/node_modules/**",
});
cartridgeDirs.forEach((file) => {
  const cartridgeName = path.basename(file);
  const jsConf = getJSConf(cartridgeName);
  if (jsConf) {
    configs.push(jsConf);
  }
  const scssConf = getCSSConf(cartridgeName)
  if (scssConf) {
    configs.push(scssConf);
  }
});

module.exports = configs;
```




### 4. CircleCIの設定

`/.circleci/config.yml` 内に下記の内容を記載。

```yml:.circleci/config.yml
version: 2.1

executors:
  my-executor:
    docker:
      - image: cimg/node:14.19.2

jobs:
  install_test_deploy:
    executor: my-executor
    working_directory: ~/workspace
    steps:
      - checkout
      - run:
          name: Install
          command: sh cicd-install.sh
      - run:
          name: Unit Test
          command: npm test
      - run:
          name: Set environment
          command: sh cicd-environment.sh
      - run:
          name: Deploy
          command: sh cicd-deploy.sh
      - run:
          name: Integration and E2E Test
          command: sh cicd-test.sh


workflows:
  my-workflow:
    jobs:
      - install_test_deploy:
          filters:
            branches:
              only:
                - staging
```



### 5. codeceptjs の設定

ルートディレクトリの `codecept.conf.js` に下記の内容を記載。 

```javascript:codecept.conf.js
const { setHeadlessWhen, setCommonPlugins } = require("@codeceptjs/configure");
const cwd = process.cwd();
const fs = require("fs");
const path = require("path");
setHeadlessWhen(process.env.HEADLESS);
setCommonPlugins();

function getDwJson() {
  if (fs.existsSync(path.join(cwd, "dw.json"))) {
    return require(path.join(cwd, "dw.json"));
  }
  return {};
}

const DEFAULT_HOST = getDwJson().hostname
  ? "https://" + getDwJson().hostname
  : "";
const HOST = DEFAULT_HOST || "https://" + process.env.SFCC_HOST;

const metadata = require("./test/acceptance/metadata.json");

exports.config = {
  gherkin: {
    features: "./test/acceptance/features/**/*.feature",
    steps: "./test/acceptance/steps/**/*.js",
  },
  cleanup: true,
  coloredLogs: true,
  output: "./output",
  helpers: {
    Puppeteer: {
      url: HOST,
      show: false,
      windowSize: "1200x900",
      waitForTimeout: 100000,
      chrome: {
        args: ["--no-sandbox"],
      },
    },
  },
  include: metadata.include,
  bootstrap: null,
  mocha: {},
  plugins: {
    screenshotOnFail: {
      enabled: true,
    },
    retryFailedStep: {
      enabled: true,
    },
  },
  name: "sample-cicd",
};
```

### 

### 6. その他

* `/output` にE2Eの結果が出力されますが デフォルトのgitignoreに入っていないので、入れておいてもいいかと思います


:::note alert
[Salesforce B2C Commerce カートリッジソースコードのテストについて](https://qiita.com/syunyama/items/506edc77a660f61252fa) で紹介した通りローカルでもテスト可能なので、_**stagingにマージする前に一度ローカルでテストを必ず行い、テストコードを修正してください**_
:::



## ローカルでの実行

CircleCI はローカルでも実行できます。　詳細は[CircleCI のローカル CLI の使用](https://circleci.com/docs/ja/2.0/local-cli/)をご覧ください。
今回の実装を実行する場合は、下記のコマンドを実行します。

```sh
# .circleci/config.yml が有効かどうかの確認 （必ずしも毎回する必要はない）
circleci config validate

# ジョブの実行
circleci local execute --job install_test_deploy -e SFCC_OAUTH_CLIENT_ID="{APIクライアントID}" -e SFCC_OAUTH_CLIENT_SECRET="{APIクライアントシークレット}" -e SFCC_HOST="{デプロイ先のホスト名}"
```

* APIクライアントIDとAPIクライアントシークレットには [Salesforce B2C Commerce sfcc-ciについての2. Account Managerで設定](https://qiita.com/syunyama/items/bdb67b187df2e6c25972#2-account-manager%E3%81%A7%E3%81%AE%E8%A8%AD%E5%AE%9A)で作成したID・パスワードを利用します。
* デプロイ先のホスト名にはデプロイ先のホスト名を入力します。例：XXXXXX.sandbox.us00.dx.commercecloud.salesforce.com


:::note alert
※ 私のMacではうまくCircle CIが動かなかったのでDockere Desktopのバージョンを4.2.0にダウングレードしました。
Mac OS上でのcircle ciの問題：
https://github.com/CircleCI-Public/circleci-cli/issues/672
https://stackoverflow.com/questions/62217678/can-i-roll-back-to-a-previous-version-of-docker-desktop
:::


## Circle CI上での実行

### Circle CIとGithubレポジトリを繋げる

コピーしたSFRAソースコードをアップロードしたGithubレポジトリをCircleCIと連携し、プロジェクトを作成します。
方法については割愛しますが、[CircleCIのブログ](https://circleci.com/ja/blog/setting-up-continuous-integration-with-github/)など参照してみてください。
同時にstagingブランチも作っておいてください。


### Circle CIへの環境変数の設定

作成したCircle CIプロジェクトの設定から環境変数を下記のように設定します。それぞれ、ローカルで実行時に引数として入力した値です。`_STAGING` という値がついていますが、この変数の扱いについては `cicd-environment.sh` のソースコードをご覧ください。 

* SFCC_HOST_STAGING
* SFCC_OAUTH_CLIENT_ID_STAGING
* SFCC_OAUTH_CLIENT_SECRET_STAGING

![スクリーンショット 2022-06-08 10.04.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2110894/9a6858ba-794d-bb84-c8e4-ad1cda8f9302.png)



## 実行結果


`feature/test` というブランチを作成し、`staging` にマージしました。
![スクリーンショット 2022-06-08 10.11.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2110894/f8a9e6bf-2f0b-63b5-aeb3-f36172881e55.png)



すると、ワークフロージョブが実行され、無事完了しました。
![スクリーンショット 2022-06-08 10.11.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2110894/92c43e3e-9ad0-6d90-ab04-5a99afb66119.png)


B2C Commerceの環境にもデプロイされアクティブ化されていることがわかります。

![スクリーンショット 2022-06-08 11.31.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2110894/59c0d9f5-2954-0c24-b074-fcde2b510721.png)



## 参考

B2C Commerceのコードのレプリケーション：
https://trailhead.salesforce.com/ja/content/learn/modules/b2c-admin-replication/b2c-admin-explore-replication

CircleCIの使い方：
https://qiita.com/gold-kou/items/4c7e62434af455e977c2
https://circleci.com/docs/ja/2.0/hello-world/

CircleCIの環境変数：
https://circleci.com/docs/ja/2.0/env-vars/

CI/CD Implementation Guide:
https://resources.docs.salesforce.com/rel1/doc/en-us/static/pdf/CI_CD_Implementation_Guide.pdf

Gruntを利用したJS APIでのサンプル：
https://github.com/SalesforceCommerceCloud/build-suite

b2c-toolsというCI/CDツールもあるので、今後取り上げたいと思います。
https://github.com/SalesforceCommerceCloud/b2c-tools