---
# ai_support_agent_k8s : エージェントを Kubernetes クラスタへ StatefulSet として配置する。
#
# 実行場所: kubectl と kubeconfig を持つノード（k3s サーバーノード等）。ホスト常駐の
# `ai_support_agent` ロールと異なり、対象ホストに Node.js を導入しないため nvm ロールに
# 依存しない。エージェント本体はクラスタ内のコンテナとして動く。
#
# なぜ Deployment ではなく StatefulSet か:
#   1. `volumeClaimTemplates` によってレプリカごとに専用 PVC が発行される。単一 PVC を
#      共有する構成は、RWO では Multi-Attach エラーで2つ目以降が起動せず、RWX にしても
#      全レプリカが同じ git ワークツリーを踏み合って壊れる。
#   2. Pod 名が序数で固定されるため、downward API で注入する
#      AI_SUPPORT_AGENT_INSTANCE_ID が再スケジュールをまたいで安定する（Deployment の
#      ランダムサフィックスでは、Pod が入れ替わるたびに管理画面上は別レプリカに見える）。
#
# 秘匿値の扱い:
#   トークンは (a) Ansible の `environment:` キーワード、(b) shell/command の本文への
#   Jinja 展開、(c) 生成マニフェスト、のいずれにも載せない。(a) は `-vvv` の EXEC
#   トレースへ平文で出力され `no_log` でも抑止できないことが既存ロールで実測されている。
#   代わりに 0600 の一時ファイルへ `copy` の `content` で書き（モジュール自身が値を
#   秘匿する）、`kubectl create secret --from-file=` で読ませてから必ず削除する。
#   非ループの `copy` にタスクレベルの `no_log` は付けない（秘匿に寄与せず、パス誤りや
#   権限不足といった失敗理由だけを潰すため。ansible-roles-no-log-diagnostics 参照）。
#
# 既知の制約:
#   1. `volumeClaimTemplates` は StatefulSet の **immutable** フィールドである。作成後に
#      persistence を false→true（またはその逆）へ切り替える、StorageClass や容量を変える、
#      といった再適用は kubectl が `Forbidden: updates to statefulset spec ...` で拒否する。
#      変更するには StatefulSet を削除して作り直す必要がある（PVC は既定で残るため、
#      同名で作り直せばデータは再アタッチされる）。
#   2. 永続化されるのは `ai_support_agent_k8s_data_dir`（= AI_SUPPORT_AGENT_CONFIG_DIR）
#      配下のみ。`~/.claude` など、そこから外れる状態は Pod 再作成で失われる。

- name: "ai_support_agent_k8s : Validate the single and multi project forms are mutually exclusive"
  # 単数指定（_project / _token）と複数リスト（_projects）の両方が与えられたとき、
  # 一方を優先して他方を黙って捨てると、実行ログを見ても「どちらの設定でデプロイ
  # されたのか」が分からなくなる。優先順位を作らず、明示的に失敗させて利用者に
  # どちらか一方を選ばせる（CLAUDE.md のフォールバック禁止ルール）。
  ansible.builtin.assert:
    that:
      - not (
          ai_support_agent_k8s_projects | length > 0
          and (
            ai_support_agent_k8s_project | default('') | trim | length > 0
            or ai_support_agent_k8s_token | default('') | trim | length > 0
          )
        )
    fail_msg: >-
      ai_support_agent_k8s_project / ai_support_agent_k8s_token (single project)
      and ai_support_agent_k8s_projects (multiple projects) are mutually
      exclusive. Specify exactly one form. Got
      project={{ ai_support_agent_k8s_project | default('(unset)') }},
      projects={{ ai_support_agent_k8s_projects | length }} entries.
    success_msg: "Exactly one project form is in use."

- name: "ai_support_agent_k8s : Validate at least one project is configured"
  ansible.builtin.assert:
    that:
      - >-
        ai_support_agent_k8s_projects | length > 0
        or ai_support_agent_k8s_project | default('') | trim | length > 0
    fail_msg: >-
      No project configured. Set ai_support_agent_k8s_project (single) or
      ai_support_agent_k8s_projects (multiple).
    success_msg: "At least one project is configured."

