---
# ai_support_agent_k8s : プロジェクト1件分のデプロイ（Secret + StatefulSet）。
#
# main.yml から `include_tasks` + `loop` で1エントリずつ呼ばれる。`item` は
# ai_support_agent_k8s_project_specs の1要素（project / name / token と、任意の
# replicas / persistence / storage_class / storage_size）。単数指定の場合も
# vars/main.yml が1要素のリストへ正規化するため、経路はここに一本化される。
#
# 呼び出し側は loop_control.label を必ず指定すること。既定では Ansible が item
# 全体を "item={...}" として表示し、トークンが平文で実行ログへ出る。no_log では
# なく label を使うのは、no_log がタスクの失敗理由まで潰し「どのプロジェクトで
# 何が失敗したか」が分からなくなるため。label なら対象を残したまま値だけ隠せる。
#
# このファイル内で新たに `loop` を使ってはならない。`item` が上書きされ、別の
# プロジェクトのトークンやマニフェストが混線する。

- name: "ai_support_agent_k8s : Validate the project entry"
  # プロジェクトごとに独立したトークンが要る。agentId はトークンの tokenId から
  # 導出される（src/agent-runner.ts の resolveAgentId）ため、複数プロジェクトで
  # 1つのトークンを共有すると agentId が衝突し、サーバー側の TOFU バインディングが
  # "Agent ID does not match the token binding" で接続を拒否する。
  #
  # name をエントリで必須にするのは、projectCode が MBC_01 のようにアンダースコアを
  # 含み、DNS-1123 ラベルへ機械的に変換すると別コードが同じ名前へ衝突しうるため。
  ansible.builtin.assert:
    that:
      - item.project | default('') | length > 0
      - item.project is match('^[a-z0-9_]+/[A-Za-z0-9_]+$')
      - item.token | default('') | trim | length > 0
      - item.name | default('') | length > 0
      - item.name is match('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$')
      - item.name | length <= 63
    fail_msg: >-
      Invalid entry in ai_support_agent_k8s_projects. Each entry needs
      "project" as "<tenantCode>/<projectCode>" (e.g. mbc/MBC_01), a non-empty
      "token" dedicated to that project, and a "name" that is a lowercase
      DNS-1123 label (max 63 chars) used as the StatefulSet and Secret name.
      Got project={{ item.project | default('(unset)') }},
      name={{ item.name | default('(unset)') }},
      token={{ 'set' if (item.token | default('') | trim | length > 0) else '(unset)' }}.
    success_msg: "Project entry {{ item.name }} is well-formed."

