code-server に Kiro CLI と Kiro Crew を同居させ Amazon Cognito で MFA 認証をさせる – SSM ドキュメント実装編

こんにちは、広野です。

以前、code-server を nginx で HTTPS 化して Amazon Cognito で MFA 認証をさせる構成を紹介しました。本記事では、同じ Amazon EC2 インスタンス上で AI エージェントの Kiro CLI と Kiro Crew を立ち上げる構成を紹介します。AWS Systems Manager (SSM) ドキュメント実装編です。

実装は 3つの AWS CloudFormation テンプレートに分けてデプロイします。本記事はその 3つ目で、EC2 インスタンスの中にソフトウェアを導入する SSM ドキュメントを作るテンプレートを紹介します。シリーズの最終回です。

作成物

このテンプレートをデプロイすると、EC2 インスタンス編でデプロイしたインスタンス限定で使用できる SSM ドキュメント (Command タイプ) が 6つ作成されます。ドキュメント名は <SystemName>-<SubName>- で始まります。

各ドキュメントが導入するものと、再実行したときにバージョンアップされるかどうかを示します。表の列の意味は、次のとおりです。

  • バージョン
    • 指定: ドキュメントを実行するときのパラメータで、バージョンを指定します。テンプレートのパラメータで、既定値も変えられます。
    • 最新: 実行した時点の最新版が入ります。
    • OS の版: dnf install で入ります。AMI を作成した時点のリポジトリの版になります。
    • -: バージョンの概念がないものです。
  • 再実行時の更新
    • される: 再実行すると、バージョンアップされます。「指定」のものは、別のバージョンを指定して再実行すると、そのバージョンに切り替わります。
    • されない: 導入済みの場合は何もしません。バージョンアップするには、別の方法が必要です。

Setup-CodeServer

事前に実行が必要なドキュメント: なし

導入するもの バージョン 再実行時の更新
code-server 最新 される
oauth2-proxy 指定 される
nginx、certbot OS の版 されない
VS Code 拡張機能 最新 されない
Let’s Encrypt の証明書、git の設定 – されない

Setup-NodePython

事前に実行が必要なドキュメント: なし

導入するもの バージョン 再実行時の更新
nvm 指定 される
Node.js 指定 される
npm、npm-check-updates、license-checker-rseidelsohn 最新 される
pyenv 最新 されない
Python 指定 される
pytest 最新 される

Setup-PythonModules

事前に実行が必要なドキュメント: Setup-NodePython

導入するもの バージョン 再実行時の更新
特定の Python パッケージ 最新 される
Playwright と Chromium 最新 される

Setup-KiroCli

事前に実行が必要なドキュメント: なし

導入するもの バージョン 再実行時の更新
Kiro CLI 最新 されない
uv 最新 されない
MCP サーバーの設定、仮のステアリングファイル AGENTS.md – されない

Setup-KiroCrew

事前に実行が必要なドキュメント: Setup-CodeServer、Setup-NodePython、Setup-KiroCli

導入するもの バージョン 再実行時の更新
Kiro Crew 最新 されない
専用の証明書、専用の oauth2-proxy、nginx の設定 – されない

Setup-LibreOffice

事前に実行が必要なドキュメント: なし

導入するもの バージョン 再実行時の更新
LibreOffice 最新の安定版 される

SSM ドキュメントでセットアップする利点

以前の構成では、ソフトウェアのインストールを EC2 の UserData にすべて書いていました。主な理由、つまり利点は以下の通りです。

  • UserData は、インスタンスの初回起動時にしか実行されない。途中で失敗すると、やり直しがきかず、手動でのリカバリが大変だった。SSM ドキュメントにすることで、何度でも再実行できるのが魅力。
  • UserData には 16KB のサイズ上限がある。SSM ドキュメントであれば分割してコマンドを実行できるので、上限を気にせず済む。
  • ソフトウェアのカテゴリにドキュメントを分けていて、必要なものだけを選んで実行できる。例えば Kiro Crew を使わない人は、Setup-KiroCrew を実行しなくてよい。
  • 実行するタイミングを自分で決められる。後から必要になったから追加する、でもよい。
  • 今回の設計では、元々 EC2 インスタンスの構築時に環境情報を environment.json に保存している。それを読み込むことで、SSM ドキュメントに環境固有の設定を含めるまたはインプットにすることなく、同じドキュメントを再利用できる。
  • 今回の設計では、ソフトウェアのバージョンを、ドキュメントのパラメータとして実行時に指定できる。常時最新をインストールするものもあり、バージョンアップにも使用できる。
  • SSH で接続する必要がない。Run Command は、インスタンスの SSM エージェントが AWS と通信してコマンドを実行可能。

今回の設計では、実行は手動にしています。EC2 インスタンスの初回構築時に自動実行もできるのですが、任意で必要なものを選択できる利便性を優先しました。

前提事項

  • Cognito 編と EC2 インスタンス編のスタックが作成済みであること。

設計のポイント

共通するブロック

ドキュメントは、スクリプトの冒頭に共通するブロックを持っています。テンプレート中、
# ===== Common block
から
# ===== End of common block =====
までの部分です。ドキュメント名を示す DOC_NAME の値だけが異なります。このブロックでは、以下を実施しています。

  • エラーがあれば停止する。(set -eEuo pipefail)
  • 秘密情報が表示されないように、set -x は使用しない。
  • root で実行されていることを確認する。
  • ログを /var/log/ide-setup/<ドキュメント名>-<日時>.log に保存する。同じ内容を標準出力にも出すので、Run Command の画面にも表示される。ログは CloudWatch エージェントが CloudWatch Logs の ide-setup ロググループに送信する。
  • 一時フォルダを作成し、終了時に必ず削除する。
  • エラーが起きたら、スクリプトの何行目で失敗したかを出力する。
  • environment.json を検証する。ファイルが存在すること、JSON として正しいこと、schemaVersion が一致すること、必要な項目がすべて入っていること、の 4点。どれかが満たされなければ、何も変更せずに失敗する。検証後、environment.json の値をシェル変数に取り込む。

schemaVersion の確認は、次の部分です。

[ "$(env_get schemaVersion)" = "$SUPPORTED_SCHEMA_VERSION" ] \
  || fail "schemaVersion mismatch (JSON: $(env_get schemaVersion), this document: ${SUPPORTED_SCHEMA_VERSION}). The PerPerson template and this document are out of sync."

EC2 インスタンス編のテンプレートと、このテンプレートのずれを検知するための仕掛けです。environment.json の項目を変更したときには、environment.json および SSM ドキュメントの両方の値を更新します。

再実行可能にする (冪等性)

すべてのドキュメントは、何度実行しても同じ結果になるように作られています。(予期せぬエラー以外は)

  • すでに同じバージョンがインストールされているものはスキップする。例えば、同じバージョンの Python があれば、再インストールしない。
  • 発行済みの証明書は、再発行しない。Let’s Encrypt には発行枚数の上限があるため、再実行で消費しない。
  • 設定ファイルは内容が変わったときだけ書き換えて、サービスを再起動する。
  • 開発者が編集するファイルは、すでに存在すれば上書きしない。対象は、code-server の settings.json、git の設定、Kiro CLI の mcp.json と AGENTS.md。
  • 秘密情報も引き継ぐ。oauth2-proxy の Cookie シークレットは、既存のものがあれば同じ値を使用する。再実行によって、ログイン中のユーザーのセッションが無効にならないようにするため。

AWS CloudFormation テンプレート

パラメータの DefaultOauth2ProxyVersion、DefaultNvmVersion、DefaultNodeVersion、DefaultPythonVersion は、ドキュメント実行時のパラメータの既定値になります。実行時に別のバージョンを指定することもできます。ソフトウェアのバージョンは、本テンプレート作成時点のものです。

AWSTemplateFormatVersion: "2010-09-09"
Description: The CloudFormation template that creates the account-common SSM Command documents for setting up the code-server environment.
# ------------------------------------------------------------#
# General Information
#
# Last update: 2026-10-30
# Author: Hirono
# ------------------------------------------------------------#