- name: "ai_support_agent_k8s : Validate every project entry defines the required keys"
  # 重複検査より先に置く。`map(attribute='name')` は キーが無いエントリに対して
  # "object of type 'dict' has no attribute 'name'" という、どのエントリが原因かも
  # 分からない例外を投げる（実機の ansible-playbook で確認済み）。先に全エントリ分を
  # まとめて名指しし、後段の式が壊れないようにする。
  ansible.builtin.assert:
    that:
      - ai_support_agent_k8s_project_specs | selectattr('name', 'defined') | list | length
        == ai_support_agent_k8s_project_specs | length
      - ai_support_agent_k8s_project_specs | selectattr('project', 'defined') | list | length
        == ai_support_agent_k8s_project_specs | length
      - ai_support_agent_k8s_project_specs | selectattr('token', 'defined') | list | length
        == ai_support_agent_k8s_project_specs | length
    fail_msg: >-
      Every entry of ai_support_agent_k8s_projects must define "project",
      "name" and "token". Entries missing a key:
      {{ ai_support_agent_k8s_project_specs
         | rejectattr('name', 'defined') | map(attribute='project', default='(no project)') | list }}
      (missing name),
      {{ ai_support_agent_k8s_project_specs
         | rejectattr('token', 'defined') | map(attribute='project', default='(no project)') | list }}
      (missing token).
    success_msg: "Every project entry defines the required keys."

- name: "ai_support_agent_k8s : Validate StatefulSet names are unique across projects"
  # 2つのエントリに同じ name を与えると、後から適用した StatefulSet が先のものを
  # 黙って上書きし、片方のプロジェクトのエージェントが消える。kubectl apply は
  # 「更新」として成功するため、失敗としては現れない。
  #
  # `default=''` は上の必須キー検査があっても外さない。片方だけを直したときに、
  # ここが例外で落ちて原因が分からなくなるのを防ぐ。
  ansible.builtin.assert:
    that:
      - >-
        ai_support_agent_k8s_project_specs | map(attribute='name', default='') | list | unique | length
        == ai_support_agent_k8s_project_specs | length
    fail_msg: >-
      Each entry of ai_support_agent_k8s_projects must have a unique "name"
      (it becomes the StatefulSet and Secret name; duplicates silently
      overwrite each other). Got:
      {{ ai_support_agent_k8s_project_specs | map(attribute='name', default='') | list }}
    success_msg: "StatefulSet names are unique."

# 単数指定のときだけ、単数変数そのものを名指しして検証する。複数リストのときは
# これらの変数が空のままなので実行しない（エントリごとの検証は project.yml が行う）。
- name: "ai_support_agent_k8s : Validate the agent token is set"
  ansible.builtin.assert:
    that:
      - ai_support_agent_k8s_token | default('') | trim | length > 0
    fail_msg: >-
      ai_support_agent_k8s_token is required. Reference an ANSIBLE# secret
      variable from the recipe, e.g.
      ai_support_agent_k8s_token: "{{ '{{ MY_AGENT_TOKEN }}' }}".
    success_msg: "Agent token is set."
  when: ai_support_agent_k8s_projects | length == 0

- name: "ai_support_agent_k8s : Validate the target project is tenantCode/projectCode"
  ansible.builtin.assert:
    that:
      - ai_support_agent_k8s_project | default('') | length > 0
      - ai_support_agent_k8s_project is match('^[a-z0-9_]+/[A-Za-z0-9_]+$')
    fail_msg: >-
      ai_support_agent_k8s_project must be "<tenantCode>/<projectCode>"
      (lower_snake_case tenant, e.g. mbc/MBC_01). Got:
      {{ ai_support_agent_k8s_project | default('(unset)') }}
    success_msg: "Target project is well-formed."
  when: ai_support_agent_k8s_projects | length == 0