- name: "ai_support_agent_k8s : Validate the per-project replica count and storage"
  # 値で比較する理由は main.yml のトップレベル検証と同じ（ANSIBLE# 変数は文字列で
  # 渡るため、型比較にすると有効な "3" を弾いてしまう）。
  ansible.builtin.assert:
    that:
      - (item.replicas | default(ai_support_agent_k8s_replicas)) | int >= 1
      - >-
        (item.replicas | default(ai_support_agent_k8s_replicas)) | int | string
        == (item.replicas | default(ai_support_agent_k8s_replicas)) | string
      - (item.storage_class | default(ai_support_agent_k8s_storage_class)) is match('^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$')
      - (item.storage_size | default(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 replicas/storage for project {{ item.name }}. replicas must be a
      positive integer, storage_class a DNS-1123 name, storage_size a
      Kubernetes quantity (e.g. 20Gi). Got
      replicas={{ item.replicas | default(ai_support_agent_k8s_replicas) }},
      storage_class={{ item.storage_class | default(ai_support_agent_k8s_storage_class) }},
      storage_size={{ item.storage_size | default(ai_support_agent_k8s_storage_size) }}.
    success_msg: "Replica count and storage for {{ item.name }} are valid."

# ローテーション判定の材料として、**適用前**の Secret の値を読み出す。
#
# なぜ kubectl apply の出力文言（created / configured / unchanged）で判定しないのか:
# 実機（MBC 社内 k3s）で、Secret が当日一度も更新されていない（managedFields の最終更新は
# 前日・resourceVersion も据え置き）実行でも changed と判定され、`rollout restart` が
# 毎回発火した（ControllerRevision の rev2/rev3 の差分が
# `kubectl.kubernetes.io/restartedAt` の1点のみであることを確認済み）。文言に依存した
# 判定は当てにならないので、値そのものを突き合わせる。
- name: "ai_support_agent_k8s : Read the current agent token Secret"
  ansible.builtin.command:
    argv:
      - "{{ ai_support_agent_k8s_kubectl }}"
      - "--kubeconfig={{ ai_support_agent_k8s_kubeconfig }}"
      - "--request-timeout=30s"
      - -n
      - "{{ ai_support_agent_k8s_namespace }}"
      - get
      - secret
      - "{{ item.name }}-token"
      - -o
      - jsonpath={.data.AI_SUPPORT_AGENT_TOKEN}
  register: ai_support_agent_k8s_secret_current
  # Secret 不在（NotFound）は失敗ではなく「新規作成」。それ以外の失敗（クラスタ応答不能・
  # RBAC 不足など）は次のタスクが名指しで落とす。
  failed_when: false
  changed_when: false
  # stdout は Secret の値（base64）そのもの。json stdout callback はタスク結果を丸ごと
  # 出力するため、no_log が無いと base64 のトークンが実行ログ（executionLogs）に残る
  # ——runner の redactSecretValues は**生の値**しか照合しないため base64 は伏字にならない。
  # no_log は出力を抑止するだけで register した値は使えるので、判定には影響しない。
  no_log: true

- name: "ai_support_agent_k8s : Fail when the current token Secret cannot be read"
  # no_log タスクが失敗すると結果全体が censored に置き換わり失敗理由が消えるため、
  # 判断材料（rc / stderr）だけを使って別タスクで表面化する（横断ルール:
  # __tests__/server-setup/ansible-roles-no-log-diagnostics.spec.ts）。stdout（＝トークン）は
  # 絶対にメッセージへ展開しない。
  ansible.builtin.fail:
    msg: >-
      Could not read the current agent token Secret {{ item.name }}-token in
      namespace {{ ai_support_agent_k8s_namespace }}
      (rc={{ ai_support_agent_k8s_secret_current.rc }}):
      {{ ai_support_agent_k8s_secret_current.stderr | default('') }}
  when:
    - ai_support_agent_k8s_secret_current.rc != 0
    - "'NotFound' not in (ai_support_agent_k8s_secret_current.stderr | default(''))"

# トークンの一時ファイルは、途中のタスクが失敗しても必ず消す。
- name: "ai_support_agent_k8s : Create the agent token Secret"
  block:
    - name: "ai_support_agent_k8s : Create a secure temporary file for the token"
      ansible.builtin.tempfile:
        state: file
        prefix: ai_support_agent_k8s_token_
      register: ai_support_agent_k8s_token_tempfile
      changed_when: false

    - name: "ai_support_agent_k8s : Write the token into the temporary file"
      # `content` は copy モジュール自身が引数仕様で no_log 指定しているため、
      # 成功・失敗・-vvv のいずれでも値は出力されない。タスクレベルの no_log は
      # 付けない（失敗理由が "task failed" に潰れるだけで秘匿に寄与しない）。
      #
      # `| trim` は必須。ホスト常駐の ai_support_agent ロールは `$(cat file)` で
      # 読むためシェルが末尾改行を落とすが、こちらは `--from-file=` でファイルの
      # 中身がそのまま Secret の値になる。ANSIBLE# 変数へ貼り付けた際の改行や
      # 前後の空白が1文字混ざるだけで、「トークンは設定されているのに 401」という
      # 切り分けの難しい失敗になる。
      ansible.builtin.copy:
        content: "{{ item.token | trim }}"
        dest: "{{ ai_support_agent_k8s_token_tempfile.path }}"
        mode: '0600'
      changed_when: true

    - name: "ai_support_agent_k8s : Apply the token Secret"
      # トークンはファイル経由でのみ kubectl へ渡す。argv には現れないため
      # `ps` やプロセスリストからも読めない。--dry-run=client | apply で冪等。
      ansible.builtin.shell: >-
        set -o pipefail &&
        {{ ai_support_agent_k8s_kubectl }} --kubeconfig={{ ai_support_agent_k8s_kubeconfig }} --request-timeout=30s
        -n {{ ai_support_agent_k8s_namespace }}
        create secret generic {{ item.name }}-token
        --from-file=AI_SUPPORT_AGENT_TOKEN={{ ai_support_agent_k8s_token_tempfile.path }}
        --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_secret_apply
      # 適用前に読み出した値（上の Read タスク）と、いま投入したトークンを実比較して
      # changed を決める。kubectl の出力文言に依存しないのはヘッダのコメントのとおり
      # （実機で毎回 changed になり、再起動が毎回発火した）。
      #
      # 比較は base64 のまま行う。デコードを挟むと、不正データでのテンプレートエラー
      # 経由で値がログへ出る余地を作るだけで、判定の精度は変わらない。
      # rc != 0 は「Secret が存在しなかった」＝新規作成なので changed。
      changed_when: >-
        ai_support_agent_k8s_secret_current.rc != 0
        or (ai_support_agent_k8s_secret_current.stdout | default(''))
        != (item.token | trim | b64encode)
  always:
    - name: "ai_support_agent_k8s : Remove the token temporary file"
      ansible.builtin.file:
        path: "{{ ai_support_agent_k8s_token_tempfile.path }}"
        state: absent
      when: ai_support_agent_k8s_token_tempfile.path is defined
      changed_when: false

- name: "ai_support_agent_k8s : Write the StatefulSet manifest"
  # 秘匿値は含まない（トークンは secretKeyRef 参照のみ）。監査と再適用のために
  # ノード上へ残す。
  ansible.builtin.copy:
    dest: "{{ ai_support_agent_k8s_manifest_dir }}/{{ item.name }}.yaml"
    owner: root
    group: root
    mode: '0600'
    content: |
      apiVersion: apps/v1
      kind: StatefulSet
      metadata:
        name: {{ item.name | to_json }}
        namespace: {{ ai_support_agent_k8s_namespace | to_json }}
        labels:
          app: {{ item.name | to_json }}
      spec:
        replicas: {{ (item.replicas | default(ai_support_agent_k8s_replicas)) | int }}
        serviceName: {{ item.name | to_json }}
        selector:
          matchLabels:
            app: {{ item.name | to_json }}
        template:
          metadata:
            labels:
              app: {{ item.name | to_json }}
          spec:
            # SIGTERM 後、エージェントは実行中コマンドの drain を待ってからレプリカ枠を
            # release する。明示しないと Kubernetes の既定 30 秒で SIGKILL され、drain の
            # 途中で殺されて実行中コマンドを見捨てたまま枠が残り、サーバーが別レプリカへ
            # 再割当して二重実行する。値は同じエージェントを配置する別経路
            # （src/manifest/manifest-generator.ts の TERMINATION_GRACE_PERIOD_SECONDS）と
            # 同一に揃える（defaults/main.yml のコメント参照）。
            terminationGracePeriodSeconds: {{ ai_support_agent_k8s_termination_grace_period_seconds | int }}
            # このエージェントは Kubernetes API を使わない。default ServiceAccount に
            # RoleBinding が付いているクラスタでは、エージェントが実行する任意コマンドや
            # 侵害された依存パッケージからクラスタ権限を悪用できてしまうため、
            # トークンを自動マウントさせない。
            automountServiceAccountToken: false
            containers:
              - name: agent
                image: {{ ai_support_agent_k8s_image | to_json }}
                # 明示しないと Kubernetes の既定が効き、タグが latest 以外のときは
                # IfNotPresent になる。:beta のような移動タグでは、タグを動かして
                # `rollout restart` してもノード上のキャッシュを使い回すため、
                # 更新したつもりで中身が変わらない（成功したように見える無効化）。
                # 版固定タグでは digest が変わらないのでレイヤの再取得は起きず、
                # マニフェスト確認ぶんのコストしか増えない。
                imagePullPolicy: Always
                # 公式イメージは ENTRYPOINT ["/entrypoint.sh"] のみ（CMD なし）で、
                # entrypoint.sh は末尾で `exec "$@"` する。したがって args の先頭は
                # **実行ファイル名**でなければならない。サブコマンド名から始めると
                # `exec start --project ...` となり、`start` という実行ファイルは
                # 存在しないため exit 127 で CrashLoopBackOff になる。
                args:
                  - ai-support-agent
                  - start
                  # Commander の negated option は未指定時に opts.docker=true になるため、
                  # これが無いとコンテナの中でさらに runInDocker() へ入り、Docker ソケット
                  # 不在で起動に失敗する。agent CLI の CONTAINER_START_ARGV と同じ契約。
                  - --no-docker
                  - --project
                  - {{ item.project | to_json }}
                env:
                  - name: AI_SUPPORT_AGENT_TOKEN
                    valueFrom:
                      secretKeyRef:
                        name: {{ (item.name ~ '-token') | to_json }}
                        key: AI_SUPPORT_AGENT_TOKEN
                  - name: AI_SUPPORT_AGENT_API_URL
                    value: {{ ai_support_agent_k8s_api_url | to_json }}
                  - name: AI_SUPPORT_AGENT_INSTANCE_ID
                    valueFrom:
                      fieldRef:
                        fieldPath: metadata.name
                  - name: AI_SUPPORT_AGENT_CONFIG_DIR
                    value: {{ ai_support_agent_k8s_data_dir | to_json }}
      {% if (item.persistence | default(ai_support_agent_k8s_persistence)) | bool %}
                volumeMounts:
                  - name: data
                    mountPath: {{ ai_support_agent_k8s_data_dir | to_json }}
      {% endif %}
      {% if (item.persistence | default(ai_support_agent_k8s_persistence)) | bool %}
        volumeClaimTemplates:
          - metadata:
              name: data
            spec:
              accessModes: ["ReadWriteOnce"]
              storageClassName: {{ (item.storage_class | default(ai_support_agent_k8s_storage_class)) | to_json }}
              resources:
                requests:
                  storage: {{ (item.storage_size | default(ai_support_agent_k8s_storage_size)) | to_json }}
      {% endif %}
  register: ai_support_agent_k8s_manifest

- name: "ai_support_agent_k8s : Record this project for deferred self-target handling"
  # 自己ターゲット（このエントリがこのプレイを実行しているエージェント自身）では、
  # ここでは名前と「Secret が変わったか」だけを記録し、クラスタへの適用は一切行わない。
  # 実際の適用は tasks/main.yml が全プロジェクトのループを抜けたあと tasks/self.yml で
  # 行う（下の Deploy ブロックのコメント参照）。
  #
  # 条件は「自己である」ことだけで、Secret の変更有無は問わない。後回しの apply は
  # トークンが変わっていなくても必要である（マニフェスト側だけが変わる更新——例えば
  # terminationGracePeriodSeconds の追加——があるため）。restart が要るかどうかは
  # secret_changed を持ち回って tasks/self.yml 側で判断する。
  ansible.builtin.set_fact:
    ai_support_agent_k8s_pending_self_targets: >-
      {{
        (ai_support_agent_k8s_pending_self_targets | default([]))
        + [{
          'name': item.name,
          'secret_changed': ai_support_agent_k8s_secret_apply.changed,
        }]
      }}
  when: ai_support_agent_k8s_item_is_self | bool

- name: "ai_support_agent_k8s : Deploy this project (deferred entirely for the self target)"
  # このブロックには「実行すると自 Pod が再作成されうる操作」だけを入れる。
  #
  # なぜ `rollout restart` だけをガードしても不十分か（レビュー指摘・CRITICAL）:
  # StatefulSet の既定 updateStrategy は RollingUpdate であり、`.spec.template` を
  # 変更したマニフェストを `kubectl apply` した**その瞬間**に Pod のローリング更新
  # （＝自 Pod の再作成）が始まる。terminationGracePeriodSeconds の追加はまさに
  # `.spec.template` の変更なので、restart に到達する前に Ansible ごとプロセスが死に、
  # restart 側のガードは完全にすり抜ける。しかも apply の出力は "configured" としか
  # 出ないため、実行ログからは何が起きたのか分からない。
  #
  # したがってガードは個々のタスクではなくこのブロックに掛ける。将来 image tag の
  # 差し替えや resources の追加など、Pod spec を変える操作を足すときも必ずこの
  # ブロックの中へ置くこと。__tests__/server-setup/ai-support-agent-k8s-role.spec.ts の
  # 「自己ターゲット時、ループ内で変更系 kubectl 操作が1つも実行されない」が、
  # ガードの外に変更系 kubectl タスクが増えたことを検出して失敗する。
  when: not (ai_support_agent_k8s_item_is_self | bool)
  block:
    - name: "ai_support_agent_k8s : Apply the StatefulSet"
      ansible.builtin.command:
        argv:
          - "{{ ai_support_agent_k8s_kubectl }}"
          - "--kubeconfig={{ ai_support_agent_k8s_kubeconfig }}"
          - "--request-timeout=30s"
          - apply
          - -f
          - "{{ ai_support_agent_k8s_manifest_dir }}/{{ item.name }}.yaml"
      register: ai_support_agent_k8s_apply
      changed_when: "'unchanged' not in ai_support_agent_k8s_apply.stdout"

    - name: "ai_support_agent_k8s : Restart the agent when the token Secret changed"
      # Secret を更新しても、起動済みコンテナの環境変数（secretKeyRef 由来）は差し替わらず、
      # StatefulSet の Pod template も変化しないためローリング更新は起きない。つまり失効した
      # トークンを差し替えて再実行しても、Pod は旧トークンで接続し続け、rollout status は
      # それを正常と判定してしまう。Secret が実際に変わったときだけ明示的に再起動する。
      #
      # 新規作成時（StatefulSet を今 apply したばかり）は Pod が新しい Secret で起動するため
      # 再起動は不要。無条件に restart すると、初回実行のたびに余計な再作成が走る。
      ansible.builtin.command:
        argv:
          - "{{ ai_support_agent_k8s_kubectl }}"
          - "--kubeconfig={{ ai_support_agent_k8s_kubeconfig }}"
          - "--request-timeout=30s"
          - -n
          - "{{ ai_support_agent_k8s_namespace }}"
          - rollout
          - restart
          - "statefulset/{{ item.name }}"
      when:
        - ai_support_agent_k8s_secret_apply.changed
        - "'created' not in ai_support_agent_k8s_apply.stdout"
      changed_when: true

    - name: "ai_support_agent_k8s : Wait for the agent to become ready"
      # 上限を超えたレプリカは standby で待機する（プロセスは終了しない）ため Ready に
      # なる。ここで検出したいのは「イメージが引けない」「Secret が無い」等の起動失敗。
      #
      # 自己ターゲットではこの待機を行わない（ブロックのガードで除外される）。自分の
      # 完了を自分で待つのは自己矛盾であり、必ずタイムアウトする。
      ansible.builtin.command:
        argv:
          - "{{ ai_support_agent_k8s_kubectl }}"
          # rollout status は --timeout=60s まで watch するため、request-timeout は
          # それより長くする（短いと watch 自体が途中で切れて誤検知になる）。
          - "--request-timeout=90s"
          - "--kubeconfig={{ ai_support_agent_k8s_kubeconfig }}"
          - -n
          - "{{ ai_support_agent_k8s_namespace }}"
          - rollout
          - status
          - "statefulset/{{ item.name }}"
          - --timeout=60s
      register: ai_support_agent_k8s_rollout
      until: ai_support_agent_k8s_rollout.rc == 0
      retries: 10
      delay: 15
      changed_when: false