# ------------------------------------------------------------#
# Input Parameters
# ------------------------------------------------------------#
Parameters:
  SystemName:
    Type: String
    Description: System name. use lower case only. (e.g. example)
    Default: example
    MaxLength: 10
    MinLength: 1
    AllowedPattern: "^[a-z0-9]+$"

  SubName:
    Type: String
    Description: System sub name. use lower case only. (e.g. prod or dev)
    Default: dev
    MaxLength: 10
    MinLength: 1
    AllowedPattern: "^[a-z0-9]+$"

  DefaultOauth2ProxyVersion:
    Type: String
    Description: Default oauth2-proxy version for Setup-CodeServer. The latest is 7.15.5 as of 2026-10-15. (https://github.com/oauth2-proxy/oauth2-proxy/releases)
    Default: "7.15.5"
    AllowedPattern: "^[0-9]+\\.[0-9]+\\.[0-9]+$"

  DefaultNvmVersion:
    Type: String
    Description: Default nvm version for Setup-NodePython. The latest is 0.40.8 as of 2026-10-15. (https://github.com/nvm-sh/nvm/releases)
    Default: "0.40.8"
    AllowedPattern: "^[0-9]+\\.[0-9]+\\.[0-9]+$"

  DefaultNodeVersion:
    Type: String
    Description: Default Node.js version for Setup-NodePython. The latest LTS is 24.22.0 as of 2026-10-17. (https://nodejs.org/en/about/previous-releases)
    Default: "24.22.0"
    AllowedPattern: "^[0-9]+\\.[0-9]+\\.[0-9]+$"

  DefaultPythonVersion:
    Type: String
    Description: Default Python version for Setup-NodePython. The latest 3.14 series is 3.14.8 as of 2026-10-17. (https://www.python.org/downloads/)
    Default: "3.14.8"
    AllowedPattern: "^[0-9]+\\.[0-9]+\\.[0-9]+$"

Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label:
          default: "General Configuration"
        Parameters:
          - SystemName
          - SubName
      - Label:
          default: "Default Software Versions"
        Parameters:
          - DefaultOauth2ProxyVersion
          - DefaultNvmVersion
          - DefaultNodeVersion
          - DefaultPythonVersion
  cfn-lint:
    config:
      ignore_checks:
        - "W3005"

Resources:
# ------------------------------------------------------------#
# SSM Documents
# ------------------------------------------------------------#
  DocumentSetupCodeServer:
    Type: AWS::SSM::Document
    Properties:
      Name: !Sub "${SystemName}-${SubName}-Setup-CodeServer"
      DocumentType: Command
      DocumentFormat: YAML
      TargetType: /AWS::EC2::Instance
      UpdateMethod: NewVersion
      Content:
        schemaVersion: "2.2"
        description: "Sets up code-server, nginx, oauth2-proxy, certificates, git, and VS Code extensions."
        parameters:
          Oauth2ProxyVersion:
            type: String
            description: "oauth2-proxy version (e.g. 7.15.5). Check the latest at https://github.com/oauth2-proxy/oauth2-proxy/releases"
            default: !Ref DefaultOauth2ProxyVersion
            allowedPattern: "^[0-9]+\\.[0-9]+\\.[0-9]+$"
        mainSteps:
          - action: aws:runShellScript
            name: SetupCodeServer
            inputs:
              timeoutSeconds: "1800"
              runCommand:
                - |
                  #!/bin/bash
                  # shellcheck disable=SC2034  # variables from the common block are not all used by every document
                  # ===== Common block (identical in all Setup-* documents except DOC_NAME) =====
                  set -eEuo pipefail   # Fail as failure. Never use "set -x" (it could print secrets).
                  umask 022
                  export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
                  DOC_NAME="Setup-CodeServer"
                  LOG_DIR="/var/log/ide-setup"
                  CONFIG_DIR="/etc/ide-setup-config"
                  ENV_JSON="${CONFIG_DIR}/environment.json"
                  SUPPORTED_SCHEMA_VERSION="1"
                  log()        { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
                  fail()       { log "[ERROR] $*" >&2; exit 1; }
                  step_begin() { log "[START] $*"; }
                  step_end()   { log "[DONE]  $*"; }
                  [ "$(id -u)" -eq 0 ] || { echo "[ERROR] This document must run as root." >&2; exit 1; }
                  # Make HOME explicit (it may be unset under Run Command).
                  # Work for ec2-user uses "sudo -u ec2-user -H".
                  export HOME=/root
                  mkdir -p "$LOG_DIR"
                  LOG_FILE="${LOG_DIR}/${DOC_NAME}-$(date '+%Y%m%d-%H%M%S').log"
                  exec > >(tee -a "$LOG_FILE") 2>&1
                  TMP_DIR=$(mktemp -d /tmp/ide-setup.XXXXXX)
                  trap 'rm -rf "$TMP_DIR"' EXIT
                  trap 'log "[ERROR] Command failed at line ${LINENO} (exit code: $?)" >&2' ERR
                  log "Starting ${DOC_NAME} (log file: ${LOG_FILE})"
                  step_begin "Check the environment info JSON"
                  [ -f "$ENV_JSON" ] || fail "${ENV_JSON} was not found. UserData may still be running. Wait a few minutes and run this document again. (Check: cloud-init status)"
                  jq -e . "$ENV_JSON" > /dev/null || fail "${ENV_JSON} is not valid JSON."
                  env_get() { jq -r --arg k "$1" '(.[$k] // empty) | tostring' "$ENV_JSON"; }
                  [ "$(env_get schemaVersion)" = "$SUPPORTED_SCHEMA_VERSION" ] \
                    || fail "schemaVersion mismatch (JSON: $(env_get schemaVersion), this document: ${SUPPORTED_SCHEMA_VERSION}). The PerPerson template and this document are out of sync."
                  for key in systemName subName yourId yourEmail region domainName timeZone \
                             codeserverFqdn kirocrewFqdn userPoolId userPoolClientId \
                             userPoolProviderUrl cognitoDomain workDir; do
                    [ -n "$(env_get "$key")" ] || fail "Key '${key}' is missing or empty in ${ENV_JSON}."
                  done
                  SYSTEM_NAME=$(env_get systemName);   SUB_NAME=$(env_get subName)
                  YOUR_ID=$(env_get yourId);           YOUR_EMAIL=$(env_get yourEmail)
                  REGION=$(env_get region);            DOMAIN_NAME=$(env_get domainName)
                  TIME_ZONE=$(env_get timeZone)
                  CODESERVER_FQDN=$(env_get codeserverFqdn); KIROCREW_FQDN=$(env_get kirocrewFqdn)
                  USER_POOL_ID=$(env_get userPoolId);  USER_POOL_CLIENT_ID=$(env_get userPoolClientId)
                  USER_POOL_PROVIDER_URL=$(env_get userPoolProviderUrl)
                  COGNITO_DOMAIN=$(env_get cognitoDomain); WORK_DIR=$(env_get workDir)
                  step_end "Check the environment info JSON (${SYSTEM_NAME}-${SUB_NAME} / ${YOUR_ID} / ${REGION})"
                  # ===== End of common block =====
                  # ----- Check that the requested oauth2-proxy release exists (before any change) -----
                  # Document parameter (already validated by allowedPattern)
                  OAUTH2_PROXY_VERSION="{{ Oauth2ProxyVersion }}"
                  OAUTH2_PROXY_BASE_URL="https://github.com/oauth2-proxy/oauth2-proxy/releases/download/v${OAUTH2_PROXY_VERSION}"
                  OAUTH2_PROXY_TARBALL="oauth2-proxy-v${OAUTH2_PROXY_VERSION}.linux-amd64.tar.gz"
                  OAUTH2_PROXY_BIN="/usr/local/bin/oauth2-proxy"
                  step_begin "Check that oauth2-proxy v${OAUTH2_PROXY_VERSION} exists"
                  curl -fsSL --retry 3 -o "${TMP_DIR}/${OAUTH2_PROXY_TARBALL}-sha256sum.txt" \
                    "${OAUTH2_PROXY_BASE_URL}/${OAUTH2_PROXY_TARBALL}-sha256sum.txt" \
                    || fail "Could not download the checksum of oauth2-proxy v${OAUTH2_PROXY_VERSION}. The version may not exist, or GitHub is unreachable. Check https://github.com/oauth2-proxy/oauth2-proxy/releases and run again. Nothing has been changed."
                  step_end "oauth2-proxy v${OAUTH2_PROXY_VERSION} exists"
                  # ----- Packages -----
                  step_begin "Install packages (certbot, nginx)"
                  dnf install -y certbot python3-certbot-dns-route53 nginx
                  step_end "Install packages (certbot, nginx)"
                  # ----- Work folder -----
                  step_begin "Create the work folder ${WORK_DIR}"
                  install -d -o ec2-user -g ec2-user -m 755 "$WORK_DIR"   # sets the owner of this folder only (no recursive chown)
                  step_end "Create the work folder ${WORK_DIR}"
                  # ----- code-server -----
                  step_begin "Install or update code-server"
                  CS_VERSION_BEFORE=""
                  # Read the version from the rpm database: running "code-server --version" as root creates /root/.config/code-server
                  # and mixes log lines into the output.
                  if rpm -q --quiet code-server; then
                    CS_VERSION_BEFORE=$(rpm -q --qf '%{VERSION}-%{RELEASE}' code-server)
                  fi
                  # The installer does not compare versions: "rpm -U" fails (exit code 2) when the same version is installed.
                  # So decide here, in the same way as the installer (the redirect target of releases/latest).
                  CS_LATEST=$(curl -fsSLI -o /dev/null -w '%{url_effective}' https://github.com/coder/code-server/releases/latest)
                  CS_LATEST="${CS_LATEST#https://github.com/coder/code-server/releases/tag/}"
                  CS_LATEST="${CS_LATEST#v}"
                  [[ "$CS_LATEST" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "Could not determine the latest code-server version (got: '${CS_LATEST}')."
                  CS_INSTALLED_VERSION=""
                  if rpm -q --quiet code-server; then
                    CS_INSTALLED_VERSION=$(rpm -q --qf '%{VERSION}' code-server)
                  fi
                  if [ "$CS_INSTALLED_VERSION" = "$CS_LATEST" ]; then
                    log "code-server ${CS_INSTALLED_VERSION} is already the latest. Skipping the installer."
                  else
                    log "Installing code-server ${CS_LATEST} (installed: ${CS_INSTALLED_VERSION:-none})."
                    curl -fsSL https://code-server.dev/install.sh | bash
                  fi
                  rpm -q --quiet code-server || fail "code-server is not installed as an rpm package."
                  CS_VERSION_AFTER=$(rpm -q --qf '%{VERSION}-%{RELEASE}' code-server)
                  log "code-server version: before=${CS_VERSION_BEFORE:-none} / after=${CS_VERSION_AFTER}"
                  step_end "Install or update code-server"
                  step_begin "Configure code-server and start the service"
                  CS_CONFIG_DIR=/home/ec2-user/.config/code-server
                  CS_USER_DIR=/home/ec2-user/.local/share/code-server/User
                  # Create as ec2-user so that parent folders (.config, .local, ...) are not owned by root
                  sudo -u ec2-user -H mkdir -p "$CS_CONFIG_DIR" "$CS_USER_DIR"
                  # config.yaml: always overwrite (part of the architecture). Restart only when the content changed.
                  cat > "${TMP_DIR}/config.yaml" << 'EOF'
                  bind-addr: 127.0.0.1:8008
                  auth: none
                  cert: false
                  EOF
                  CS_CONFIG_CHANGED=0
                  if ! cmp -s "${TMP_DIR}/config.yaml" "${CS_CONFIG_DIR}/config.yaml"; then
                    install -m 644 -o ec2-user -g ec2-user "${TMP_DIR}/config.yaml" "${CS_CONFIG_DIR}/config.yaml"
                    CS_CONFIG_CHANGED=1
                    log "Wrote ${CS_CONFIG_DIR}/config.yaml"
                  else
                    log "${CS_CONFIG_DIR}/config.yaml is unchanged."
                  fi
                  # settings.json: create only if it does not exist (the developer may edit it in the IDE)
                  if [ -e "${CS_USER_DIR}/settings.json" ]; then
                    log "${CS_USER_DIR}/settings.json already exists. Not changed."
                  else
                    cat > "${TMP_DIR}/settings.json" << EOF
                  {
                    "extensions.autoUpdate": false,
                    "extensions.autoCheckUpdates": false,
                    "terminal.integrated.cwd": "${WORK_DIR}",
                    "telemetry.telemetryLevel": "off",
                    "security.workspace.trust.startupPrompt": "never",
                    "security.workspace.trust.enabled": false,
                    "security.workspace.trust.banner": "never",
                    "security.workspace.trust.emptyWindow": false,
                    "editor.indentSize": "tabSize",
                    "editor.tabSize": 2,
                    "python.testing.pytestEnabled": true
                  }
                  EOF
                    install -m 644 -o ec2-user -g ec2-user "${TMP_DIR}/settings.json" "${CS_USER_DIR}/settings.json"
                    log "Created ${CS_USER_DIR}/settings.json"
                  fi
                  systemctl enable --now code-server@ec2-user
                  if systemctl is-active --quiet code-server@ec2-user \
                     && { [ "$CS_CONFIG_CHANGED" -eq 1 ] || { [ -n "$CS_VERSION_BEFORE" ] && [ "$CS_VERSION_BEFORE" != "$CS_VERSION_AFTER" ]; }; }; then
                    log "Restarting code-server (config or version changed). Reload the browser tab afterwards."
                    systemctl restart code-server@ec2-user
                  fi
                  step_end "Configure code-server and start the service"
                  # ----- git -----
                  step_begin "Configure git"
                  git_set_default() {   # Set a global value only when it is not set yet
                    local key="$1" value="$2" current
                    current=$(sudo -u ec2-user -H git config --global --get "$key" || true)
                    if [ -z "$current" ]; then
                      sudo -u ec2-user -H git config --global "$key" "$value"
                      log "git config ${key}: set"
                    else
                      log "git config ${key}: already set. Not changed."
                    fi
                  }
                  git_set_default user.name "$YOUR_ID"
                  git_set_default user.email "$YOUR_EMAIL"
                  git_set_default credential.helper '!aws codecommit credential-helper $@'
                  git_set_default credential.UseHttpPath true
                  git_set_default init.defaultBranch main
                  step_end "Configure git"
                  # ----- VS Code extensions -----
                  step_begin "Install VS Code extensions"
                  for ext in adamraichu.pdf-viewer bierner.markdown-mermaid shd101wyy.markdown-preview-enhanced; do
                    sudo -u ec2-user -H code-server --install-extension "$ext"
                  done
                  sudo -u ec2-user -H code-server --list-extensions --show-versions
                  step_end "Install VS Code extensions"
                  # ----- oauth2-proxy binary -----
                  step_begin "Install oauth2-proxy v${OAUTH2_PROXY_VERSION}"
                  INSTALLED_VERSION=""
                  if [ -x "$OAUTH2_PROXY_BIN" ]; then
                    INSTALLED_VERSION=$("$OAUTH2_PROXY_BIN" --version | awk '{print $2}' | sed 's/^v//') || true
                  fi
                  if [ "$INSTALLED_VERSION" = "$OAUTH2_PROXY_VERSION" ]; then
                    log "oauth2-proxy v${INSTALLED_VERSION} is already installed. Skipping."
                  else
                    curl -fsSL --retry 3 -o "${TMP_DIR}/${OAUTH2_PROXY_TARBALL}" "${OAUTH2_PROXY_BASE_URL}/${OAUTH2_PROXY_TARBALL}"
                    (cd "$TMP_DIR" && sha256sum -c "${OAUTH2_PROXY_TARBALL}-sha256sum.txt")   # fails the document on mismatch
                    tar -xzf "${TMP_DIR}/${OAUTH2_PROXY_TARBALL}" -C "$TMP_DIR"
                    install -m 755 -o root -g root \
                      "${TMP_DIR}/oauth2-proxy-v${OAUTH2_PROXY_VERSION}.linux-amd64/oauth2-proxy" "${OAUTH2_PROXY_BIN}.new"
                    # Stop only this document's service, then swap atomically (cp over a running binary fails with "Text file busy")
                    if systemctl is-active --quiet oauth2-proxy; then systemctl stop oauth2-proxy; fi
                    mv -f "${OAUTH2_PROXY_BIN}.new" "$OAUTH2_PROXY_BIN"
                    log "Installed oauth2-proxy v${OAUTH2_PROXY_VERSION} (previous: ${INSTALLED_VERSION:-none})"
                  fi
                  mkdir -p "$CONFIG_DIR"
                  echo "$OAUTH2_PROXY_VERSION" > "${CONFIG_DIR}/oauth2-proxy.version"
                  step_end "Install oauth2-proxy v${OAUTH2_PROXY_VERSION}"
                  # ----- Certificate -----
                  step_begin "Issue the certificate for ${CODESERVER_FQDN}"
                  LE_LIVE="/etc/letsencrypt/live/${CODESERVER_FQDN}"
                  if [ -s "${LE_LIVE}/fullchain.pem" ] && [ -s "${LE_LIVE}/privkey.pem" ]; then
                    log "The certificate already exists. Not re-issued (use renew-cert-codeserver.sh to renew)."
                  else
                    certbot certonly --dns-route53 -d "$CODESERVER_FQDN" --agree-tos -m "$YOUR_EMAIL" \
                      --non-interactive --preferred-challenges dns-01
                  fi
                  step_end "Issue the certificate for ${CODESERVER_FQDN}"
                  # ----- oauth2-proxy service -----
                  step_begin "Configure the oauth2-proxy service"
                  OP_UNIT=/etc/systemd/system/oauth2-proxy.service
                  # Allowed email file (also read by oauth2-proxy-kirocrew)
                  install -d -o ec2-user -g ec2-user -m 700 /etc/oauth2-proxy
                  printf '%s\n' "$YOUR_EMAIL" > /etc/oauth2-proxy/allowed_emails.txt
                  chown ec2-user:ec2-user /etc/oauth2-proxy/allowed_emails.txt
                  chmod 600 /etc/oauth2-proxy/allowed_emails.txt
                  # Client secret: always fetched from Cognito (never printed)
                  CLIENT_SECRET=$(aws cognito-idp describe-user-pool-client \
                    --user-pool-id "$USER_POOL_ID" --client-id "$USER_POOL_CLIENT_ID" --region "$REGION" \
                    --query 'UserPoolClient.ClientSecret' --output text)
                  { [ -n "$CLIENT_SECRET" ] && [ "$CLIENT_SECRET" != "None" ]; } \
                    || fail "Could not get the client secret of the Cognito app client."
                  # Cookie secret: keep the existing one, otherwise generate (URL-safe base64 of 32 bytes)
                  COOKIE_SECRET=""
                  if [ -f "$OP_UNIT" ]; then
                    COOKIE_SECRET=$(sed -n 's/^Environment=COOKIE_SECRET=//p' "$OP_UNIT" | head -1)
                  fi
                  if [ -z "$COOKIE_SECRET" ]; then
                    COOKIE_SECRET=$(openssl rand -base64 32 | tr '+/' '-_')
                    log "Generated a new cookie secret."
                  else
                    log "Kept the existing cookie secret."
                  fi
                  # shellcheck disable=SC2016  # the single-quoted $CLIENT_SECRET and $COOKIE_SECRET are expanded by systemd, not by the shell
                  OP_ARGS=(
                    --provider=oidc
                    "--oidc-issuer-url=${USER_POOL_PROVIDER_URL}"
                    "--client-id=${USER_POOL_CLIENT_ID}"
                    '--client-secret=${CLIENT_SECRET}'          # expanded by systemd from Environment=
                    "--redirect-url=https://${CODESERVER_FQDN}/oauth2/callback"
                    "--login-url=https://${COGNITO_DOMAIN}/oauth2/authorize"
                    "--profile-url=https://${COGNITO_DOMAIN}/oauth2/userInfo"
                    "--redeem-url=https://${COGNITO_DOMAIN}/oauth2/token"
                    --scope=openid
                    --cookie-secure=true
                    --cookie-expire=72h
                    --cookie-refresh=1h
                    '--cookie-secret=${COOKIE_SECRET}'          # expanded by systemd from Environment=
                    --authenticated-emails-file=/etc/oauth2-proxy/allowed_emails.txt
                    --http-address=127.0.0.1:4180
                  )
                  {
                    cat << 'EOF'
                  [Unit]
                  Description=oauth2-proxy
                  After=network.target

                  [Service]
                  Type=simple
                  EOF
                    printf 'Environment=CLIENT_SECRET=%s\n' "$CLIENT_SECRET"
                    printf 'Environment=COOKIE_SECRET=%s\n' "$COOKIE_SECRET"
                    printf 'ExecStart=%s' "$OAUTH2_PROXY_BIN"; printf ' %s' "${OP_ARGS[@]}"; printf '\n'
                    cat << 'EOF'
                  Restart=on-failure
                  User=ec2-user
                  Group=ec2-user
                  [Install]
                  WantedBy=multi-user.target
                  EOF
                  } > "${TMP_DIR}/oauth2-proxy.service"
                  OP_UNIT_CHANGED=0
                  if ! cmp -s "${TMP_DIR}/oauth2-proxy.service" "$OP_UNIT"; then
                    install -m 600 -o root -g root "${TMP_DIR}/oauth2-proxy.service" "$OP_UNIT"   # 600: it contains secrets
                    OP_UNIT_CHANGED=1
                    log "Wrote ${OP_UNIT} (contents are not shown because they contain secrets)."
                  else
                    log "${OP_UNIT} is unchanged."
                  fi
                  systemctl daemon-reload
                  systemctl enable oauth2-proxy
                  # Restart only when the definition changed or the service is not running (e.g. stopped for a binary swap)
                  if [ "$OP_UNIT_CHANGED" -eq 1 ] || ! systemctl is-active --quiet oauth2-proxy; then
                    systemctl restart oauth2-proxy
                  fi
                  # Health check: /ping must return 200
                  PING_CODE=""
                  for _ in $(seq 1 30); do
                    PING_CODE=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:4180/ping || true)
                    [ "$PING_CODE" = "200" ] && break
                    sleep 1
                  done
                  [ "$PING_CODE" = "200" ] \
                    || fail "oauth2-proxy /ping did not return 200 (last: ${PING_CODE:-none}). Check 'journalctl -u oauth2-proxy'. If the version is the cause, run this document again with another Oauth2ProxyVersion."
                  unset CLIENT_SECRET COOKIE_SECRET
                  step_end "Configure the oauth2-proxy service (version $(cat "${CONFIG_DIR}/oauth2-proxy.version"), /ping OK)"
                  # ----- nginx -----
                  step_begin "Configure nginx for code-server"
                  install -d -m 700 /etc/nginx/ssl
                  install -m 644 "${LE_LIVE}/fullchain.pem" /etc/nginx/ssl/nginx-chain.crt
                  install -m 600 "${LE_LIVE}/privkey.pem"   /etc/nginx/ssl/nginx.key
                  cat > "${TMP_DIR}/codeserver.conf.tpl" << 'EOF'
                  server {
                    listen 443 ssl;
                    server_name __CODESERVER_FQDN__;
                    large_client_header_buffers 4 16k;
                    ssl_certificate_key /etc/nginx/ssl/nginx.key;
                    ssl_certificate /etc/nginx/ssl/nginx-chain.crt;
                    location / {
                      auth_request /oauth2/auth;
                      error_page 401 = /oauth2/sign_in;
                      proxy_pass http://127.0.0.1:8008;
                      proxy_http_version 1.1;
                      proxy_set_header Upgrade $http_upgrade;
                      proxy_set_header Connection "upgrade";
                      proxy_set_header Host $host;
                      proxy_set_header X-Real-IP $remote_addr;
                      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                      proxy_set_header X-Forwarded-Proto $scheme;
                      proxy_read_timeout 3600s;
                      proxy_send_timeout 3600s;
                    }
                    location /oauth2/ {
                      proxy_pass http://127.0.0.1:4180;
                      proxy_set_header Host $host;
                      proxy_set_header X-Real-IP $remote_addr;
                      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                      proxy_set_header X-Forwarded-Proto $scheme;
                      proxy_set_header X-Auth-Request-Redirect $request_uri;
                      proxy_buffers 16 16k;
                      proxy_buffer_size 16k;
                    }
                    location = /oauth2/auth {
                      proxy_pass http://127.0.0.1:4180;
                      proxy_set_header Host $host;
                      proxy_set_header X-Real-IP $remote_addr;
                      proxy_set_header X-Scheme $scheme;
                      proxy_set_header Content-Length "";
                      proxy_pass_request_body off;
                      proxy_buffers 16 16k;
                      proxy_buffer_size 16k;
                    }
                  }
                  EOF
                  NGINX_CONF=/etc/nginx/conf.d/codeserver.conf
                  if [ -f "$NGINX_CONF" ]; then cp -p "$NGINX_CONF" "${TMP_DIR}/codeserver.conf.bak"; fi
                  sed "s#__CODESERVER_FQDN__#${CODESERVER_FQDN}#g" "${TMP_DIR}/codeserver.conf.tpl" > "$NGINX_CONF"
                  if ! nginx -t; then   # roll back so that a bad file does not break other documents' reload
                    if [ -f "${TMP_DIR}/codeserver.conf.bak" ]; then cp -p "${TMP_DIR}/codeserver.conf.bak" "$NGINX_CONF"; else rm -f "$NGINX_CONF"; fi
                    fail "nginx -t failed. The previous nginx configuration was restored."
                  fi
                  systemctl enable nginx
                  if systemctl is-active --quiet nginx; then systemctl reload nginx; else systemctl start nginx; fi
                  step_end "Configure nginx for code-server"
                  # ----- Certificate renewal script -----
                  step_begin "Create the certificate renewal script"
                  cat > "${TMP_DIR}/renew-cert-codeserver.sh.tpl" << 'EOF'
                  #!/bin/bash
                  # Renews the certificate of __CODESERVER_FQDN__ (code-server).
                  # Usage: sudo /home/ec2-user/renew-cert-codeserver.sh
                  set -euo pipefail
                  [ "$(id -u)" -eq 0 ] || { echo "Run this script with sudo." >&2; exit 1; }
                  FQDN="__CODESERVER_FQDN__"
                  LIVE="/etc/letsencrypt/live/${FQDN}"
                  certbot renew --cert-name "$FQDN"
                  if cmp -s "${LIVE}/fullchain.pem" /etc/nginx/ssl/nginx-chain.crt \
                     && cmp -s "${LIVE}/privkey.pem" /etc/nginx/ssl/nginx.key; then
                    echo "The certificate is not due for renewal yet. Nothing was changed."
                    exit 0
                  fi
                  install -m 644 "${LIVE}/fullchain.pem" /etc/nginx/ssl/nginx-chain.crt
                  install -m 600 "${LIVE}/privkey.pem"   /etc/nginx/ssl/nginx.key
                  nginx -t
                  systemctl reload nginx
                  echo "The certificate was renewed and nginx was reloaded."
                  EOF
                  sed "s#__CODESERVER_FQDN__#${CODESERVER_FQDN}#g" "${TMP_DIR}/renew-cert-codeserver.sh.tpl" > /home/ec2-user/renew-cert-codeserver.sh
                  chown ec2-user:ec2-user /home/ec2-user/renew-cert-codeserver.sh
                  chmod 755 /home/ec2-user/renew-cert-codeserver.sh
                  step_end "Create the certificate renewal script"
                  # ----- Sign-out link -----
                  step_begin "Create signout.md"
                  cat > "${WORK_DIR}/signout.md" << EOF
                  [Sign Out](https://${COGNITO_DOMAIN}/logout?client_id=${USER_POOL_CLIENT_ID}&logout_uri=https%3A%2F%2F${CODESERVER_FQDN}%2Foauth2%2Fsign_out)
                  EOF
                  chown ec2-user:ec2-user "${WORK_DIR}/signout.md"
                  chmod 644 "${WORK_DIR}/signout.md"
                  step_end "Create signout.md"
                  log "[COMPLETE] ${DOC_NAME} finished successfully."
      Tags:
        - Key: Cost
          Value: !Sub "${SystemName}-${SubName}"

  DocumentSetupNodePython:
    Type: AWS::SSM::Document
    Properties:
      Name: !Sub "${SystemName}-${SubName}-Setup-NodePython"
      DocumentType: Command
      DocumentFormat: YAML
      TargetType: /AWS::EC2::Instance
      UpdateMethod: NewVersion
      Content:
        schemaVersion: "2.2"
        description: "Sets up nvm, Node.js, npm tools, pyenv, Python, and pytest."
        parameters:
          NvmVersion:
            type: String
            description: "nvm version (e.g. 0.40.8). Check the latest at https://github.com/nvm-sh/nvm/releases"
            default: !Ref DefaultNvmVersion
            allowedPattern: "^[0-9]+\\.[0-9]+\\.[0-9]+$"
          NodeVersion:
            type: String
            description: "Node.js version (e.g. 24.22.0). Kiro Crew requires 22.12 or later. Check the LTS at https://nodejs.org/en/about/previous-releases"
            default: !Ref DefaultNodeVersion
            allowedPattern: "^[0-9]+\\.[0-9]+\\.[0-9]+$"
          PythonVersion:
            type: String
            description: "Python version (e.g. 3.14.8). Kiro Crew requires 3.12 or later. Check the latest at https://www.python.org/downloads/"
            default: !Ref DefaultPythonVersion
            allowedPattern: "^[0-9]+\\.[0-9]+\\.[0-9]+$"
        mainSteps:
          - action: aws:runShellScript
            name: SetupNodePython
            inputs:
              timeoutSeconds: "1800"
              runCommand:
                - |
                  #!/bin/bash
                  # shellcheck disable=SC2034  # variables from the common block are not all used by every document
                  # ===== Common block (identical in all Setup-* documents except DOC_NAME) =====
                  set -eEuo pipefail   # Fail as failure. Never use "set -x" (it could print secrets).
                  umask 022
                  export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
                  DOC_NAME="Setup-NodePython"
                  LOG_DIR="/var/log/ide-setup"
                  CONFIG_DIR="/etc/ide-setup-config"
                  ENV_JSON="${CONFIG_DIR}/environment.json"
                  SUPPORTED_SCHEMA_VERSION="1"
                  log()        { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
                  fail()       { log "[ERROR] $*" >&2; exit 1; }
                  step_begin() { log "[START] $*"; }
                  step_end()   { log "[DONE]  $*"; }
                  [ "$(id -u)" -eq 0 ] || { echo "[ERROR] This document must run as root." >&2; exit 1; }
                  # Make HOME explicit (it may be unset under Run Command).
                  # Work for ec2-user uses "sudo -u ec2-user -H".
                  export HOME=/root
                  mkdir -p "$LOG_DIR"
                  LOG_FILE="${LOG_DIR}/${DOC_NAME}-$(date '+%Y%m%d-%H%M%S').log"
                  exec > >(tee -a "$LOG_FILE") 2>&1
                  TMP_DIR=$(mktemp -d /tmp/ide-setup.XXXXXX)
                  trap 'rm -rf "$TMP_DIR"' EXIT
                  trap 'log "[ERROR] Command failed at line ${LINENO} (exit code: $?)" >&2' ERR
                  log "Starting ${DOC_NAME} (log file: ${LOG_FILE})"
                  step_begin "Check the environment info JSON"
                  [ -f "$ENV_JSON" ] || fail "${ENV_JSON} was not found. UserData may still be running. Wait a few minutes and run this document again. (Check: cloud-init status)"
                  jq -e . "$ENV_JSON" > /dev/null || fail "${ENV_JSON} is not valid JSON."
                  env_get() { jq -r --arg k "$1" '(.[$k] // empty) | tostring' "$ENV_JSON"; }
                  [ "$(env_get schemaVersion)" = "$SUPPORTED_SCHEMA_VERSION" ] \
                    || fail "schemaVersion mismatch (JSON: $(env_get schemaVersion), this document: ${SUPPORTED_SCHEMA_VERSION}). The PerPerson template and this document are out of sync."
                  for key in systemName subName yourId yourEmail region domainName timeZone \
                             codeserverFqdn kirocrewFqdn userPoolId userPoolClientId \
                             userPoolProviderUrl cognitoDomain workDir; do
                    [ -n "$(env_get "$key")" ] || fail "Key '${key}' is missing or empty in ${ENV_JSON}."
                  done
                  SYSTEM_NAME=$(env_get systemName);   SUB_NAME=$(env_get subName)
                  YOUR_ID=$(env_get yourId);           YOUR_EMAIL=$(env_get yourEmail)
                  REGION=$(env_get region);            DOMAIN_NAME=$(env_get domainName)
                  TIME_ZONE=$(env_get timeZone)
                  CODESERVER_FQDN=$(env_get codeserverFqdn); KIROCREW_FQDN=$(env_get kirocrewFqdn)
                  USER_POOL_ID=$(env_get userPoolId);  USER_POOL_CLIENT_ID=$(env_get userPoolClientId)
                  USER_POOL_PROVIDER_URL=$(env_get userPoolProviderUrl)
                  COGNITO_DOMAIN=$(env_get cognitoDomain); WORK_DIR=$(env_get workDir)
                  step_end "Check the environment info JSON (${SYSTEM_NAME}-${SUB_NAME} / ${YOUR_ID} / ${REGION})"
                  # ===== End of common block =====
                  # Document parameters (already validated by allowedPattern)
                  NVM_VERSION="{{ NvmVersion }}"
                  NODE_VERSION="{{ NodeVersion }}"
                  PYTHON_VERSION="{{ PythonVersion }}"
                  EC2_HOME=/home/ec2-user
                  # ----- Check that the requested versions exist (no change is made) -----
                  step_begin "Check that the requested versions exist"
                  curl -fsSL --retry 3 -o "${TMP_DIR}/nvm-install.sh" \
                    "https://raw.githubusercontent.com/nvm-sh/nvm/v${NVM_VERSION}/install.sh" \
                    || fail "nvm v${NVM_VERSION} was not found (or GitHub is unreachable). Check https://github.com/nvm-sh/nvm/releases. Nothing has been changed."
                  # Node.js: index.tab is the list that "nvm ls-remote" reads (nvm itself may not be installed yet)
                  curl -fsSL --retry 3 -o "${TMP_DIR}/node-index.tab" https://nodejs.org/dist/index.tab \
                    || fail "Could not download the Node.js version list. Nothing has been changed."
                  awk -F'\t' -v v="v${NODE_VERSION}" '$1 == v && $3 ~ /(^|,)linux-x64(,|$)/ { found = 1 } END { exit !found }' \
                    "${TMP_DIR}/node-index.tab" \
                    || fail "Node.js v${NODE_VERSION} (linux-x64) was not found. Check https://nodejs.org/en/about/previous-releases. Nothing has been changed."
                  # Python: coarse check on python.org (pyenv checks again after it is installed)
                  curl -fsSL --retry 3 -I -o /dev/null \
                    "https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz" \
                    || fail "Python ${PYTHON_VERSION} was not found on python.org (or it is unreachable). Check https://www.python.org/downloads/. Nothing has been changed."
                  step_end "Check that the requested versions exist"
                  # ----- Packages (git is installed by UserData) -----
                  step_begin "Install the build dependencies for pyenv"
                  dnf install -y gcc make patch zlib-devel bzip2 bzip2-devel xz-devel libffi-devel \
                    readline-devel sqlite sqlite-devel openssl-devel tk-devel libuuid-devel gdbm-devel ncurses-devel
                  step_end "Install the build dependencies for pyenv"
                  # ----- nvm -----
                  step_begin "Install nvm v${NVM_VERSION}"
                  sudo -u ec2-user -H touch "${EC2_HOME}/.bashrc"
                  NVM_INSTALLED_VERSION=$(sudo -u ec2-user -H bash -c \
                    '[ -s "$HOME/.nvm/nvm.sh" ] && . "$HOME/.nvm/nvm.sh" && nvm --version' 2> /dev/null || true)
                  if [ "$NVM_INSTALLED_VERSION" = "$NVM_VERSION" ]; then
                    log "nvm v${NVM_INSTALLED_VERSION} is already installed. Skipping."
                  else
                    # Re-running the install script is the official way to install or update nvm
                    # shellcheck disable=SC2024  # the redirect must be opened by root: TMP_DIR is readable only by root
                    sudo -u ec2-user -H env PROFILE="${EC2_HOME}/.bashrc" bash < "${TMP_DIR}/nvm-install.sh"
                    log "Installed nvm v${NVM_VERSION} (previous: ${NVM_INSTALLED_VERSION:-none})"
                  fi
                  step_end "Install nvm v${NVM_VERSION}"
                  # ----- Node.js and global npm tools (runs as ec2-user) -----
                  step_begin "Install Node.js v${NODE_VERSION} and npm tools"
                  read -r -d '' NODE_SCRIPT << 'EOF' || true
                  set -eo pipefail   # no "-u": nvm.sh is not nounset-safe
                  NODE_VERSION="$1"
                  export NVM_DIR="$HOME/.nvm"
                  . "$NVM_DIR/nvm.sh"
                  if nvm version "$NODE_VERSION" > /dev/null 2>&1; then
                    echo "Node.js v${NODE_VERSION} is already installed. Skipping."
                  else
                    nvm install "$NODE_VERSION"
                  fi
                  nvm alias default "$NODE_VERSION"
                  nvm use "$NODE_VERSION"
                  npm install -g npm@latest
                  npm install -g npm-check-updates license-checker-rseidelsohn
                  echo "node $(node --version) / npm $(npm --version) / ncu $(ncu --version)"
                  command -v license-checker-rseidelsohn
                  EOF
                  sudo -u ec2-user -H bash -c "$NODE_SCRIPT" ide-setup "$NODE_VERSION"
                  step_end "Install Node.js v${NODE_VERSION} and npm tools"
                  # ----- pyenv, Python, pip, pytest (runs as ec2-user) -----
                  step_begin "Install pyenv, Python ${PYTHON_VERSION}, and pytest"
                  read -r -d '' PYTHON_SCRIPT << 'EOF' || true
                  set -eo pipefail   # no "-u": "pyenv init" output is not nounset-safe
                  PYTHON_VERSION="$1"
                  export PYENV_ROOT="$HOME/.pyenv"
                  export PATH="$PYENV_ROOT/bin:$PATH"
                  if [ -x "$PYENV_ROOT/bin/pyenv" ]; then
                    echo "pyenv is already installed ($(pyenv --version))."
                  else
                    curl -fsSL https://pyenv.run | bash
                  fi
                  touch "$HOME/.bashrc"
                  if ! grep -q 'PYENV_ROOT' "$HOME/.bashrc"; then
                    cat >> "$HOME/.bashrc" << 'RCEOF'
                  # pyenv
                  export PYENV_ROOT="$HOME/.pyenv"
                  [[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"
                  eval "$(pyenv init - bash)"
                  RCEOF
                  fi
                  eval "$(pyenv init - bash)"
                  py_known() {   # capture the list first: "pyenv ... | grep -q" would fail with SIGPIPE under pipefail
                    local list
                    list=$(pyenv install -l)
                    grep -qE "^[[:space:]]*${PYTHON_VERSION//./\\.}[[:space:]]*$" <<< "$list"
                  }
                  if ! py_known; then
                    echo "pyenv does not know Python ${PYTHON_VERSION}. Running 'pyenv update' once."
                    pyenv update
                    py_known || { echo "[ERROR] pyenv does not know Python ${PYTHON_VERSION} even after 'pyenv update'." >&2; exit 1; }
                  fi
                  if [ -x "$PYENV_ROOT/versions/${PYTHON_VERSION}/bin/python" ]; then
                    echo "Python ${PYTHON_VERSION} is already installed. Skipping."
                  else
                    # -f: overwrite an incomplete folder left by an interrupted build
                    MAKE_OPTS="-j$(nproc)" pyenv install -f "$PYTHON_VERSION"
                  fi
                  pyenv global "$PYTHON_VERSION"
                  python --version
                  python -m pip install --upgrade pip pytest
                  pyenv rehash
                  pytest --version
                  EOF
                  sudo -u ec2-user -H bash -c "$PYTHON_SCRIPT" ide-setup "$PYTHON_VERSION"
                  step_end "Install pyenv, Python ${PYTHON_VERSION}, and pytest"
                  # ----- Record the installed versions -----
                  mkdir -p "$CONFIG_DIR"
                  echo "$NVM_VERSION"    > "${CONFIG_DIR}/nvm.version"
                  echo "$NODE_VERSION"   > "${CONFIG_DIR}/node.version"
                  echo "$PYTHON_VERSION" > "${CONFIG_DIR}/python.version"
                  log "[COMPLETE] ${DOC_NAME} finished successfully (nvm ${NVM_VERSION}, Node.js ${NODE_VERSION}, Python ${PYTHON_VERSION})."
      Tags:
        - Key: Cost
          Value: !Sub "${SystemName}-${SubName}"

  DocumentSetupPythonModules:
    Type: AWS::SSM::Document
    Properties:
      Name: !Sub "${SystemName}-${SubName}-Setup-PythonModules"
      DocumentType: Command
      DocumentFormat: YAML
      TargetType: /AWS::EC2::Instance
      UpdateMethod: NewVersion
      Content:
        schemaVersion: "2.2"
        description: "Installs the commonly used Python modules and Playwright with Chromium. Run Setup-NodePython first."
        mainSteps:
          - action: aws:runShellScript
            name: SetupPythonModules
            inputs:
              timeoutSeconds: "1800"
              runCommand:
                - |
                  #!/bin/bash
                  # shellcheck disable=SC2034  # variables from the common block are not all used by every document
                  # ===== Common block (identical in all Setup-* documents except DOC_NAME) =====
                  set -eEuo pipefail   # Fail as failure. Never use "set -x" (it could print secrets).
                  umask 022
                  export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
                  DOC_NAME="Setup-PythonModules"
                  LOG_DIR="/var/log/ide-setup"
                  CONFIG_DIR="/etc/ide-setup-config"
                  ENV_JSON="${CONFIG_DIR}/environment.json"
                  SUPPORTED_SCHEMA_VERSION="1"
                  log()        { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
                  fail()       { log "[ERROR] $*" >&2; exit 1; }
                  step_begin() { log "[START] $*"; }
                  step_end()   { log "[DONE]  $*"; }
                  [ "$(id -u)" -eq 0 ] || { echo "[ERROR] This document must run as root." >&2; exit 1; }
                  # Make HOME explicit (it may be unset under Run Command).
                  # Work for ec2-user uses "sudo -u ec2-user -H".
                  export HOME=/root
                  mkdir -p "$LOG_DIR"
                  LOG_FILE="${LOG_DIR}/${DOC_NAME}-$(date '+%Y%m%d-%H%M%S').log"
                  exec > >(tee -a "$LOG_FILE") 2>&1
                  TMP_DIR=$(mktemp -d /tmp/ide-setup.XXXXXX)
                  trap 'rm -rf "$TMP_DIR"' EXIT
                  trap 'log "[ERROR] Command failed at line ${LINENO} (exit code: $?)" >&2' ERR
                  log "Starting ${DOC_NAME} (log file: ${LOG_FILE})"
                  step_begin "Check the environment info JSON"
                  [ -f "$ENV_JSON" ] || fail "${ENV_JSON} was not found. UserData may still be running. Wait a few minutes and run this document again. (Check: cloud-init status)"
                  jq -e . "$ENV_JSON" > /dev/null || fail "${ENV_JSON} is not valid JSON."
                  env_get() { jq -r --arg k "$1" '(.[$k] // empty) | tostring' "$ENV_JSON"; }
                  [ "$(env_get schemaVersion)" = "$SUPPORTED_SCHEMA_VERSION" ] \
                    || fail "schemaVersion mismatch (JSON: $(env_get schemaVersion), this document: ${SUPPORTED_SCHEMA_VERSION}). The PerPerson template and this document are out of sync."
                  for key in systemName subName yourId yourEmail region domainName timeZone \
                             codeserverFqdn kirocrewFqdn userPoolId userPoolClientId \
                             userPoolProviderUrl cognitoDomain workDir; do
                    [ -n "$(env_get "$key")" ] || fail "Key '${key}' is missing or empty in ${ENV_JSON}."
                  done
                  SYSTEM_NAME=$(env_get systemName);   SUB_NAME=$(env_get subName)
                  YOUR_ID=$(env_get yourId);           YOUR_EMAIL=$(env_get yourEmail)
                  REGION=$(env_get region);            DOMAIN_NAME=$(env_get domainName)
                  TIME_ZONE=$(env_get timeZone)
                  CODESERVER_FQDN=$(env_get codeserverFqdn); KIROCREW_FQDN=$(env_get kirocrewFqdn)
                  USER_POOL_ID=$(env_get userPoolId);  USER_POOL_CLIENT_ID=$(env_get userPoolClientId)
                  USER_POOL_PROVIDER_URL=$(env_get userPoolProviderUrl)
                  COGNITO_DOMAIN=$(env_get cognitoDomain); WORK_DIR=$(env_get workDir)
                  step_end "Check the environment info JSON (${SYSTEM_NAME}-${SUB_NAME} / ${YOUR_ID} / ${REGION})"
                  # ===== End of common block =====
                  # ----- Dependency check: Setup-NodePython must be completed (no change is made) -----
                  step_begin "Check that a pyenv Python is available for ec2-user"
                  read -r -d '' CHECK_SCRIPT << 'EOF' || true
                  set -eo pipefail   # no "-u": "pyenv init" output is not nounset-safe
                  export PYENV_ROOT="$HOME/.pyenv"
                  export PATH="$PYENV_ROOT/bin:$PATH"
                  [ -x "$PYENV_ROOT/bin/pyenv" ] || { echo "pyenv is not installed."; exit 1; }
                  eval "$(pyenv init - bash)"
                  [ "$(pyenv version-name)" != "system" ] || { echo "No pyenv Python is selected."; exit 1; }
                  python --version
                  EOF
                  sudo -u ec2-user -H bash -c "$CHECK_SCRIPT" \
                    || fail "A pyenv Python is not available for ec2-user. Run ${SYSTEM_NAME}-${SUB_NAME}-Setup-NodePython first. Nothing has been changed."
                  step_end "Check that a pyenv Python is available for ec2-user"
                  # ----- OS libraries for Playwright's Chromium (runs as root) -----
                  # "playwright install --with-deps" does not work on Amazon Linux 2023 (it assumes apt), so install the libraries here.
                  step_begin "Install the OS libraries for Playwright"
                  dnf install -y \
                    atk at-spi2-atk cups-libs libXcomposite libXdamage libXfixes \
                    libXrandr mesa-libgbm pango alsa-lib nss nspr at-spi2-core \
                    libdrm libxkbcommon libXScrnSaver
                  step_end "Install the OS libraries for Playwright"
                  # ----- Python modules (runs as ec2-user; versions are not pinned: the latest are installed) -----
                  step_begin "Install Python modules"
                  read -r -d '' MODULES_SCRIPT << 'EOF' || true
                  set -eo pipefail   # no "-u": "pyenv init" output is not nounset-safe
                  export PYENV_ROOT="$HOME/.pyenv"
                  export PATH="$PYENV_ROOT/bin:$PATH"
                  eval "$(pyenv init - bash)"
                  # If a module has no build for this Python version (e.g. a very new Python), pip fails and so does this document.
                  python -m pip install --upgrade \
                    cfn-lint \
                    ruff \
                    pdf_oxide \
                    pdfminer.six \
                    openpyxl \
                    xlsxwriter \
                    python-pptx \
                    python-docx \
                    pypandoc_binary \
                    git-remote-codecommit \
                    boto3 \
                    pandas \
                    numpy \
                    scipy \
                    scikit-learn \
                    nltk \
                    Markdown \
                    pymdown-extensions \
                    playwright
                  pyenv rehash
                  # Chromium for Playwright. Safe to run again: browsers that are already downloaded are skipped.
                  # Run it again after Playwright is upgraded, so that the matching Chromium is downloaded.
                  python -m playwright install chromium
                  cfn-lint --version
                  ruff --version
                  python -m playwright --version
                  python -c "import openpyxl, pptx, docx, pandas, numpy, boto3; print('modules OK')"
                  python -m pip list
                  EOF
                  sudo -u ec2-user -H bash -c "$MODULES_SCRIPT" ide-setup
                  step_end "Install Python modules"
                  # ----- Check that Chromium starts (fails the document if a shared library is missing) -----
                  step_begin "Check that Chromium starts"
                  read -r -d '' CHROMIUM_CHECK_SCRIPT << 'EOF' || true
                  set -eo pipefail   # no "-u": "pyenv init" output is not nounset-safe
                  export PYENV_ROOT="$HOME/.pyenv"
                  export PATH="$PYENV_ROOT/bin:$PATH"
                  eval "$(pyenv init - bash)"
                  python - << 'PYEOF'
                  from playwright.sync_api import sync_playwright
                  with sync_playwright() as p:
                    browser = p.chromium.launch()
                    page = browser.new_page()
                    page.set_content("<title>playwright-ok</title>")
                    assert page.title() == "playwright-ok"
                    print("Chromium " + browser.version + " started and rendered a page.")
                    browser.close()
                  PYEOF
                  EOF
                  sudo -u ec2-user -H bash -c "$CHROMIUM_CHECK_SCRIPT" ide-setup \
                    || fail "Chromium did not start. Check the OS libraries and the Playwright installation above."
                  step_end "Check that Chromium starts"
                  log "[COMPLETE] ${DOC_NAME} finished successfully."
      Tags:
        - Key: Cost
          Value: !Sub "${SystemName}-${SubName}"

  DocumentSetupKiroCli:
    Type: AWS::SSM::Document
    Properties:
      Name: !Sub "${SystemName}-${SubName}-Setup-KiroCli"
      DocumentType: Command
      DocumentFormat: YAML
      TargetType: /AWS::EC2::Instance
      UpdateMethod: NewVersion
      Content:
        schemaVersion: "2.2"
        description: "Sets up Kiro CLI, uv, the MCP settings, and a provisional steering file (AGENTS.md)."
        mainSteps:
          - action: aws:runShellScript
            name: SetupKiroCli
            inputs:
              timeoutSeconds: "1800"
              runCommand:
                - |
                  #!/bin/bash
                  # shellcheck disable=SC2034  # variables from the common block are not all used by every document
                  # ===== Common block (identical in all Setup-* documents except DOC_NAME) =====
                  set -eEuo pipefail   # Fail as failure. Never use "set -x" (it could print secrets).
                  umask 022
                  export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
                  DOC_NAME="Setup-KiroCli"
                  LOG_DIR="/var/log/ide-setup"
                  CONFIG_DIR="/etc/ide-setup-config"
                  ENV_JSON="${CONFIG_DIR}/environment.json"
                  SUPPORTED_SCHEMA_VERSION="1"
                  log()        { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
                  fail()       { log "[ERROR] $*" >&2; exit 1; }
                  step_begin() { log "[START] $*"; }
                  step_end()   { log "[DONE]  $*"; }
                  [ "$(id -u)" -eq 0 ] || { echo "[ERROR] This document must run as root." >&2; exit 1; }
                  # Make HOME explicit (it may be unset under Run Command).
                  # Work for ec2-user uses "sudo -u ec2-user -H".
                  export HOME=/root
                  mkdir -p "$LOG_DIR"
                  LOG_FILE="${LOG_DIR}/${DOC_NAME}-$(date '+%Y%m%d-%H%M%S').log"
                  exec > >(tee -a "$LOG_FILE") 2>&1
                  TMP_DIR=$(mktemp -d /tmp/ide-setup.XXXXXX)
                  trap 'rm -rf "$TMP_DIR"' EXIT
                  trap 'log "[ERROR] Command failed at line ${LINENO} (exit code: $?)" >&2' ERR
                  log "Starting ${DOC_NAME} (log file: ${LOG_FILE})"
                  step_begin "Check the environment info JSON"
                  [ -f "$ENV_JSON" ] || fail "${ENV_JSON} was not found. UserData may still be running. Wait a few minutes and run this document again. (Check: cloud-init status)"
                  jq -e . "$ENV_JSON" > /dev/null || fail "${ENV_JSON} is not valid JSON."
                  env_get() { jq -r --arg k "$1" '(.[$k] // empty) | tostring' "$ENV_JSON"; }
                  [ "$(env_get schemaVersion)" = "$SUPPORTED_SCHEMA_VERSION" ] \
                    || fail "schemaVersion mismatch (JSON: $(env_get schemaVersion), this document: ${SUPPORTED_SCHEMA_VERSION}). The PerPerson template and this document are out of sync."
                  for key in systemName subName yourId yourEmail region domainName timeZone \
                             codeserverFqdn kirocrewFqdn userPoolId userPoolClientId \
                             userPoolProviderUrl cognitoDomain workDir; do
                    [ -n "$(env_get "$key")" ] || fail "Key '${key}' is missing or empty in ${ENV_JSON}."
                  done
                  SYSTEM_NAME=$(env_get systemName);   SUB_NAME=$(env_get subName)
                  YOUR_ID=$(env_get yourId);           YOUR_EMAIL=$(env_get yourEmail)
                  REGION=$(env_get region);            DOMAIN_NAME=$(env_get domainName)
                  TIME_ZONE=$(env_get timeZone)
                  CODESERVER_FQDN=$(env_get codeserverFqdn); KIROCREW_FQDN=$(env_get kirocrewFqdn)
                  USER_POOL_ID=$(env_get userPoolId);  USER_POOL_CLIENT_ID=$(env_get userPoolClientId)
                  USER_POOL_PROVIDER_URL=$(env_get userPoolProviderUrl)
                  COGNITO_DOMAIN=$(env_get cognitoDomain); WORK_DIR=$(env_get workDir)
                  step_end "Check the environment info JSON (${SYSTEM_NAME}-${SUB_NAME} / ${YOUR_ID} / ${REGION})"
                  # ===== End of common block =====
                  EC2_HOME=/home/ec2-user
                  # ----- Kiro CLI (runs as ec2-user) -----
                  step_begin "Install Kiro CLI"
                  read -r -d '' KIRO_SCRIPT << 'EOF' || true
                  set -eo pipefail
                  export PATH="$HOME/.local/bin:$PATH"
                  if [ -x "$HOME/.local/bin/kiro-cli" ] && kiro-cli --version > /dev/null 2>&1; then
                    # The installer asks "replace it?" on /dev/tty when Kiro CLI exists, so never run it again
                    echo "Kiro CLI is already installed ($(kiro-cli --version)). Skipping."
                  else
                    # The installer needs 2 GiB in its work folder; /tmp is tmpfs (RAM), so use the root disk
                    curl -fsSL https://cli.kiro.dev/install | TMPDIR=/var/tmp bash
                  fi
                  kiro-cli --version
                  EOF
                  sudo -u ec2-user -H bash -c "$KIRO_SCRIPT" ide-setup
                  step_end "Install Kiro CLI"
                  # ----- uv (runs as ec2-user) -----
                  step_begin "Install uv"
                  read -r -d '' UV_SCRIPT << 'EOF' || true
                  set -eo pipefail
                  export PATH="$HOME/.local/bin:$PATH"
                  if [ -x "$HOME/.local/bin/uv" ] && [ -x "$HOME/.local/bin/uvx" ]; then
                    echo "uv is already installed ($(uv --version)). Skipping."
                  else
                    # ~/.local/bin is already on PATH through the default ~/.bashrc, so do not let the installer edit shell profiles
                    curl -LsSf https://astral.sh/uv/install.sh | UV_NO_MODIFY_PATH=1 sh
                  fi
                  uv --version
                  uvx --version
                  EOF
                  sudo -u ec2-user -H bash -c "$UV_SCRIPT" ide-setup
                  step_end "Install uv"
                  # ----- Kiro CLI settings (created only when missing: the developer edits them) -----
                  step_begin "Create the Kiro CLI settings"
                  make_dir() {   # make_dir <path> <mode>: create only when missing, owned by ec2-user
                    [ -d "$1" ] || install -d -o ec2-user -g ec2-user -m "$2" "$1"
                  }
                  make_dir "${EC2_HOME}/.cache" 700
                  make_dir "${EC2_HOME}/.kiro" 700
                  make_dir "${EC2_HOME}/.kiro/settings" 700
                  make_dir "${EC2_HOME}/.kiro/steering" 755
                  if [ -e "${EC2_HOME}/.kiro/settings/mcp.json" ]; then
                    log "${EC2_HOME}/.kiro/settings/mcp.json already exists. Not changed."
                  else
                    cat > "${TMP_DIR}/mcp.json" << 'EOF'
                  {
                    "mcpServers": {
                      "awslabs.aws-documentation-mcp-server": {
                        "command": "uvx",
                        "args": ["awslabs.aws-documentation-mcp-server@latest"],
                        "env": {
                          "FASTMCP_LOG_LEVEL": "ERROR",
                          "AWS_DOCUMENTATION_PARTITION": "aws"
                        },
                        "disabled": false,
                        "autoApprove": []
                      }
                    }
                  }
                  EOF
                    install -m 600 -o ec2-user -g ec2-user "${TMP_DIR}/mcp.json" "${EC2_HOME}/.kiro/settings/mcp.json"
                    log "Created ${EC2_HOME}/.kiro/settings/mcp.json"
                  fi
                  if [ -e "${EC2_HOME}/.kiro/steering/AGENTS.md" ]; then
                    log "${EC2_HOME}/.kiro/steering/AGENTS.md already exists. Not changed."
                  else
                    cat > "${TMP_DIR}/AGENTS.md" << 'EOF'
                  # Steering Instructions

                  ## Common rules

                  - 日本語で回答すること

                  ## Change Confirmation Policy

                  変更作業は必ずユーザーが内容を理解したことを確認し、実行の指示を得てから作業してください。
                  いきなりファイルに変更をかけることは絶対にしないでください。
                  ユーザーは、指定がない限り対話型での作業を期待しています。
                  EOF
                    install -m 644 -o ec2-user -g ec2-user "${TMP_DIR}/AGENTS.md" "${EC2_HOME}/.kiro/steering/AGENTS.md"
                    log "Created ${EC2_HOME}/.kiro/steering/AGENTS.md (provisional)"
                  fi
                  step_end "Create the Kiro CLI settings"
                  log "[COMPLETE] ${DOC_NAME} finished successfully. Next: sign in manually with 'kiro-cli login' (not done by this document)."
      Tags:
        - Key: Cost
          Value: !Sub "${SystemName}-${SubName}"

  DocumentSetupKiroCrew:
    Type: AWS::SSM::Document
    Properties:
      Name: !Sub "${SystemName}-${SubName}-Setup-KiroCrew"
      DocumentType: Command
      DocumentFormat: YAML
      TargetType: /AWS::EC2::Instance
      UpdateMethod: NewVersion
      Content:
        schemaVersion: "2.2"
        description: "Sets up Kiro Crew with its own FQDN, certificate, oauth2-proxy, and nginx settings. Run Setup-CodeServer, Setup-NodePython, and Setup-KiroCli first."
        mainSteps:
          - action: aws:runShellScript
            name: SetupKiroCrew
            inputs:
              timeoutSeconds: "1800"
              runCommand:
                - |
                  #!/bin/bash
                  # shellcheck disable=SC2034  # variables from the common block are not all used by every document
                  # ===== Common block (identical in all Setup-* documents except DOC_NAME) =====
                  set -eEuo pipefail   # Fail as failure. Never use "set -x" (it could print secrets).
                  umask 022
                  export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
                  DOC_NAME="Setup-KiroCrew"
                  LOG_DIR="/var/log/ide-setup"
                  CONFIG_DIR="/etc/ide-setup-config"
                  ENV_JSON="${CONFIG_DIR}/environment.json"
                  SUPPORTED_SCHEMA_VERSION="1"
                  log()        { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
                  fail()       { log "[ERROR] $*" >&2; exit 1; }
                  step_begin() { log "[START] $*"; }
                  step_end()   { log "[DONE]  $*"; }
                  [ "$(id -u)" -eq 0 ] || { echo "[ERROR] This document must run as root." >&2; exit 1; }
                  # Make HOME explicit (it may be unset under Run Command).
                  # Work for ec2-user uses "sudo -u ec2-user -H".
                  export HOME=/root
                  mkdir -p "$LOG_DIR"
                  LOG_FILE="${LOG_DIR}/${DOC_NAME}-$(date '+%Y%m%d-%H%M%S').log"
                  exec > >(tee -a "$LOG_FILE") 2>&1
                  TMP_DIR=$(mktemp -d /tmp/ide-setup.XXXXXX)
                  trap 'rm -rf "$TMP_DIR"' EXIT
                  trap 'log "[ERROR] Command failed at line ${LINENO} (exit code: $?)" >&2' ERR
                  log "Starting ${DOC_NAME} (log file: ${LOG_FILE})"
                  step_begin "Check the environment info JSON"
                  [ -f "$ENV_JSON" ] || fail "${ENV_JSON} was not found. UserData may still be running. Wait a few minutes and run this document again. (Check: cloud-init status)"
                  jq -e . "$ENV_JSON" > /dev/null || fail "${ENV_JSON} is not valid JSON."
                  env_get() { jq -r --arg k "$1" '(.[$k] // empty) | tostring' "$ENV_JSON"; }
                  [ "$(env_get schemaVersion)" = "$SUPPORTED_SCHEMA_VERSION" ] \
                    || fail "schemaVersion mismatch (JSON: $(env_get schemaVersion), this document: ${SUPPORTED_SCHEMA_VERSION}). The PerPerson template and this document are out of sync."
                  for key in systemName subName yourId yourEmail region domainName timeZone \
                             codeserverFqdn kirocrewFqdn userPoolId userPoolClientId \
                             userPoolProviderUrl cognitoDomain workDir; do
                    [ -n "$(env_get "$key")" ] || fail "Key '${key}' is missing or empty in ${ENV_JSON}."
                  done
                  SYSTEM_NAME=$(env_get systemName);   SUB_NAME=$(env_get subName)
                  YOUR_ID=$(env_get yourId);           YOUR_EMAIL=$(env_get yourEmail)
                  REGION=$(env_get region);            DOMAIN_NAME=$(env_get domainName)
                  TIME_ZONE=$(env_get timeZone)
                  CODESERVER_FQDN=$(env_get codeserverFqdn); KIROCREW_FQDN=$(env_get kirocrewFqdn)
                  USER_POOL_ID=$(env_get userPoolId);  USER_POOL_CLIENT_ID=$(env_get userPoolClientId)
                  USER_POOL_PROVIDER_URL=$(env_get userPoolProviderUrl)
                  COGNITO_DOMAIN=$(env_get cognitoDomain); WORK_DIR=$(env_get workDir)
                  step_end "Check the environment info JSON (${SYSTEM_NAME}-${SUB_NAME} / ${YOUR_ID} / ${REGION})"
                  # ===== End of common block =====
                  EC2_HOME=/home/ec2-user
                  # ----- Dependency check (no change is made) -----
                  step_begin "Check the dependencies"
                  # (1) Checks for ec2-user: Python, Node.js, Kiro CLI, uvx. No "-e" so that all problems are reported at once.
                  read -r -d '' CHECK_SCRIPT << 'EOF' || true
                  export PATH="$HOME/.local/bin:$PATH"
                  export PYENV_ROOT="$HOME/.pyenv"
                  export NVM_DIR="$HOME/.nvm"
                  ok()      { echo "[OK]      $*"; }
                  missing() { echo "[MISSING] $*"; }
                  warn()    { echo "[WARN]    $*"; }
                  # Python 3.12 or later, provided by pyenv
                  if [ -x "$PYENV_ROOT/bin/pyenv" ]; then
                    export PATH="$PYENV_ROOT/bin:$PATH"
                    eval "$(pyenv init - bash)"
                    if [ "$(pyenv version-name)" != "system" ] \
                       && python -c 'import sys; sys.exit(0 if sys.version_info >= (3, 12) else 1)' 2> /dev/null; then
                      ok "Python $(python --version 2>&1 | cut -d' ' -f2) (pyenv)"
                    else
                      missing "Python 3.12 or later from pyenv. Run Setup-NodePython."
                    fi
                  else
                    missing "pyenv is not installed. Run Setup-NodePython."
                  fi
                  # Node.js 22.12 or later as the nvm default
                  if [ -s "$NVM_DIR/nvm.sh" ]; then
                    . "$NVM_DIR/nvm.sh"
                    NODE_DEFAULT=$(nvm version default 2> /dev/null || true)   # e.g. v24.22.0, or N/A
                    NODE_NUM="${NODE_DEFAULT#v}"
                    if [[ "$NODE_NUM" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \
                       && [ "$(printf '%s\n' 22.12.0 "$NODE_NUM" | sort -V | head -1)" = "22.12.0" ]; then
                      ok "Node.js ${NODE_DEFAULT} (nvm default)"
                    else
                      missing "Node.js 22.12 or later as the nvm default (found: ${NODE_DEFAULT:-none}). Run Setup-NodePython."
                    fi
                  else
                    missing "nvm is not installed. Run Setup-NodePython."
                  fi
                  # Kiro CLI
                  if kiro-cli --version > /dev/null 2>&1; then
                    ok "Kiro CLI $(kiro-cli --version)"
                  else
                    missing "Kiro CLI cannot be run. Run Setup-KiroCli."
                  fi
                  # uvx (warning only: needed to start MCP servers)
                  if [ -x "$HOME/.local/bin/uvx" ]; then ok "uvx"; else warn "uvx was not found (MCP servers cannot start). Run Setup-KiroCli."; fi
                  EOF
                  CHECK_OUT=$(sudo -u ec2-user -H bash -c "$CHECK_SCRIPT" ide-setup 2>&1)
                  echo "$CHECK_OUT"
                  MISSING_COUNT=$(grep -c '^\[MISSING\]' <<< "$CHECK_OUT" || true)
                  # (2) Checks for root: Setup-CodeServer must be completed
                  miss() { log "[MISSING] $*"; MISSING_COUNT=$((MISSING_COUNT + 1)); }
                  for svc in nginx oauth2-proxy; do
                    if systemctl is-active --quiet "$svc"; then log "[OK]      ${svc} is running"; else miss "${svc} is not running. Run Setup-CodeServer."; fi
                  done
                  [ -x /usr/local/bin/oauth2-proxy ]                              || miss "/usr/local/bin/oauth2-proxy was not found. Run Setup-CodeServer."
                  [ -s /etc/oauth2-proxy/allowed_emails.txt ]                     || miss "/etc/oauth2-proxy/allowed_emails.txt was not found. Run Setup-CodeServer."
                  [ -f /etc/nginx/conf.d/codeserver.conf ]                        || miss "/etc/nginx/conf.d/codeserver.conf was not found. Run Setup-CodeServer."
                  [ -s "/etc/letsencrypt/live/${CODESERVER_FQDN}/fullchain.pem" ] || miss "The certificate of ${CODESERVER_FQDN} was not found. Run Setup-CodeServer."
                  [ -f "${EC2_HOME}/.config/code-server/config.yaml" ]            || miss "The code-server config was not found. Run Setup-CodeServer."
                  if [ -d "$WORK_DIR" ] && [ "$(stat -c %U "$WORK_DIR")" = "ec2-user" ]; then
                    log "[OK]      ${WORK_DIR} exists and is owned by ec2-user"
                  else
                    miss "${WORK_DIR} does not exist or is not owned by ec2-user. Run Setup-CodeServer."
                  fi
                  # (3) Memory (warning only): the official minimum is 10GB
                  MEM_KIB=$(awk '/^MemTotal:/ { print $2 }' /proc/meminfo)
                  if [ "$MEM_KIB" -lt $((10 * 1024 * 1024)) ]; then
                    log "[WARN]    Memory is $((MEM_KIB / 1024)) MiB. Kiro Crew officially needs at least 10GB (16GB recommended)."
                  else
                    log "[OK]      Memory is $((MEM_KIB / 1024)) MiB"
                  fi
                  if [ "$MISSING_COUNT" -gt 0 ]; then
                    fail "${MISSING_COUNT} dependency check(s) failed. See the [MISSING] lines above. Nothing has been changed."
                  fi
                  step_end "Check the dependencies"
                  # ----- linger: keeps ec2-user's systemd and /run/user/<uid>/bus alive (needed by Kiro Crew) -----
                  step_begin "Enable linger for ec2-user"
                  loginctl enable-linger ec2-user
                  EC2_UID=$(id -u ec2-user)
                  for _ in $(seq 1 30); do   # the user manager starts asynchronously
                    [ -S "/run/user/${EC2_UID}/bus" ] && break
                    sleep 1
                  done
                  [ -S "/run/user/${EC2_UID}/bus" ] \
                    || fail "/run/user/${EC2_UID}/bus was not created after 'loginctl enable-linger ec2-user'. Kiro Crew cannot be installed as a service."
                  step_end "Enable linger for ec2-user"
                  # ----- Kiro Crew: install, configure, and register the service (runs as ec2-user) -----
                  step_begin "Install and configure Kiro Crew"
                  read -r -d '' CREW_SCRIPT << 'EOF' || true
                  set -eo pipefail
                  TIME_ZONE="$1"; KIROCREW_FQDN="$2"; WORK_DIR="$3"
                  # The user bus for "kirocrew service install" (the shell started by sudo has no login session)
                  XDG_RUNTIME_DIR="/run/user/$(id -u)"
                  export XDG_RUNTIME_DIR
                  export DBUS_SESSION_BUS_ADDRESS="unix:path=${XDG_RUNTIME_DIR}/bus"
                  # "kirocrew service install" writes the PATH of this moment into the service,
                  # so put pyenv shims and the default nvm Node.js on PATH first
                  export PYENV_ROOT="$HOME/.pyenv"
                  export PATH="$PYENV_ROOT/bin:$PATH"
                  eval "$(pyenv init - bash)"
                  export NVM_DIR="$HOME/.nvm"
                  . "$NVM_DIR/nvm.sh"
                  nvm use default > /dev/null
                  export PATH="$HOME/.local/bin:$PATH"
                  echo "PATH used by the service: $PATH"
                  # Install with the pyenv Python (--system-python): no managed CPython is downloaded
                  if command -v kirocrew > /dev/null 2>&1 && kirocrew --version > /dev/null 2>&1; then
                    echo "Kiro Crew is already installed ($(kirocrew --version)). Skipping the installation."
                  else
                    export KIROCREW_INSTALL_PLAIN=1
                    curl -fsSL https://download.crew.kiro.dev/cli.sh | sh -s -- --system-python
                    hash -r
                    kirocrew --version
                  fi
                  # Initial setup (agent only; sign-in stays manual). Skipped when the config already exists.
                  if [ -f "$HOME/.kiro/crew/config.json" ]; then
                    echo "$HOME/.kiro/crew/config.json exists. Skipping 'kirocrew setup'."
                  else
                    kirocrew setup --agent-only < /dev/null
                  fi
                  # Settings (set to the same values every time)
                  kirocrew config set timezone "$TIME_ZONE"
                  kirocrew config set dashboard.url "https://${KIROCREW_FQDN}"
                  # workspaces.default.dir: "config set <key> <value>" rejects an absolute path, so pass the whole config with --file
                  umask 077
                  CONFIG_TMP=$(mktemp)
                  trap 'rm -f "$CONFIG_TMP"' EXIT
                  jq --arg d "$WORK_DIR" '.workspaces.default.dir = $d' "$HOME/.kiro/crew/config.json" > "$CONFIG_TMP"
                  kirocrew config set --file "$CONFIG_TMP"
                  echo "workspaces.default.dir = $(jq -r '.workspaces.default.dir' "$HOME/.kiro/crew/config.json")"
                  # Service: never re-run 'service install' when it exists (it restarts the gateway, which invalidates tokens and interrupts sessions)
                  if [ -f /etc/systemd/system/kirocrew.service ]; then
                    echo "kirocrew.service already exists. Not re-installed."
                    echo "To switch the Node.js used by the service, run 'kirocrew service install' again by hand."
                  else
                    kirocrew service install
                  fi
                  kirocrew service status | head -5 || true
                  EOF
                  sudo -u ec2-user -H bash -c "$CREW_SCRIPT" ide-setup "$TIME_ZONE" "$KIROCREW_FQDN" "$WORK_DIR"
                  step_end "Install and configure Kiro Crew"
                  # ----- Certificate for Kiro Crew -----
                  step_begin "Issue the certificate for ${KIROCREW_FQDN}"
                  LE_LIVE_KC="/etc/letsencrypt/live/${KIROCREW_FQDN}"
                  if [ -s "${LE_LIVE_KC}/fullchain.pem" ] && [ -s "${LE_LIVE_KC}/privkey.pem" ]; then
                    log "The certificate already exists. Not re-issued (use renew-cert-kirocrew.sh to renew)."
                  else
                    certbot certonly --dns-route53 -d "$KIROCREW_FQDN" --agree-tos -m "$YOUR_EMAIL" \
                      --non-interactive --preferred-challenges dns-01
                  fi
                  step_end "Issue the certificate for ${KIROCREW_FQDN}"
                  # ----- oauth2-proxy-kirocrew service (generated from the environment info and Cognito; nothing is copied) -----
                  step_begin "Configure the oauth2-proxy-kirocrew service"
                  OPK_UNIT=/etc/systemd/system/oauth2-proxy-kirocrew.service
                  # Client secret: always fetched from Cognito (never printed)
                  CLIENT_SECRET=$(aws cognito-idp describe-user-pool-client \
                    --user-pool-id "$USER_POOL_ID" --client-id "$USER_POOL_CLIENT_ID" --region "$REGION" \
                    --query 'UserPoolClient.ClientSecret' --output text)
                  { [ -n "$CLIENT_SECRET" ] && [ "$CLIENT_SECRET" != "None" ]; } \
                    || fail "Could not get the client secret of the Cognito app client."
                  # Cookie secret (separate from the one of code-server): keep the existing one, otherwise generate
                  COOKIE_SECRET=""
                  if [ -f "$OPK_UNIT" ]; then
                    COOKIE_SECRET=$(sed -n 's/^Environment=COOKIE_SECRET=//p' "$OPK_UNIT" | head -1)
                  fi
                  if [ -z "$COOKIE_SECRET" ]; then
                    COOKIE_SECRET=$(openssl rand -base64 32 | tr '+/' '-_')
                    log "Generated a new cookie secret."
                  else
                    log "Kept the existing cookie secret."
                  fi
                  # shellcheck disable=SC2016  # the single-quoted $CLIENT_SECRET and $COOKIE_SECRET are expanded by systemd, not by the shell
                  OPK_ARGS=(
                    --provider=oidc
                    "--oidc-issuer-url=${USER_POOL_PROVIDER_URL}"
                    "--client-id=${USER_POOL_CLIENT_ID}"
                    '--client-secret=${CLIENT_SECRET}'
                    "--redirect-url=https://${KIROCREW_FQDN}/oauth2/callback"
                    "--login-url=https://${COGNITO_DOMAIN}/oauth2/authorize"
                    "--profile-url=https://${COGNITO_DOMAIN}/oauth2/userInfo"
                    "--redeem-url=https://${COGNITO_DOMAIN}/oauth2/token"
                    --scope=openid
                    --cookie-name=_oauth2_proxy_kirocrew
                    --cookie-secure=true
                    --cookie-expire=72h
                    --cookie-refresh=1h
                    '--cookie-secret=${COOKIE_SECRET}'
                    --authenticated-emails-file=/etc/oauth2-proxy/allowed_emails.txt
                    --http-address=127.0.0.1:4181
                  )
                  {
                    cat << 'EOF'
                  [Unit]
                  Description=oauth2-proxy for Kiro Crew
                  After=network.target
                  [Service]
                  Type=simple
                  EOF
                    printf 'Environment=CLIENT_SECRET=%s\n' "$CLIENT_SECRET"
                    printf 'Environment=COOKIE_SECRET=%s\n' "$COOKIE_SECRET"
                    printf 'ExecStart=/usr/local/bin/oauth2-proxy'; printf ' %s' "${OPK_ARGS[@]}"; printf '\n'
                    cat << 'EOF'
                  Restart=on-failure
                  User=ec2-user
                  Group=ec2-user
                  [Install]
                  WantedBy=multi-user.target
                  EOF
                  } > "${TMP_DIR}/oauth2-proxy-kirocrew.service"
                  OPK_UNIT_CHANGED=0
                  if ! cmp -s "${TMP_DIR}/oauth2-proxy-kirocrew.service" "$OPK_UNIT"; then
                    install -m 600 -o root -g root "${TMP_DIR}/oauth2-proxy-kirocrew.service" "$OPK_UNIT"   # 600: it contains secrets
                    OPK_UNIT_CHANGED=1
                    log "Wrote ${OPK_UNIT} (contents are not shown because they contain secrets)."
                  else
                    log "${OPK_UNIT} is unchanged."
                  fi
                  systemctl daemon-reload
                  systemctl enable oauth2-proxy-kirocrew
                  # Restart only when the definition changed or the service is not running.
                  # A running instance keeps its old oauth2-proxy binary after Setup-CodeServer changed the version (restart it by hand).
                  if [ "$OPK_UNIT_CHANGED" -eq 1 ] || ! systemctl is-active --quiet oauth2-proxy-kirocrew; then
                    systemctl restart oauth2-proxy-kirocrew
                  fi
                  unset CLIENT_SECRET COOKIE_SECRET
                  OPK_PING=""
                  for _ in $(seq 1 30); do
                    OPK_PING=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:4181/ping || true)
                    [ "$OPK_PING" = "200" ] && break
                    sleep 1
                  done
                  [ "$OPK_PING" = "200" ] \
                    || fail "oauth2-proxy-kirocrew /ping did not return 200 (last: ${OPK_PING:-none}). Check 'journalctl -u oauth2-proxy-kirocrew'."
                  step_end "Configure the oauth2-proxy-kirocrew service (/ping OK)"
                  # ----- nginx for Kiro Crew -----
                  step_begin "Configure nginx for Kiro Crew"
                  install -d -m 700 /etc/nginx/ssl
                  install -m 644 "${LE_LIVE_KC}/fullchain.pem" /etc/nginx/ssl/kirocrew-chain.crt
                  install -m 600 "${LE_LIVE_KC}/privkey.pem"   /etc/nginx/ssl/kirocrew.key
                  cat > "${TMP_DIR}/kirocrew.conf.tpl" << 'EOF'
                  server {
                    listen 443 ssl;
                    server_name __KIROCREW_FQDN__;
                    large_client_header_buffers 4 16k;
                    ssl_certificate_key /etc/nginx/ssl/kirocrew.key;
                    ssl_certificate /etc/nginx/ssl/kirocrew-chain.crt;
                    location / {
                      auth_request /oauth2/auth;
                      error_page 401 = /oauth2/sign_in;
                      proxy_pass http://127.0.0.1:5476;
                      proxy_http_version 1.1;
                      proxy_set_header Upgrade $http_upgrade;
                      proxy_set_header Connection "upgrade";
                      proxy_set_header Host $host;
                      proxy_set_header X-Real-IP $remote_addr;
                      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                      proxy_set_header X-Forwarded-Proto $scheme;
                      proxy_read_timeout 86400s;
                      proxy_send_timeout 86400s;
                    }
                    location /oauth2/ {
                      proxy_pass http://127.0.0.1:4181;
                      proxy_set_header Host $host;
                      proxy_set_header X-Real-IP $remote_addr;
                      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                      proxy_set_header X-Forwarded-Proto $scheme;
                      proxy_set_header X-Auth-Request-Redirect $request_uri;
                      proxy_buffers 16 16k;
                      proxy_buffer_size 16k;
                    }
                    location = /oauth2/auth {
                      proxy_pass http://127.0.0.1:4181;
                      proxy_set_header Host $host;
                      proxy_set_header X-Real-IP $remote_addr;
                      proxy_set_header X-Scheme $scheme;
                      proxy_set_header Content-Length "";
                      proxy_pass_request_body off;
                      proxy_buffers 16 16k;
                      proxy_buffer_size 16k;
                    }
                  }
                  EOF
                  NGINX_CONF_KC=/etc/nginx/conf.d/kirocrew.conf
                  if [ -f "$NGINX_CONF_KC" ]; then cp -p "$NGINX_CONF_KC" "${TMP_DIR}/kirocrew.conf.bak"; fi
                  sed "s#__KIROCREW_FQDN__#${KIROCREW_FQDN}#g" "${TMP_DIR}/kirocrew.conf.tpl" > "$NGINX_CONF_KC"
                  if ! nginx -t; then   # roll back so that a bad file does not break other documents' reload
                    if [ -f "${TMP_DIR}/kirocrew.conf.bak" ]; then cp -p "${TMP_DIR}/kirocrew.conf.bak" "$NGINX_CONF_KC"; else rm -f "$NGINX_CONF_KC"; fi
                    fail "nginx -t failed. The previous nginx configuration was restored."
                  fi
                  systemctl enable nginx
                  if systemctl is-active --quiet nginx; then systemctl reload nginx; else systemctl start nginx; fi
                  step_end "Configure nginx for Kiro Crew"
                  # ----- Certificate renewal script -----
                  step_begin "Create the certificate renewal script"
                  cat > "${TMP_DIR}/renew-cert-kirocrew.sh.tpl" << 'EOF'
                  #!/bin/bash
                  # Renews the certificate of __KIROCREW_FQDN__ (Kiro Crew).
                  # Usage: sudo /home/ec2-user/renew-cert-kirocrew.sh
                  set -euo pipefail
                  [ "$(id -u)" -eq 0 ] || { echo "Run this script with sudo." >&2; exit 1; }
                  FQDN="__KIROCREW_FQDN__"
                  LIVE="/etc/letsencrypt/live/${FQDN}"
                  certbot renew --cert-name "$FQDN"
                  if cmp -s "${LIVE}/fullchain.pem" /etc/nginx/ssl/kirocrew-chain.crt \
                     && cmp -s "${LIVE}/privkey.pem" /etc/nginx/ssl/kirocrew.key; then
                    echo "The certificate is not due for renewal yet. Nothing was changed."
                    exit 0
                  fi
                  install -m 644 "${LIVE}/fullchain.pem" /etc/nginx/ssl/kirocrew-chain.crt
                  install -m 600 "${LIVE}/privkey.pem"   /etc/nginx/ssl/kirocrew.key
                  nginx -t
                  systemctl reload nginx
                  echo "The certificate was renewed and nginx was reloaded."
                  EOF
                  sed "s#__KIROCREW_FQDN__#${KIROCREW_FQDN}#g" "${TMP_DIR}/renew-cert-kirocrew.sh.tpl" > /home/ec2-user/renew-cert-kirocrew.sh
                  chown ec2-user:ec2-user /home/ec2-user/renew-cert-kirocrew.sh
                  chmod 755 /home/ec2-user/renew-cert-kirocrew.sh
                  step_end "Create the certificate renewal script"
                  # ----- End-to-end check -----
                  step_begin "Check Kiro Crew end to end"
                  # (1) The gateway answers for the public host name (dashboard.url allows it); it may need a moment to start
                  GW_CODE=""
                  for _ in $(seq 1 60); do
                    GW_CODE=$(curl -s -o /dev/null -w '%{http_code}' -H "Host: ${KIROCREW_FQDN}" http://127.0.0.1:5476/ || true)
                    [ "$GW_CODE" = "200" ] && break
                    sleep 1
                  done
                  [ "$GW_CODE" = "200" ] \
                    || fail "The Kiro Crew gateway did not return 200 on 127.0.0.1:5476 (last: ${GW_CODE:-none}). Check 'kirocrew service status' and 'kirocrew logs'."
                  # (2) nginx with the issued certificate shows the sign-in page (TLS is verified: the certificate must be valid for the FQDN).
                  # Retry: right after "systemctl reload nginx" the new configuration may not be served yet (seen in a test run: curl got no response).
                  NGX_CODE=""
                  for _ in $(seq 1 30); do
                    NGX_CODE=$(curl -sS -o /dev/null -w '%{http_code}' --resolve "${KIROCREW_FQDN}:443:127.0.0.1" "https://${KIROCREW_FQDN}/" 2> "${TMP_DIR}/nginx-curl.err" || true)
                    [ "$NGX_CODE" = "200" ] && break
                    sleep 1
                  done
                  if [ "$NGX_CODE" != "200" ]; then
                    fail "https://${KIROCREW_FQDN}/ did not return 200 through nginx (last: ${NGX_CODE:-none}; curl said: $(head -c 300 "${TMP_DIR}/nginx-curl.err")). Check 'nginx -t' and the certificate."
                  fi
                  step_end "Check Kiro Crew end to end"
                  log "[COMPLETE] ${DOC_NAME} finished successfully. Next: sign in with 'kiro-cli login', then run 'kirocrew token | grep ^https://' and open the URL."
      Tags:
        - Key: Cost
          Value: !Sub "${SystemName}-${SubName}"

  DocumentSetupLibreOffice:
    Type: AWS::SSM::Document
    Properties:
      Name: !Sub "${SystemName}-${SubName}-Setup-LibreOffice"
      DocumentType: Command
      DocumentFormat: YAML
      TargetType: /AWS::EC2::Instance
      UpdateMethod: NewVersion
      Content:
        schemaVersion: "2.2"
        description: "Installs the latest stable LibreOffice (official RPM) and links soffice."
        mainSteps:
          - action: aws:runShellScript
            name: SetupLibreOffice
            inputs:
              timeoutSeconds: "1800"
              runCommand:
                - |
                  #!/bin/bash
                  # shellcheck disable=SC2034  # variables from the common block are not all used by every document
                  # ===== Common block (identical in all Setup-* documents except DOC_NAME) =====
                  set -eEuo pipefail   # Fail as failure. Never use "set -x" (it could print secrets).
                  umask 022
                  export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
                  DOC_NAME="Setup-LibreOffice"
                  LOG_DIR="/var/log/ide-setup"
                  CONFIG_DIR="/etc/ide-setup-config"
                  ENV_JSON="${CONFIG_DIR}/environment.json"
                  SUPPORTED_SCHEMA_VERSION="1"
                  log()        { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
                  fail()       { log "[ERROR] $*" >&2; exit 1; }
                  step_begin() { log "[START] $*"; }
                  step_end()   { log "[DONE]  $*"; }
                  [ "$(id -u)" -eq 0 ] || { echo "[ERROR] This document must run as root." >&2; exit 1; }
                  # Make HOME explicit (it may be unset under Run Command).
                  # Work for ec2-user uses "sudo -u ec2-user -H".
                  export HOME=/root
                  mkdir -p "$LOG_DIR"
                  LOG_FILE="${LOG_DIR}/${DOC_NAME}-$(date '+%Y%m%d-%H%M%S').log"
                  exec > >(tee -a "$LOG_FILE") 2>&1
                  TMP_DIR=$(mktemp -d /tmp/ide-setup.XXXXXX)
                  trap 'rm -rf "$TMP_DIR"' EXIT
                  trap 'log "[ERROR] Command failed at line ${LINENO} (exit code: $?)" >&2' ERR
                  log "Starting ${DOC_NAME} (log file: ${LOG_FILE})"
                  step_begin "Check the environment info JSON"
                  [ -f "$ENV_JSON" ] || fail "${ENV_JSON} was not found. UserData may still be running. Wait a few minutes and run this document again. (Check: cloud-init status)"
                  jq -e . "$ENV_JSON" > /dev/null || fail "${ENV_JSON} is not valid JSON."
                  env_get() { jq -r --arg k "$1" '(.[$k] // empty) | tostring' "$ENV_JSON"; }
                  [ "$(env_get schemaVersion)" = "$SUPPORTED_SCHEMA_VERSION" ] \
                    || fail "schemaVersion mismatch (JSON: $(env_get schemaVersion), this document: ${SUPPORTED_SCHEMA_VERSION}). The PerPerson template and this document are out of sync."
                  for key in systemName subName yourId yourEmail region domainName timeZone \
                             codeserverFqdn kirocrewFqdn userPoolId userPoolClientId \
                             userPoolProviderUrl cognitoDomain workDir; do
                    [ -n "$(env_get "$key")" ] || fail "Key '${key}' is missing or empty in ${ENV_JSON}."
                  done
                  SYSTEM_NAME=$(env_get systemName);   SUB_NAME=$(env_get subName)
                  YOUR_ID=$(env_get yourId);           YOUR_EMAIL=$(env_get yourEmail)
                  REGION=$(env_get region);            DOMAIN_NAME=$(env_get domainName)
                  TIME_ZONE=$(env_get timeZone)
                  CODESERVER_FQDN=$(env_get codeserverFqdn); KIROCREW_FQDN=$(env_get kirocrewFqdn)
                  USER_POOL_ID=$(env_get userPoolId);  USER_POOL_CLIENT_ID=$(env_get userPoolClientId)
                  USER_POOL_PROVIDER_URL=$(env_get userPoolProviderUrl)
                  COGNITO_DOMAIN=$(env_get cognitoDomain); WORK_DIR=$(env_get workDir)
                  step_end "Check the environment info JSON (${SYSTEM_NAME}-${SUB_NAME} / ${YOUR_ID} / ${REGION})"
                  # ===== End of common block =====
                  LO_BASE_URL="https://download.documentfoundation.org/libreoffice/stable"
                  LO_WORK=""
                  # Work folder is on the root disk (/var/tmp): /tmp is tmpfs (RAM) and the package is large
                  trap 'rm -rf "$TMP_DIR" "$LO_WORK"' EXIT
                  # ----- Find the latest stable version (no change is made) -----
                  step_begin "Find the latest stable LibreOffice"
                  LO_LISTING=$(curl -fsSL --retry 3 "${LO_BASE_URL}/")
                  LO_VERSIONS=$(grep -oE 'href="[0-9]+(\.[0-9]+)+/"' <<< "$LO_LISTING" | sed -E 's/^href="//; s#/"$##' | sort -V || true)
                  [ -n "$LO_VERSIONS" ] || fail "Could not read the LibreOffice version list. Nothing has been changed."
                  LO_VERSION=$(tail -1 <<< "$LO_VERSIONS")
                  LO_SERIES=$(cut -d. -f1,2 <<< "$LO_VERSION")   # e.g. 26.8: used by RPM names and the /opt folder
                  LO_TARBALL="LibreOffice_${LO_VERSION}_Linux_x86-64_rpm.tar.gz"
                  LO_URL="${LO_BASE_URL}/${LO_VERSION}/rpm/x86_64/${LO_TARBALL}"
                  log "Available versions: $(tr '\n' ' ' <<< "$LO_VERSIONS")"
                  log "Selected (largest number): ${LO_VERSION}"
                  curl -fsSL --retry 3 -I -o /dev/null "$LO_URL" \
                    || fail "The RPM package of LibreOffice ${LO_VERSION} was not found: ${LO_URL}. Nothing has been changed."
                  step_end "Find the latest stable LibreOffice (${LO_VERSION})"
                  # ----- Install LibreOffice (skipped when the same version is installed) -----
                  step_begin "Install LibreOffice ${LO_VERSION}"
                  LO_INSTALLED=""
                  if rpm -q --quiet "libreoffice${LO_SERIES}"; then
                    LO_INSTALLED=$(rpm -q --qf '%{VERSION}' "libreoffice${LO_SERIES}")   # e.g. 26.8.1.1
                  fi
                  if [ -n "$LO_INSTALLED" ] && { [ "$LO_INSTALLED" = "$LO_VERSION" ] || [[ "$LO_INSTALLED" == "${LO_VERSION}."* ]]; }; then
                    log "LibreOffice ${LO_INSTALLED} is already installed. Skipping the installation."
                  else
                    LO_WORK=$(mktemp -d /var/tmp/ide-setup-lo.XXXXXX)
                    curl -fsSL --retry 3 -o "${LO_WORK}/${LO_TARBALL}" "$LO_URL"
                    curl -fsSL --retry 3 -o "${LO_WORK}/${LO_TARBALL}.sha256" "${LO_URL}.sha256"
                    (cd "$LO_WORK" && sha256sum -c "${LO_TARBALL}.sha256")   # fails the document on mismatch
                    tar -xzf "${LO_WORK}/${LO_TARBALL}" -C "$LO_WORK"
                    # The folder name after extraction has the full version (e.g. 26.8.1.1), so use a wildcard
                    LO_RPMS=("$LO_WORK"/LibreOffice_*_Linux_x86-64_rpm/RPMS/*.rpm)
                    [ -e "${LO_RPMS[0]}" ] || fail "No RPM files were found after extracting ${LO_TARBALL}."
                    dnf install -y "${LO_RPMS[@]}"
                    log "Installed LibreOffice ${LO_VERSION} (previous: ${LO_INSTALLED:-none})"
                  fi
                  # Libraries needed to start in headless mode (without them libXinerama.so.1 etc. are not found)
                  dnf install -y libXinerama libXrender libXext libX11 libSM libICE cups-libs dbus-libs libxslt
                  step_end "Install LibreOffice ${LO_VERSION}"
                  # ----- soffice link (the RPM command name has the version, e.g. libreoffice26.8) -----
                  step_begin "Link /usr/local/bin/soffice"
                  LO_SOFFICE="/opt/libreoffice${LO_SERIES}/program/soffice"
                  [ -x "$LO_SOFFICE" ] || fail "${LO_SOFFICE} was not found after the installation."
                  ln -sfn "$LO_SOFFICE" /usr/local/bin/soffice
                  sudo -u ec2-user -H /usr/local/bin/soffice --version
                  step_end "Link /usr/local/bin/soffice -> ${LO_SOFFICE}"
                  # ----- Record the installed version -----
                  mkdir -p "$CONFIG_DIR"
                  rpm -q --qf '%{VERSION}\n' "libreoffice${LO_SERIES}" > "${CONFIG_DIR}/libreoffice.version"
                  log "[COMPLETE] ${DOC_NAME} finished successfully (LibreOffice $(cat "${CONFIG_DIR}/libreoffice.version"))."
      Tags:
        - Key: Cost
          Value: !Sub "${SystemName}-${SubName}"

デプロイ手順

  1. このテンプレートでスタックを作成します。パラメータは、SystemName と SubName を Cognito 編、EC2 インスタンス編と同じ値にします。ソフトウェアのバージョンは、必要に応じて変更します。
  2. スタックが完了したら、Systems Manager のコンソールの「ドキュメント」で、6つのドキュメントができていることを確認します。

ドキュメントの実行手順

AWS マネジメントコンソールから、Run Command で実行します。

インストールするソフトウェアのバージョンを指定できるドキュメントでは、既定値が入っています。変えたいときだけ書き換えます。

複数のドキュメントを同じ EC2 インスタンスに同時に実行しないでください。

ステータスが「成功」になれば完了です。出力の最後に、次の行が表示されます。

[COMPLETE] Setup-CodeServer finished successfully.

失敗したときは、ステータスが「失敗」になります。出力の [ERROR] の行にある原因を取り除いてから、同じドキュメントをもう一度実行してください。再実行しても安全なように作ってあります。出力の全体は、CloudWatch Logs の ide-setup ロググループで確認できます。

運用について

証明書の更新

Let’s Encrypt の証明書の有効期間は、現在は 90日ですが、将来は短くなっていく予定です。

このテンプレートは、証明書の自動更新を設定していません。代わりに、手動で更新するスクリプトを、SSM ドキュメントが ec2-user のホームフォルダに作成します。

スクリプト 作るドキュメント 対象の証明書
/home/ec2-user/renew-cert-codeserver.sh Setup-CodeServer code-server の FQDN
/home/ec2-user/renew-cert-kirocrew.sh Setup-KiroCrew Kiro Crew の FQDN

code-server のターミナルから、sudo で実行します。証明書エラーで code-server にアクセスできなくなった場合は、Session Manager で OS にログインします。

sudo /home/ec2-user/renew-cert-codeserver.sh
sudo /home/ec2-user/renew-cert-kirocrew.sh

ソフトウェアのバージョンアップ

ドキュメントを再実行すると、バージョンアップできるものと、できないものがあります。ドキュメントでバージョンアップできないソフトウェアのバージョンアップ方法は以下の通りです。

ソフトウェア バージョンアップの方法
Kiro CLI ターミナルで kiro-cli update を実行します。
Kiro Crew ターミナルで kirocrew update を実行します。
uv ターミナルで uv self update を実行します。
pyenv ターミナルで pyenv update を実行します。
nginx、certbot ターミナルで sudo dnf upgrade --releasever=latest nginx certbot python3-certbot-dns-route53 を実行します。
VS Code 拡張機能 自動更新を無効にしているため、code-server の拡張機能の画面から更新します。

まとめ

いかがでしたでしょうか。

EC2 インスタンスの中のソフトウェア導入を、SSM ドキュメントにまとめました。ここまで実行できれば、自分用の code-server / Kiro CLI / Kiro Crew 環境が EC2 環境で立ち上げられます。

SSM ドキュメントのコマンドは私が決めただけなので、適宜必要なものに変更すれば他の用途にも応用可能です。

本記事が皆様のお役に立てれば幸いです。

著者について
広野 祐司

AWS サーバーレスアーキテクチャと React を使用して社内向け e-Learning アプリ開発とコンテンツ作成に勤しんでいます。React でアプリを書き始めたら、快適すぎて他の言語には戻れなくなりました。近年は社内外への AWS 技術支援にも従事しています。AWS サービスには AWS が考える IT 設計思想が詰め込まれているので、いつも AWS を通して勉強させて頂いてまます。
取得資格:AWS 認定は13資格、Claude Certified Architect - Foundations、IT サービスマネージャ、ITIL v3 Expert 等
2020 - 2026 Japan AWS Top Engineer 受賞
2022 - 2026 AWS Ambassador 受賞
2023 当社初代フルスタックエンジニア認定
好きなAWSサービス:AWS AppSync Events / AWS Step Functions / AWS CloudFormation

広野 祐司をフォローする

クラウドに強いによるエンジニアブログです。

SCSKクラウドサービス(AWS)は、企業価値の向上につながるAWS 導入を全面支援するオールインワンサービスです。AWS最上位パートナーとして、多種多様な業界のシステム構築実績を持つSCSKが、お客様のDX推進を強力にサポートします。

AWSAI・MLアプリケーション開発クラウドソリューション
シェアする
×
タイトルとURLをコピーしました