- name: "ai_support_agent_k8s : Validate Kubernetes object names are DNS-1123 labels"
  # 名前は metadata.name・ラベルセレクタ・Secret 参照など複数の構造的位置へ展開される
  # ため、不正値はクォートでは救えない。kubectl が apply 時に出す一般的なエラーではなく、
  # どの変数が原因かを名指しして早期に落とす。
  ansible.builtin.assert:
    that:
      - ai_support_agent_k8s_name is match('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$')
      - ai_support_agent_k8s_name | length <= 63
      - ai_support_agent_k8s_namespace is match('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$')
      - ai_support_agent_k8s_namespace | length <= 63
    fail_msg: >-
      ai_support_agent_k8s_name / _namespace must be lowercase DNS-1123 labels
      (alphanumerics and "-", starting and ending with an alphanumeric,
      max 63 chars). Got name={{ ai_support_agent_k8s_name }},
      namespace={{ ai_support_agent_k8s_namespace }}.
    success_msg: "Kubernetes object names are valid."

- name: "ai_support_agent_k8s : Validate the container image is an official agent image"
  # 許可リストはこの assert 内のインラインリテラルであり、role 変数にはしない。
  # 変数にすると recipe の task-level vars から許可リストごと上書きでき、検証が
  # 無効化される（CLAUDE.md「セキュリティ許可リストはインラインリテラル」）。
  # これが無いと、このロールは任意のコンテナをクラスタで起動する汎用手段になる。
  ansible.builtin.assert:
    that:
      - ai_support_agent_k8s_image is match('^ghcr\.io/mbc-net/ai-support-agent-cli:[A-Za-z0-9._-]+$')
    fail_msg: >-
      ai_support_agent_k8s_image must be an official agent image
      (ghcr.io/mbc-net/ai-support-agent-cli:<tag>). Got:
      {{ ai_support_agent_k8s_image }}
    success_msg: "Container image is an official agent image."

- name: "ai_support_agent_k8s : Validate the replica count"
  ansible.builtin.assert:
    that:
      - ai_support_agent_k8s_replicas | int >= 1
      # 型ではなく値で比較する。ANSIBLE# 変数は文字列で渡るため
      # `x | int == x` にすると、有効な "3" が「正の整数でない」と誤判定される。
      # 文字列同士に揃えることで "3" は通り、"1.5"・"abc" は弾ける。
      - ai_support_agent_k8s_replicas | int | string == ai_support_agent_k8s_replicas | string
    fail_msg: >-
      ai_support_agent_k8s_replicas must be a positive integer. Got:
      {{ ai_support_agent_k8s_replicas }}
    success_msg: "Replica count is valid."

- name: "ai_support_agent_k8s : Validate storage and path settings"
  # マニフェストへの埋め込みは `| to_json` でエスケープ済みだが、形式が誤っていれば
  # kubectl の一般的なエラーになるだけで原因変数が分からない。ここで名指しして落とす。
  ansible.builtin.assert:
    that:
      - ai_support_agent_k8s_data_dir is match('^/[A-Za-z0-9._/-]*$')
      - ai_support_agent_k8s_api_url is match('^https?://[A-Za-z0-9._~%-]+(\\[[0-9A-Fa-f:.]+\\])?[A-Za-z0-9._~:/?#@!$&()*+,;=%-]*$|^https?://\\[[0-9A-Fa-f:.]+\\][A-Za-z0-9._~:/?#@!$&()*+,;=%-]*$')
      - ai_support_agent_k8s_storage_class is match('^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$')
      - ai_support_agent_k8s_storage_size is match('^[0-9]+(\.[0-9]+)?(E|P|T|G|M|k|Ei|Pi|Ti|Gi|Mi|Ki)?$')
    fail_msg: >-
      Invalid storage/path settings. data_dir must be an absolute path,
      api_url must be an http(s) URL, storage_class must be a DNS-1123 name,
      storage_size must be a Kubernetes quantity (e.g. 20Gi). Got
      data_dir={{ ai_support_agent_k8s_data_dir }},
      api_url={{ ai_support_agent_k8s_api_url }},
      storage_class={{ ai_support_agent_k8s_storage_class }},
      storage_size={{ ai_support_agent_k8s_storage_size }}.
    success_msg: "Storage and path settings are valid."

- name: "ai_support_agent_k8s : Check the kubectl binary"
  ansible.builtin.stat:
    path: "{{ ai_support_agent_k8s_kubectl }}"
  register: ai_support_agent_k8s_kubectl_stat

- name: "ai_support_agent_k8s : Assert kubectl is available"
  ansible.builtin.assert:
    that:
      - ai_support_agent_k8s_kubectl_stat.stat.exists
      - ai_support_agent_k8s_kubectl_stat.stat.executable | default(false)
    fail_msg: >-
      kubectl not found or not executable at
      {{ ai_support_agent_k8s_kubectl }}. Run this role on a node that
      administers the cluster (a k3s server node), or set
      ai_support_agent_k8s_kubectl to the correct path.
    success_msg: "kubectl is available."

- name: "ai_support_agent_k8s : Check the kubeconfig"
  ansible.builtin.stat:
    path: "{{ ai_support_agent_k8s_kubeconfig }}"
  register: ai_support_agent_k8s_kubeconfig_stat

- name: "ai_support_agent_k8s : Assert the kubeconfig is readable"
  ansible.builtin.assert:
    that:
      - ai_support_agent_k8s_kubeconfig_stat.stat.exists
    fail_msg: >-
      kubeconfig not found at {{ ai_support_agent_k8s_kubeconfig }}.
      Set ai_support_agent_k8s_kubeconfig to the cluster's kubeconfig path
      (k3s default: /etc/rancher/k3s/k3s.yaml).
    success_msg: "kubeconfig is present."

- name: "ai_support_agent_k8s : Ensure the target namespace exists"
  # create --dry-run=client | apply は、存在しても失敗しない冪等な適用手順。
  ansible.builtin.shell: >-
    set -o pipefail &&
    {{ ai_support_agent_k8s_kubectl }} --kubeconfig={{ ai_support_agent_k8s_kubeconfig }} --request-timeout=30s
    create namespace {{ ai_support_agent_k8s_namespace }}
    --dry-run=client -o yaml
    | {{ ai_support_agent_k8s_kubectl }} --kubeconfig={{ ai_support_agent_k8s_kubeconfig }} --request-timeout=30s
    apply -f -
  args:
    executable: /bin/bash
  register: ai_support_agent_k8s_namespace_apply
  # kubectl apply の出力は created / configured / unchanged の3通り。'created' だけを
  # 見ると、将来 namespace にラベル等を足したとき 'configured' が changed=false と
  # 誤報告される。Secret / StatefulSet の適用タスクと同じ判定に揃える。
  changed_when: "'unchanged' not in ai_support_agent_k8s_namespace_apply.stdout"
- name: "ai_support_agent_k8s : Ensure the manifest directory exists"
  ansible.builtin.file:
    path: "{{ ai_support_agent_k8s_manifest_dir }}"
    state: directory
    owner: root
    group: root
    mode: '0700'
- name: "ai_support_agent_k8s : Deploy each project as its own StatefulSet"
  # 1プロジェクト = 1 StatefulSet + 1 Secret。プロジェクトごとにトークンが異なる
  # （agentId がトークンから導出されるため共有できない）ので、Secret を分けざるを
  # 得ず、StatefulSet も必然的に分かれる。
  #
  # label が無いと Ansible は item 全体を "item={...}" として表示し、トークンが
  # 平文で実行ログに出る。no_log ではなく label にしているのは、no_log だと失敗
  # 理由まで潰れて「どのプロジェクトで何が起きたか」が追えなくなるため。
  ansible.builtin.include_tasks: project.yml
  loop: "{{ ai_support_agent_k8s_project_specs }}"
  loop_control:
    label: "{{ item.name | default('(unnamed)') }}"

# ここから下は「このプレイを実行しているエージェント自身」の配置。
#
# レシピが自分自身を配置対象に含む構成は正当だが、ループの途中で自 Pod を作り直す
# 操作を行うと Ansible ごと死ぬ。実機（MBC 社内 k3s）では1番目のエントリが自分自身
# だったため、2番目以降のプロジェクトが配置されないままサーバー側の実行が running の
# まま滞留した。
#
# 「自 Pod を作り直す操作」は `rollout restart` だけではない。StatefulSet の既定
# updateStrategy は RollingUpdate なので、`.spec.template` を変えたマニフェストの
# `kubectl apply` も同じ結果になる。そこで自己ターゲットに対する spec 変更操作は
# apply も含めてまとめてここまで持ち越し、他のプロジェクトの配置が全て終わってから
# tasks/self.yml で実行する。
- name: "ai_support_agent_k8s : Report that deploying this agent ends the run without a result"
  # 自 Pod が置き換わると、この実行の結果はサーバーへ報告されない（プロセスが消えるため
  # 原理的に不可能）。エラーではなく意図された動作なので、fail ではなく debug で残す。
  ansible.builtin.debug:
    msg: >-
      Deploying the agent that is executing this play
      ({{ ai_support_agent_k8s_pending_self_targets | default([]) | map(attribute='name') | join(', ') }}).
      Applying its StatefulSet, or restarting it for a rotated token, makes
      Kubernetes replace this Pod, so this run terminates without reporting a
      result and the execution stays "running" until it is released with "Stop
      execution" in the admin UI (or reclaimed by the server-side watchdog).
      Every other project in this recipe has already been deployed. This is the
      intended behavior, not a failure.
  when: ai_support_agent_k8s_pending_self_targets | default([]) | length > 0

- name: "ai_support_agent_k8s : Tell the agent this run ends without a result (marker)"
  # 自己再起動へ進む前の申告のトリガー。エージェントは kubectl / ansible の**出力文言**
  # ではなく、この**ファイルの存在**だけを見て「自己再起動待ち」をサーバーへ申告する
  # （文言依存の判定は毎回誤発火した実績があるため使わない）。中身は診断用で、判定には
  # 使わない。
  #
  # 置き先はコントローラ（＝このプレイを実行しているエージェント自身）のファイル
  # システムであり、対象ホストではない。プレイ全体の become: true をそのまま使うと
  # root でローカル実行しようとするため become: false を明示する。
  ansible.builtin.copy:
    content: >-
      {{ {
        'selfInstanceId': ai_support_agent_k8s_self_instance_id,
        'targets': ai_support_agent_k8s_pending_self_targets
                   | default([]) | map(attribute='name') | list
      } | to_json }}
    dest: "{{ ai_support_agent_k8s_self_restart_marker_file }}"
    mode: '0600'
  delegate_to: localhost
  become: false
  register: ai_support_agent_k8s_self_restart_marker
  # 申告は報告経路であって配置の一部ではない。書けなくても配置は続ける（下の debug が
  # 失敗を表面化する）。`failed_when: false` は使わない: register の .failed まで False に
  # なり、診断が一度も発火しない死んだコードになる
  # （__tests__/server-setup/ansible-roles-no-log-diagnostics.spec.ts 参照）。
  ignore_errors: true
  when:
    - ai_support_agent_k8s_pending_self_targets | default([]) | length > 0
    # 予約変数が空 = 監視者がいない経路。ハンドシェイクごと行わない（誰も書かない ack を
    # 待つだけになる）。相対パスは runner のバグ以外ではあり得ないが、その場合に
    # カレントディレクトリ配下へ書き出さないよう絶対パスも要求する。
    - ai_support_agent_k8s_self_restart_marker_file | default('') | trim | length > 0
    - ai_support_agent_k8s_self_restart_ack_file | default('') | trim | length > 0
    - ai_support_agent_k8s_self_restart_marker_file is match('^/')
    - ai_support_agent_k8s_self_restart_ack_file is match('^/')

- name: "ai_support_agent_k8s : Wait for the agent to report it before restarting itself"
  # ここで待つことが、この機能の本体である。エージェントは申告の送信が**完了してから**
  # ack を書く（src/server-setup/self-restart-declaration.ts）。待たずに進むと、申告が
  # 届く前に Pod が置き換わってプロセスが消え、申告そのものが無意味になる。
  ansible.builtin.wait_for:
    path: "{{ ai_support_agent_k8s_self_restart_ack_file }}"
    timeout: "{{ ai_support_agent_k8s_self_restart_ack_timeout_seconds | int }}"
  delegate_to: localhost
  become: false
  register: ai_support_agent_k8s_self_restart_ack
  # マーカーを置けなかったときに待っても、答える者はいない。
  ignore_errors: true
  when:
    - not (ai_support_agent_k8s_self_restart_marker.skipped | default(false))
    - not (ai_support_agent_k8s_self_restart_marker.failed | default(false))

- name: "ai_support_agent_k8s : Read what the agent reported (ack content)"
  # ack は**ローカル書き込み**なので、ほぼ確実に成功する。つまり「marker/wait が
  # 失敗したか」だけを見る診断は、最も起きやすい失敗経路 — API への申告そのものが
  # 失敗した場合（認証切れは 4xx でリトライされず数百ms で確定する）— では一度も
  # 発火しない。エージェントは ack の**中身**に成否を書くので、それを読む。
  #
  # 判定材料はエージェント自身が書いた構造化された値であり、ansible / kubectl の
  # 出力文言ではない（文言依存の判定は毎回誤発火した実績があるため使わない）。
  ansible.builtin.slurp:
    src: "{{ ai_support_agent_k8s_self_restart_ack_file }}"
  delegate_to: localhost
  become: false
  register: ai_support_agent_k8s_self_restart_ack_content
  # 読めなくても配置は続ける（読み取りは診断であって配置の一部ではない）。
  # 待ちがタイムアウトして ack が無い場合もここに来るが、その場合は下の
  # wait 失敗の条件で診断が発火する。
  ignore_errors: true
  when:
    - not (ai_support_agent_k8s_self_restart_marker.skipped | default(false))
    - not (ai_support_agent_k8s_self_restart_marker.failed | default(false))

- name: "ai_support_agent_k8s : Note that the self-restart report did not get through"
  # 申告の失敗は配置の失敗ではないので fail にしない。ただし黙って進むと、画面上は
  # 「自己再起動待ち」が出ないまま実行が running で滞留し、原因が追えなくなる。
  ansible.builtin.debug:
    msg: >-
      The agent did not report this run as awaiting a self restart
      (marker failed={{ ai_support_agent_k8s_self_restart_marker.failed | default(false) }},
      ack failed={{ ai_support_agent_k8s_self_restart_ack.failed | default(false) }},
      ack content={{ ai_support_agent_k8s_self_restart_ack_content.content
                     | default('') | b64decode | trim }}).
      Deploying this agent anyway. The execution will stay "running" until it is
      released with "Stop execution" in the admin UI or reclaimed by the
      server-side watchdog.
  when: >-
    (ai_support_agent_k8s_self_restart_marker.failed | default(false))
    or (ai_support_agent_k8s_self_restart_ack.failed | default(false))
    or (ai_support_agent_k8s_self_restart_ack_content.content | default('') | b64decode
        is search('"declared"\s*:\s*false'))

- name: "ai_support_agent_k8s : Deploy the agent that is running this play (deferred)"
  # 完了待ち（rollout status）は行わない/行えない。Pod の置き換えが受理された時点で
  # このプロセスは終了に向かうため、待つ相手が自分自身になる（tasks/self.yml 参照）。
  ansible.builtin.include_tasks: self.yml
  loop: "{{ ai_support_agent_k8s_pending_self_targets | default([]) }}"
  loop_control:
    label: "{{ item.name | default('(unnamed)') }}"
