From 7af9aeca35d8497a634abe2ccad4d1fb2e2ddf0a Mon Sep 17 00:00:00 2001 From: panicboat Date: Fri, 9 Oct 2026 02:47:21 +0900 Subject: [PATCH 01/13] docs: define stack configuration model Signed-off-by: panicboat --- .../specs/stack-configuration-design.md | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 docs/superpowers/specs/stack-configuration-design.md diff --git a/docs/superpowers/specs/stack-configuration-design.md b/docs/superpowers/specs/stack-configuration-design.md new file mode 100644 index 0000000..77eb3ac --- /dev/null +++ b/docs/superpowers/specs/stack-configuration-design.md @@ -0,0 +1,202 @@ +# Stack Configuration Design + +## Intent + +同じ stack のパスと属性を一つの定義で確認できる設定モデルを提供する。プロダクト間で属性を共有する場合と、独立した属性を持つ場合を同じ形式で表現する。 + +設定の破壊的変更を許容する。新しいモデルを読み込み、変更検出・環境選択・matrix 生成・設定表示が直接利用する。旧形式への変換や互換用のアクセス方法は提供しない。 + +## Configuration + +トップレベルには必須の `stacks` と任意の `services` を置く。`stacks` は空でない配列とする。stack 定義のフィールドは `name`、`id`、`paths`、`environments`、`attributes` に限る。トップレベルと stack 定義の未知のフィールドは設定エラーとする。 + +```yaml +stacks: + - name: terragrunt + id: aws + paths: + - "dystopia/{service}/aws/{environment}" + - "system-components/{service}/infrastructure/aws/{environment}" + environments: + develop: + aws_region: ap-northeast-1 + iam_role_plan: arn:aws:iam::111111111111:role/plan-develop + iam_role_apply: arn:aws:iam::111111111111:role/apply-develop + production: + aws_region: ap-northeast-1 + iam_role_plan: arn:aws:iam::111111111111:role/plan-production + iam_role_apply: arn:aws:iam::111111111111:role/apply-production + + - name: container + paths: + - "dystopia/{service}" + - "system-components/{service}" + attributes: + repository: registry.example.com/app + +services: + - name: demo + exclude_from_automation: true + exclusion_config: + reason: "Manual deployment is required." + type: permanent +``` + +### Stack Identity + +`name` は処理の種類を表す空でない文字列である。`id` は設定単位を識別する任意の空でない文字列である。`id` を省略した定義は、読み込み時に `name` を識別子として確定する。 + +識別子は設定全体で一意にする。同じ種類の stack を複数定義する場合は、一意な識別子を持たせる。属性はその定義から直接取得し、別の識別子や種類名を探索しない。 + +### Paths + +`paths` は完全なディレクトリパターンの空でない配列である。各パターンはリポジトリルートからの相対パスとし、`{service}` を含める。`root` と `directory` の分割は持たない。 + +各パターンの実在するディレクトリをすべて対象にする。同じサービス名のディレクトリが複数プロダクトに存在する場合も、それぞれ別の matrix 行になる。パターンの記載順による優先順位は設けない。 + +`{service}` と `{environment}` に加え、`{team}` などの任意の placeholder を利用できる。placeholder 名は `[a-z_][a-z0-9_]*` とし、抽出値は一つのパス要素内に収まる。同じ名前が繰り返される場合は同じ値に一致する。 + +### Environment Attributes + +`environments` のキーが環境名であり、その値が当該環境の属性である。キーは空でない文字列、値は属性のマップとする。環境名は一つのパス要素として利用できる値とし、`/`、`.`、`..` は許可しない。環境に属性が不要なら空のマップを指定できる。 + +`environments` を持つ stack は、定義した環境だけで実行する。全体の環境一覧は各 stack の環境名の和集合から導出する。独立した環境定義や `environment` フィールドは置かない。 + +実行単位は環境の定義で決まる。環境を持つ stack のパスに `{environment}` がなくても、そのディレクトリを環境ごとに異なる属性で実行できる。 + +### Shared Attributes + +`environments` を持たない stack は環境共通である。属性は任意の `attributes` マップに置き、省略時は空のマップとして扱う。環境選択数にかかわらず、実在するディレクトリごとに一度実行する。 + +`attributes` と `environments` は併用しない。環境共通の属性と環境属性を暗黙にマージする規則は設けない。環境共通の stack のパスに `{environment}` がある場合は設定エラーとする。 + +### Product Boundaries + +属性セットを共有するプロダクトのパスは一つの stack 定義に集める。属性が独立する場合は、同じ `name` を持つ別の定義にする。 + +```yaml +stacks: + - name: terragrunt + id: dystopia-aws + paths: + - "dystopia/{service}/aws/{environment}" + environments: + develop: + aws_region: ap-northeast-1 + iam_role_plan: arn:aws:iam::111111111111:role/plan + + - name: terragrunt + id: system-components-aws + paths: + - "system-components/{service}/infrastructure/aws/{environment}" + environments: + develop: + aws_region: us-west-2 + iam_role_plan: arn:aws:iam::222222222222:role/plan +``` + +一部のプロダクトだけで共有する場合は、その範囲のパスを同じ定義にまとめる。属性の一部だけが共通の場合は、独立した各定義に値を明記する。属性の継承・参照・共通定義の解決機構は設けない。 + +### Services + +`services` はサービス設定の配列であり、省略時は空の配列として扱う。各定義には必須の `name`、任意の `exclude_from_automation` と `exclusion_config` を置く。これら以外のフィールドは設定エラーとする。 + +サービス名は空でない文字列で、`/` を含まず、`.` で始まらないものとする。`exclude_from_automation` は真偽値であり、省略時は `false` として扱う。`true` の場合は `exclusion_config` を要求する。 + +`exclusion_config` は必須の空でない文字列 `reason` と、任意の `type` を持つマップである。`type` は `permanent`、`temporary`、`conditional` のいずれかを指定でき、除外の説明に使う。実行対象から外す判断は除外フラグだけで行う。未知のフィールドは拒否する。 + +## Responsibilities + +| Component | Responsibility | +|---|---| +| `ConfigClient` | 設定ファイルを読み、YAML を解析して設定モデルへ渡す。ファイル読み込みと YAML 構文のエラーを伝える。 | +| `WorkflowConfig` | スキーマと設定内の整合性を一か所で検証し、stack 定義・環境名・サービスの設定を提供する。 | +| `PatternMatcher` | placeholder の文法、照合、展開、抽出を扱う。プロダクトや stack の属性を判断しない。 | +| `DetectChangedServices` | 各 stack のパスに変更ファイルを照合し、抽出したサービス名と除外設定からラベル対象を決める。 | +| `DetermineTargetEnvironment` | 環境選択を導出された環境一覧と照合する。 | +| `GenerateMatrix` | stack の環境と各パスから実在する実行対象を列挙し、その定義の属性を渡す。 | +| Config manager | 同じ設定モデルを使って検証結果・stack・環境・属性を表示する。 | + +各利用側は `stacks` を直接扱う。root ごとの convention 配列を中間表現として組み立てない。属性は読み込んだ定義内で完結し、プロバイダー固有の知識を設定モデルに持ち込まない。 + +## Runtime Flow + +1. 設定ファイルを読み、スキーマと設定内の整合性を検証する。成功時だけ検証済みモデルを利用側へ渡す。 +2. 環境指定を検証する。指定省略時は定義された全環境を選択し、重複した指定は一つにまとめる。未知の環境名があれば処理を失敗させる。 +3. ラベルで指定されたサービスを対象にする。`deploy:all` は `services` に明示されたサービスを対象にし、通常のサービスラベルは明示登録がなくても処理できる。除外されたサービスは対象から外す。 +4. stack ごとに対象環境を決める。環境を持つ stack は定義された環境と選択された環境の共通部分を使い、環境共通の stack は環境値を `null` として一度処理する。 +5. 各パスの実在するディレクトリを列挙し、サービス名と環境名を確認する。任意の placeholder に複数の値が一致する場合も各ディレクトリを対象にする。存在しないパスは対象を生成しない。 +6. 各対象へ、その stack 定義の環境属性または環境共通の属性と、パスから抽出した値を渡す。対象の一意性を確認した配列が matrix 生成の完了条件となる。 + +環境共通の stack だけを持つ設定も有効である。環境指定を省略した場合は空の環境一覧から環境共通の対象を生成できる。明示的な環境指定は、定義された環境名であることを要求する。 + +変更検出は各 `paths` が指すディレクトリとその配下を対象にする。サービス全体の変更を検出するには、`dystopia/{service}` のようにサービスディレクトリを指すパスを stack に定義する。`{environment}` を含むパスで定義外の環境を抽出した場合は、その照合をラベル対象にしない。 + +## Matrix + +matrix の固定キーは `service`、`environment`、`stack`、`stack_id`、`working_directory` とする。`stack` は `name`、`stack_id` は確定した識別子、`working_directory` は解決した相対パスである。環境共通の対象では `environment` が `null` になる。 + +属性と任意の placeholder の抽出値はトップレベルへ展開する。`service` と `environment` の抽出値は固定フィールドに反映し、追加のキーとして扱わない。属性値の型を変換しない。 + +`stack_convention_root` は出力しない。完全なパスから別のルート値を推測する規則は設けない。 + +実行対象の識別には `service`、`stack_id`、`environment`、`working_directory` を使う。これらが同じ対象は一行にまとめる。同じ対象で異なる placeholder の抽出結果が得られた場合は、曖昧な設定として失敗させる。異なるパスや独立した識別子の対象は別行にする。 + +## Validation and Errors + +設定モデルで以下を検証する。 + +- トップレベル、stack、サービス設定の構造と許可されたフィールド。 +- 空でない `name`、指定された `id` の型、設定全体での識別子の一意性。 +- 空でない `paths` 配列と文字列のパターン。絶対パスと親ディレクトリへ移動する要素は拒否する。`.` 要素と末尾の `/` は相対パスとして正規化する。 +- 各パターンの `{service}`、placeholder の文法、同名 placeholder の一致規則。 +- `environments` が指定された場合の空でないマップ、環境名と属性マップの型。`environments: {}` は設定エラーとする。 +- `attributes` の型、`environments` との併用、環境共通のパスでの `{environment}` 使用。 +- 属性キーは空でない文字列であり、matrix の固定キーと衝突しないこと。 +- 任意の placeholder 名が matrix の固定キーや当該 stack の属性キーと衝突しないこと。環境属性のキーはその stack の全環境から収集する。 +- サービス名の一意性、除外フラグの型、指定された除外情報の構造。サービス別のパス override は受け付けない。 + +属性の必須チェックと、環境間で属性キーを揃える検証は行わない。必要な属性の判断は利用側の workflow または action が担う。 + +設定エラーは設定内の位置と理由を含めて利用側へ伝える。ファイル・YAML・ディレクトリ列挙のエラーや矛盾した抽出結果は失敗として返し、途中の matrix は成功結果として返さない。該当ディレクトリが存在しない場合は正常な空結果として扱う。 + +## Removal Scope + +新しい形式の利用側を揃え、以下の設定モデルに関係する経路を削除する。 + +- トップレベルの `environments` と `stack_conventions`、サービス配下の `stack_conventions` の読み込み。 +- `required_attributes` とその取得・型検証・必須キー検証。 +- `stack_conventions*`、`directory_stacks`、root の取得と推測など、旧構造を公開するメソッド。 +- 属性を識別子から種類名へ探し直す処理と、最初の convention や最初のパスだけを採用する処理。 +- 環境共通の対象に空の属性を固定して渡す処理。 +- 設定の読み込み失敗時に環境一覧を既定値で補う処理。 +- 設定の各利用側に分散したスキーマ検証と、特定のプロバイダーの属性だけを表示する設定 CLI の処理。 +- 新しいモデルを利用する箇所から呼ばれなくなる補助処理、旧形式を前提とする fixture と期待値。 + +許可するフィールドを検証することで旧形式を拒否し、旧形式専用の変換器・アダプター・二重の読み込み経路は作らない。設定例、テンプレート、README、CLI 表示、テストの fixture は同じスキーマに揃える。変更履歴を扱う文書は現在の設定例として参照しない。 + +## Alternatives + +root ごとのグループ内へ属性を移す形式は、同じ stack が複数プロダクトに存在すると定義と属性の共有範囲が分散する。stack を設定単位とし、パスを列挙する形式なら、共有する範囲を一つの定義で確認できる。 + +`root` と `directory` を別フィールドとして持つ形式は、実行パスを確認する際に二つの値を結合する必要がある。完全なパターンを使うことで、実行パスを直接確認できる。 + +属性の共通定義や継承を導入すると、属性値を確認するために別の定義と上書き順序を追う必要がある。複数パスで共有する定義と、独立した識別子の定義を使い分けることで共有範囲を明示する。 + +## Verification Criteria + +実装の完了条件は、新しいモデルに対して以下の振る舞いをテストで確認することである。 + +1. プロダクトごとに異なるパスが同じ属性を共有し、実在する各パスが対象になる。 +2. 同じ `name` の独立した定義が、各識別子の属性を持つ対象になる。 +3. 定義した環境だけを対象にし、未定義の環境でディレクトリが存在しても対象にしない。 +4. 環境共通の対象が、複数環境選択時と環境共通だけの設定の双方で、各ディレクトリにつき一度生成される。その属性も matrix に渡る。 +5. 環境を持つ stack のパスに `{environment}` がなくても、環境ごとの対象と属性が得られる。 +6. 同名サービスの複数プロダクト、任意の placeholder の複数一致、重複したパスが、対象の識別規則どおりに処理される。 +7. 変更検出が新しいパス、定義された環境、サービス除外を使ってラベル対象を決める。 +8. 新しいスキーマの型、識別子重複、フィールド混在、placeholder 衝突、矛盾した抽出結果を検出する。 +9. 旧形式と廃止したフィールドを拒否する。属性キーが環境ごとに異なる設定と、必須キーの宣言がない設定を受け付ける。 +10. matrix に固定キー・属性・抽出値が正しく入り、ルート由来の出力を持たない。 +11. Config manager の検証・表示・環境一覧・サービス診断・テンプレートが同じ設定モデルを利用する。 + +実装時には対象のテストと全体の RSpec を実行し、サンプル設定を Config manager で検証する。旧モデルの経路と互換コードが利用側に残っていないことを検索と差分レビューで確認する。 From aac42e197a95ad6888afeca97518d8302e514a28 Mon Sep 17 00:00:00 2001 From: panicboat Date: Fri, 9 Oct 2026 08:35:21 +0900 Subject: [PATCH 02/13] docs: define exclusions for service and environment targets Service registration would preserve a second source for target membership after stack configuration is consolidated. Signed-off-by: panicboat --- .../specs/stack-configuration-design.md | 72 +++++++++++-------- 1 file changed, 43 insertions(+), 29 deletions(-) diff --git a/docs/superpowers/specs/stack-configuration-design.md b/docs/superpowers/specs/stack-configuration-design.md index 77eb3ac..e195111 100644 --- a/docs/superpowers/specs/stack-configuration-design.md +++ b/docs/superpowers/specs/stack-configuration-design.md @@ -8,7 +8,7 @@ ## Configuration -トップレベルには必須の `stacks` と任意の `services` を置く。`stacks` は空でない配列とする。stack 定義のフィールドは `name`、`id`、`paths`、`environments`、`attributes` に限る。トップレベルと stack 定義の未知のフィールドは設定エラーとする。 +トップレベルには必須の `stacks` だけを置く。`stacks` は空でない配列とする。stack 定義のフィールドは `name`、`id`、`paths`、`environments`、`attributes`、`exclude` に限る。トップレベルと stack 定義の未知のフィールドは設定エラーとする。 ```yaml stacks: @@ -26,6 +26,9 @@ stacks: aws_region: ap-northeast-1 iam_role_plan: arn:aws:iam::111111111111:role/plan-production iam_role_apply: arn:aws:iam::111111111111:role/apply-production + exclude: + - service: demo + environment: production - name: container paths: @@ -33,13 +36,6 @@ stacks: - "system-components/{service}" attributes: repository: registry.example.com/app - -services: - - name: demo - exclude_from_automation: true - exclusion_config: - reason: "Manual deployment is required." - type: permanent ``` ### Stack Identity @@ -58,9 +54,9 @@ services: ### Environment Attributes -`environments` のキーが環境名であり、その値が当該環境の属性である。キーは空でない文字列、値は属性のマップとする。環境名は一つのパス要素として利用できる値とし、`/`、`.`、`..` は許可しない。環境に属性が不要なら空のマップを指定できる。 +`environments` のキーが環境名であり、その値が当該環境の属性である。キーは空でない文字列、値は属性のマップとする。環境名は一つのパス要素として利用できる値とし、`/` を含む値と、`.` または `..` そのものを拒否する。環境に属性が不要なら空のマップを指定できる。 -`environments` を持つ stack は、定義した環境だけで実行する。全体の環境一覧は各 stack の環境名の和集合から導出する。独立した環境定義や `environment` フィールドは置かない。 +`environments` を持つ stack は、定義した環境だけで実行する。全体の環境一覧は各 stack の環境名の和集合から導出する。全体の環境一覧を設定には登録せず、環境属性の識別には `environments` のキーを使う。 実行単位は環境の定義で決まる。環境を持つ stack のパスに `{environment}` がなくても、そのディレクトリを環境ごとに異なる属性で実行できる。 @@ -72,7 +68,7 @@ services: ### Product Boundaries -属性セットを共有するプロダクトのパスは一つの stack 定義に集める。属性が独立する場合は、同じ `name` を持つ別の定義にする。 +属性セットと除外規則を共有するプロダクトのパスは一つの stack 定義に集める。属性または除外規則が独立する場合は、同じ `name` を持つ別の定義にする。 ```yaml stacks: @@ -97,41 +93,50 @@ stacks: 一部のプロダクトだけで共有する場合は、その範囲のパスを同じ定義にまとめる。属性の一部だけが共通の場合は、独立した各定義に値を明記する。属性の継承・参照・共通定義の解決機構は設けない。 -### Services +### Target Exclusions + +各 stack の任意の `exclude` に、除外するサービス名と環境名の組を並べる。省略時は空の配列として扱う。各規則はその stack 定義の対象だけに作用し、複数の規則のいずれかに一致した対象を除外する。 + +環境を持つ stack の規則は必須の `service` と `environment` を持つ。値は完全一致で照合し、`environment` はその stack に定義された環境名を指定する。パスに `{environment}` がなくても環境別の除外ができる。 -`services` はサービス設定の配列であり、省略時は空の配列として扱う。各定義には必須の `name`、任意の `exclude_from_automation` と `exclusion_config` を置く。これら以外のフィールドは設定エラーとする。 +環境共通の stack の規則は必須の `service` だけを持つ。`environment` は指定しない。そのサービスの環境共通の対象だけを除外する。 -サービス名は空でない文字列で、`/` を含まず、`.` で始まらないものとする。`exclude_from_automation` は真偽値であり、省略時は `false` として扱う。`true` の場合は `exclusion_config` を要求する。 +`service` は空でない文字列で、`/` を含まず、`.` で始まらないものとする。規則内の未知のフィールドは設定エラーとする。サービスがディレクトリにまだ存在しない場合も規則の定義は有効である。 -`exclusion_config` は必須の空でない文字列 `reason` と、任意の `type` を持つマップである。`type` は `permanent`、`temporary`、`conditional` のいずれかを指定でき、除外の説明に使う。実行対象から外す判断は除外フラグだけで行う。未知のフィールドは拒否する。 +設定例では、`aws` の `demo` と `production` の組を除外する。`demo` の `develop`、別サービスの `production`、環境共通の `container` はそれぞれ対象になり得る。共有した複数パスのすべてに同じ規則が作用する。 + +サービスを全体で除外する設定、除外理由や種別の設定、パスによる別の除外形式は設けない。サービス一覧は実在するパスから取得し、設定には登録しない。 ## Responsibilities | Component | Responsibility | |---|---| | `ConfigClient` | 設定ファイルを読み、YAML を解析して設定モデルへ渡す。ファイル読み込みと YAML 構文のエラーを伝える。 | -| `WorkflowConfig` | スキーマと設定内の整合性を一か所で検証し、stack 定義・環境名・サービスの設定を提供する。 | +| `WorkflowConfig` | スキーマと設定内の整合性を一か所で検証し、stack 定義・環境名・当該 stack のサービスと環境の除外判定を提供する。 | | `PatternMatcher` | placeholder の文法、照合、展開、抽出を扱う。プロダクトや stack の属性を判断しない。 | -| `DetectChangedServices` | 各 stack のパスに変更ファイルを照合し、抽出したサービス名と除外設定からラベル対象を決める。 | +| `DetectChangedServices` | 各 stack のパスに変更ファイルを照合し、有効なサービスと環境の組があるサービスをラベル対象にする。 | | `DetermineTargetEnvironment` | 環境選択を導出された環境一覧と照合する。 | -| `GenerateMatrix` | stack の環境と各パスから実在する実行対象を列挙し、その定義の属性を渡す。 | -| Config manager | 同じ設定モデルを使って検証結果・stack・環境・属性を表示する。 | +| `GenerateMatrix` | stack の環境と各パスから実在する実行対象を列挙し、除外規則を適用してその定義の属性を渡す。 | +| Config manager | 同じ設定モデルを使って検証結果・stack・環境・属性・除外規則を表示する。 | -各利用側は `stacks` を直接扱う。root ごとの convention 配列を中間表現として組み立てない。属性は読み込んだ定義内で完結し、プロバイダー固有の知識を設定モデルに持ち込まない。 +各利用側は `stacks` を直接扱う。root ごとの convention 配列を中間表現として組み立てない。属性と除外規則は読み込んだ定義内で完結し、プロバイダー固有の知識を設定モデルに持ち込まない。変更検出と matrix 生成は同じ除外判定を利用する。 ## Runtime Flow 1. 設定ファイルを読み、スキーマと設定内の整合性を検証する。成功時だけ検証済みモデルを利用側へ渡す。 2. 環境指定を検証する。指定省略時は定義された全環境を選択し、重複した指定は一つにまとめる。未知の環境名があれば処理を失敗させる。 -3. ラベルで指定されたサービスを対象にする。`deploy:all` は `services` に明示されたサービスを対象にし、通常のサービスラベルは明示登録がなくても処理できる。除外されたサービスは対象から外す。 +3. ラベルのサービス指定を確認する。通常のサービスラベルでは指定した名前でパスを照合し、`deploy:all` では実在するパスからすべてのサービスを取得する。 4. stack ごとに対象環境を決める。環境を持つ stack は定義された環境と選択された環境の共通部分を使い、環境共通の stack は環境値を `null` として一度処理する。 5. 各パスの実在するディレクトリを列挙し、サービス名と環境名を確認する。任意の placeholder に複数の値が一致する場合も各ディレクトリを対象にする。存在しないパスは対象を生成しない。 -6. 各対象へ、その stack 定義の環境属性または環境共通の属性と、パスから抽出した値を渡す。対象の一意性を確認した配列が matrix 生成の完了条件となる。 +6. 各対象のサービス名と環境名を当該 stack の除外規則と照合し、一致した対象を除く。通常のサービスラベルと `deploy:all` の双方で同じ規則を適用する。 +7. 各対象へ、その stack 定義の環境属性または環境共通の属性と、パスから抽出した値を渡す。対象の一意性を確認した配列が matrix 生成の完了条件となる。 環境共通の stack だけを持つ設定も有効である。環境指定を省略した場合は空の環境一覧から環境共通の対象を生成できる。明示的な環境指定は、定義された環境名であることを要求する。 変更検出は各 `paths` が指すディレクトリとその配下を対象にする。サービス全体の変更を検出するには、`dystopia/{service}` のようにサービスディレクトリを指すパスを stack に定義する。`{environment}` を含むパスで定義外の環境を抽出した場合は、その照合をラベル対象にしない。 +変更ファイルが照合したサービスと環境の組に除外規則を適用する。環境を持つパスで環境名を抽出できない場合は、その stack の定義された環境ごとに判定する。環境共通のパスは環境値を `null` として判定する。除外されない照合が一つでもあればサービスラベルを生成し、除外された照合しかない場合は生成しない。 + ## Matrix matrix の固定キーは `service`、`environment`、`stack`、`stack_id`、`working_directory` とする。`stack` は `name`、`stack_id` は確定した識別子、`working_directory` は解決した相対パスである。環境共通の対象では `environment` が `null` になる。 @@ -146,7 +151,7 @@ matrix の固定キーは `service`、`environment`、`stack`、`stack_id`、`wo 設定モデルで以下を検証する。 -- トップレベル、stack、サービス設定の構造と許可されたフィールド。 +- トップレベルと stack 定義の構造と許可されたフィールド。 - 空でない `name`、指定された `id` の型、設定全体での識別子の一意性。 - 空でない `paths` 配列と文字列のパターン。絶対パスと親ディレクトリへ移動する要素は拒否する。`.` 要素と末尾の `/` は相対パスとして正規化する。 - 各パターンの `{service}`、placeholder の文法、同名 placeholder の一致規則。 @@ -154,7 +159,8 @@ matrix の固定キーは `service`、`environment`、`stack`、`stack_id`、`wo - `attributes` の型、`environments` との併用、環境共通のパスでの `{environment}` 使用。 - 属性キーは空でない文字列であり、matrix の固定キーと衝突しないこと。 - 任意の placeholder 名が matrix の固定キーや当該 stack の属性キーと衝突しないこと。環境属性のキーはその stack の全環境から収集する。 -- サービス名の一意性、除外フラグの型、指定された除外情報の構造。サービス別のパス override は受け付けない。 +- `exclude` の配列と各規則のマップ、許可されたフィールド、サービス名の型。 +- 環境を持つ stack の除外規則の環境名が当該 stack に定義されていること。環境共通の stack の除外規則に環境指定がないこと。 属性の必須チェックと、環境間で属性キーを揃える検証は行わない。必要な属性の判断は利用側の workflow または action が担う。 @@ -165,12 +171,15 @@ matrix の固定キーは `service`、`environment`、`stack`、`stack_id`、`wo 新しい形式の利用側を揃え、以下の設定モデルに関係する経路を削除する。 - トップレベルの `environments` と `stack_conventions`、サービス配下の `stack_conventions` の読み込み。 +- `services` の登録、`exclude_from_automation`、`exclusion_config` と、全体のサービス除外一覧・理由・種別の取得や検証。 - `required_attributes` とその取得・型検証・必須キー検証。 - `stack_conventions*`、`directory_stacks`、root の取得と推測など、旧構造を公開するメソッド。 - 属性を識別子から種類名へ探し直す処理と、最初の convention や最初のパスだけを採用する処理。 - 環境共通の対象に空の属性を固定して渡す処理。 - 設定の読み込み失敗時に環境一覧を既定値で補う処理。 - 設定の各利用側に分散したスキーマ検証と、特定のプロバイダーの属性だけを表示する設定 CLI の処理。 +- 登録したサービスの一覧と全体のサービス除外一覧を表示する CLI コマンド、およびその統計表示。 +- 全体のサービス除外一覧を前提とする内部の `EXCLUDED_SERVICES` と `HAS_EXCLUDED_SERVICES`、および対応する内部出力と表示。 - 新しいモデルを利用する箇所から呼ばれなくなる補助処理、旧形式を前提とする fixture と期待値。 許可するフィールドを検証することで旧形式を拒否し、旧形式専用の変換器・アダプター・二重の読み込み経路は作らない。設定例、テンプレート、README、CLI 表示、テストの fixture は同じスキーマに揃える。変更履歴を扱う文書は現在の設定例として参照しない。 @@ -183,6 +192,8 @@ root ごとのグループ内へ属性を移す形式は、同じ stack が複 属性の共通定義や継承を導入すると、属性値を確認するために別の定義と上書き順序を追う必要がある。複数パスで共有する定義と、独立した識別子の定義を使い分けることで共有範囲を明示する。 +パスによる除外は、環境名がパスに含まれない stack で環境別の対象を指定できない。サービス名と環境名の組を使うことで、ディレクトリ構成と除外対象を独立させる。 + ## Verification Criteria 実装の完了条件は、新しいモデルに対して以下の振る舞いをテストで確認することである。 @@ -193,10 +204,13 @@ root ごとのグループ内へ属性を移す形式は、同じ stack が複 4. 環境共通の対象が、複数環境選択時と環境共通だけの設定の双方で、各ディレクトリにつき一度生成される。その属性も matrix に渡る。 5. 環境を持つ stack のパスに `{environment}` がなくても、環境ごとの対象と属性が得られる。 6. 同名サービスの複数プロダクト、任意の placeholder の複数一致、重複したパスが、対象の識別規則どおりに処理される。 -7. 変更検出が新しいパス、定義された環境、サービス除外を使ってラベル対象を決める。 -8. 新しいスキーマの型、識別子重複、フィールド混在、placeholder 衝突、矛盾した抽出結果を検出する。 -9. 旧形式と廃止したフィールドを拒否する。属性キーが環境ごとに異なる設定と、必須キーの宣言がない設定を受け付ける。 -10. matrix に固定キー・属性・抽出値が正しく入り、ルート由来の出力を持たない。 -11. Config manager の検証・表示・環境一覧・サービス診断・テンプレートが同じ設定モデルを利用する。 +7. 特定のサービスと環境の組だけを除外し、別の環境・別のサービス・別の stack の対象に影響しない。環境名がパスにない構成でも環境別に除外できる。 +8. 環境共通の対象をサービス名で除外でき、変更検出と matrix 生成の双方で同じ規則が作用する。 +9. 変更検出は有効な照合があるサービスだけをラベル対象にする。除外された照合しかない場合と、除外されない環境または stack の照合もある場合を区別する。 +10. `deploy:all` がサービスの明示登録を要求せず、実在するパスから得られたサービスと環境に除外規則を適用する。 +11. 新しいスキーマの型、識別子重複、フィールド混在、placeholder 衝突、矛盾した抽出結果を検出する。除外規則の環境指定の欠落・未定義の環境・環境共通の対象への環境指定も検出する。 +12. 旧形式と廃止したフィールドを拒否する。属性キーが環境ごとに異なる設定と、必須キーの宣言がない設定を受け付ける。 +13. matrix に固定キー・属性・抽出値が正しく入り、ルート由来の出力を持たない。 +14. Config manager の検証・表示・環境一覧・サービス診断・テンプレートが同じ設定モデルを利用する。 実装時には対象のテストと全体の RSpec を実行し、サンプル設定を Config manager で検証する。旧モデルの経路と互換コードが利用側に残っていないことを検索と差分レビューで確認する。 From 370be380acb73811e11c4502e223927dbc162e9f Mon Sep 17 00:00:00 2001 From: panicboat Date: Fri, 9 Oct 2026 09:08:38 +0900 Subject: [PATCH 03/13] docs: define placeholder exclusion conditions Rule keys derived from stack paths allow teams and products to define exclusion boundaries without extending the schema. Signed-off-by: panicboat --- .../specs/stack-configuration-design.md | 65 ++++++++++++------- 1 file changed, 43 insertions(+), 22 deletions(-) diff --git a/docs/superpowers/specs/stack-configuration-design.md b/docs/superpowers/specs/stack-configuration-design.md index e195111..973fa48 100644 --- a/docs/superpowers/specs/stack-configuration-design.md +++ b/docs/superpowers/specs/stack-configuration-design.md @@ -68,7 +68,7 @@ stacks: ### Product Boundaries -属性セットと除外規則を共有するプロダクトのパスは一つの stack 定義に集める。属性または除外規則が独立する場合は、同じ `name` を持つ別の定義にする。 +属性セットを共有するプロダクトのパスは一つの stack 定義に集める。除外規則はその定義内で管理し、placeholder の条件で対象を絞り込める。属性セットや設定の管理単位が独立する場合は、同じ `name` を持つ別の定義にする。 ```yaml stacks: @@ -95,26 +95,44 @@ stacks: ### Target Exclusions -各 stack の任意の `exclude` に、除外するサービス名と環境名の組を並べる。省略時は空の配列として扱う。各規則はその stack 定義の対象だけに作用し、複数の規則のいずれかに一致した対象を除外する。 +各 stack の任意の `exclude` に、除外条件のマップを並べる。省略時は空の配列として扱う。各規則はその stack 定義の対象だけに作用する。一つのマップ内の条件はすべて一致する必要があり、配列内の規則のいずれかが一致した対象を除外する。値は完全一致で照合し、省略したキーは条件に含めない。 -環境を持つ stack の規則は必須の `service` と `environment` を持つ。値は完全一致で照合し、`environment` はその stack に定義された環境名を指定する。パスに `{environment}` がなくても環境別の除外ができる。 +条件のキーには固定の `service`・`environment` と、当該 stack の `paths` に定義した任意の placeholder 名を使える。`service` と `environment` の指定はそれぞれ任意であり、各規則に少なくとも一つのキーを要求する。空のマップと、許可されたキー以外の指定は設定エラーとする。 -環境共通の stack の規則は必須の `service` だけを持つ。`environment` は指定しない。そのサービスの環境共通の対象だけを除外する。 +`service` と `environment` は実行対象として確定した値を照合する。パスに `{environment}` がなくても環境別の除外ができる。任意の placeholder は、照合したパスから抽出した値を使う。当該パスから条件のキーに対応する値を抽出できなかった場合は、その規則には一致しない。 -`service` は空でない文字列で、`/` を含まず、`.` で始まらないものとする。規則内の未知のフィールドは設定エラーとする。サービスがディレクトリにまだ存在しない場合も規則の定義は有効である。 +条件値は空でない文字列とし、`/` を含む値と、`.` または `..` そのものを拒否する。`service` はさらに `.` で始まる値を拒否する。環境を持つ stack では `environment` に当該 stack に定義された環境名を指定する。環境共通の stack では `environment` に `null` だけを指定でき、条件から省略することもできる。`null` を許容するキーは `environment` だけとする。サービスや任意の placeholder の値がまだディレクトリに存在しない場合も、規則の定義は有効である。 -設定例では、`aws` の `demo` と `production` の組を除外する。`demo` の `develop`、別サービスの `production`、環境共通の `container` はそれぞれ対象になり得る。共有した複数パスのすべてに同じ規則が作用する。 +```yaml +stacks: + - name: terragrunt + id: aws + paths: + - "{team}/{service}/aws/{environment}" + environments: + develop: {} + production: {} + exclude: + - team: platform + service: demo + environment: production + - team: sandbox +``` -サービスを全体で除外する設定、除外理由や種別の設定、パスによる別の除外形式は設けない。サービス一覧は実在するパスから取得し、設定には登録しない。 +この例では、`platform` の `demo` の `production` と、`sandbox` のすべてのサービス・環境を当該 stack から除外する。`service` だけを指定するとそのサービスの全環境、`environment` だけを指定するとその環境の全サービスを当該 stack から除外する。 + +任意のキーの許可範囲は当該 stack のすべての `paths` から集める。例えば `{team}` を含むパスと含まないパスを併記できるが、`team` を条件に持つ規則は後者には一致しない。リテラルのディレクトリ名から placeholder の値を推測しない。プロダクト名を条件に使うには、パスに `{product}` として定義する。 + +設定全体のサービス除外、除外理由や種別、パスによる別の除外形式は設けない。サービス一覧は実在するパスから取得し、設定には登録しない。 ## Responsibilities | Component | Responsibility | |---|---| | `ConfigClient` | 設定ファイルを読み、YAML を解析して設定モデルへ渡す。ファイル読み込みと YAML 構文のエラーを伝える。 | -| `WorkflowConfig` | スキーマと設定内の整合性を一か所で検証し、stack 定義・環境名・当該 stack のサービスと環境の除外判定を提供する。 | +| `WorkflowConfig` | スキーマと設定内の整合性を一か所で検証し、stack 定義・環境名・当該 stack の確定した値に基づく除外判定を提供する。 | | `PatternMatcher` | placeholder の文法、照合、展開、抽出を扱う。プロダクトや stack の属性を判断しない。 | -| `DetectChangedServices` | 各 stack のパスに変更ファイルを照合し、有効なサービスと環境の組があるサービスをラベル対象にする。 | +| `DetectChangedServices` | 各 stack のパスに変更ファイルを照合し、環境名と任意の placeholder を解決して、除外されない照合があるサービスをラベル対象にする。 | | `DetermineTargetEnvironment` | 環境選択を導出された環境一覧と照合する。 | | `GenerateMatrix` | stack の環境と各パスから実在する実行対象を列挙し、除外規則を適用してその定義の属性を渡す。 | | Config manager | 同じ設定モデルを使って検証結果・stack・環境・属性・除外規則を表示する。 | @@ -128,14 +146,14 @@ stacks: 3. ラベルのサービス指定を確認する。通常のサービスラベルでは指定した名前でパスを照合し、`deploy:all` では実在するパスからすべてのサービスを取得する。 4. stack ごとに対象環境を決める。環境を持つ stack は定義された環境と選択された環境の共通部分を使い、環境共通の stack は環境値を `null` として一度処理する。 5. 各パスの実在するディレクトリを列挙し、サービス名と環境名を確認する。任意の placeholder に複数の値が一致する場合も各ディレクトリを対象にする。存在しないパスは対象を生成しない。 -6. 各対象のサービス名と環境名を当該 stack の除外規則と照合し、一致した対象を除く。通常のサービスラベルと `deploy:all` の双方で同じ規則を適用する。 +6. 各対象のサービス名・環境名・任意の placeholder の抽出値を当該 stack の除外規則と照合し、一致した対象を除く。通常のサービスラベルと `deploy:all` の双方で同じ規則を適用する。 7. 各対象へ、その stack 定義の環境属性または環境共通の属性と、パスから抽出した値を渡す。対象の一意性を確認した配列が matrix 生成の完了条件となる。 環境共通の stack だけを持つ設定も有効である。環境指定を省略した場合は空の環境一覧から環境共通の対象を生成できる。明示的な環境指定は、定義された環境名であることを要求する。 変更検出は各 `paths` が指すディレクトリとその配下を対象にする。サービス全体の変更を検出するには、`dystopia/{service}` のようにサービスディレクトリを指すパスを stack に定義する。`{environment}` を含むパスで定義外の環境を抽出した場合は、その照合をラベル対象にしない。 -変更ファイルが照合したサービスと環境の組に除外規則を適用する。環境を持つパスで環境名を抽出できない場合は、その stack の定義された環境ごとに判定する。環境共通のパスは環境値を `null` として判定する。除外されない照合が一つでもあればサービスラベルを生成し、除外された照合しかない場合は生成しない。 +変更ファイルが照合したパスからサービス名と任意の placeholder の値を抽出し、環境名を解決して除外規則を適用する。環境を持つ stack のパスに `{environment}` がない場合は、その stack の定義された環境ごとに判定する。環境共通のパスは環境値を `null` として判定する。条件のキーに対応する抽出値がない場合は、その規則には一致しない。除外されない照合が一つでもあればサービスラベルを生成し、除外された照合しかない場合は生成しない。 ## Matrix @@ -159,8 +177,8 @@ matrix の固定キーは `service`、`environment`、`stack`、`stack_id`、`wo - `attributes` の型、`environments` との併用、環境共通のパスでの `{environment}` 使用。 - 属性キーは空でない文字列であり、matrix の固定キーと衝突しないこと。 - 任意の placeholder 名が matrix の固定キーや当該 stack の属性キーと衝突しないこと。環境属性のキーはその stack の全環境から収集する。 -- `exclude` の配列と各規則のマップ、許可されたフィールド、サービス名の型。 -- 環境を持つ stack の除外規則の環境名が当該 stack に定義されていること。環境共通の stack の除外規則に環境指定がないこと。 +- `exclude` の配列と各規則の空でないマップ。キーが `service`・`environment` または当該 stack の `paths` の placeholder 名であること。 +- 除外条件の値の型とパス要素の制約。環境を持つ stack で指定した除外条件の環境名が当該 stack に定義されていること。環境共通の stack で指定した除外条件の環境値が `null` であること。 属性の必須チェックと、環境間で属性キーを揃える検証は行わない。必要な属性の判断は利用側の workflow または action が担う。 @@ -192,7 +210,9 @@ root ごとのグループ内へ属性を移す形式は、同じ stack が複 属性の共通定義や継承を導入すると、属性値を確認するために別の定義と上書き順序を追う必要がある。複数パスで共有する定義と、独立した識別子の定義を使い分けることで共有範囲を明示する。 -パスによる除外は、環境名がパスに含まれない stack で環境別の対象を指定できない。サービス名と環境名の組を使うことで、ディレクトリ構成と除外対象を独立させる。 +パスによる除外は、環境名がパスに含まれない stack で環境別の対象を指定できない。確定したサービス名と環境名を条件に使うことで、ディレクトリ構成と除外対象を独立させる。任意の placeholder も条件に使うことで、パスに定義した team や product の単位で除外できる。 + +除外のキーを固定すると、team や product を使う構成ごとにスキーマを拡張する必要がある。当該 stack の placeholder 名から許可するキーを導出することで、構成で定義した単位を使え、未定義のキーによる誤記も検出できる。 ## Verification Criteria @@ -204,13 +224,14 @@ root ごとのグループ内へ属性を移す形式は、同じ stack が複 4. 環境共通の対象が、複数環境選択時と環境共通だけの設定の双方で、各ディレクトリにつき一度生成される。その属性も matrix に渡る。 5. 環境を持つ stack のパスに `{environment}` がなくても、環境ごとの対象と属性が得られる。 6. 同名サービスの複数プロダクト、任意の placeholder の複数一致、重複したパスが、対象の識別規則どおりに処理される。 -7. 特定のサービスと環境の組だけを除外し、別の環境・別のサービス・別の stack の対象に影響しない。環境名がパスにない構成でも環境別に除外できる。 -8. 環境共通の対象をサービス名で除外でき、変更検出と matrix 生成の双方で同じ規則が作用する。 -9. 変更検出は有効な照合があるサービスだけをラベル対象にする。除外された照合しかない場合と、除外されない環境または stack の照合もある場合を区別する。 -10. `deploy:all` がサービスの明示登録を要求せず、実在するパスから得られたサービスと環境に除外規則を適用する。 -11. 新しいスキーマの型、識別子重複、フィールド混在、placeholder 衝突、矛盾した抽出結果を検出する。除外規則の環境指定の欠落・未定義の環境・環境共通の対象への環境指定も検出する。 -12. 旧形式と廃止したフィールドを拒否する。属性キーが環境ごとに異なる設定と、必須キーの宣言がない設定を受け付ける。 -13. matrix に固定キー・属性・抽出値が正しく入り、ルート由来の出力を持たない。 -14. Config manager の検証・表示・環境一覧・サービス診断・テンプレートが同じ設定モデルを利用する。 +7. サービスだけ、環境だけ、両方の指定を受け付け、指定した条件だけで当該 stack の対象を除外する。環境名がパスにない構成でも環境別に除外できる。 +8. 任意の placeholder だけの条件と、固定キーを組み合わせた条件を受け付ける。一つのマップ内は AND、規則の配列は OR で一致し、条件の抽出値がないパスは一致しない。別の stack の placeholder は条件のキーとして許可しない。 +9. 環境共通の対象をサービス名や任意の placeholder で除外でき、`environment: null` の指定と環境条件の省略の双方を受け付ける。変更検出と matrix 生成の双方で同じ規則が作用する。 +10. 変更検出は有効な照合があるサービスだけをラベル対象にする。除外された照合しかない場合と、除外されない環境・stack・任意の placeholder の照合もある場合を区別する。 +11. `deploy:all` がサービスの明示登録を要求せず、実在するパスから得られたサービス名・環境名・任意の placeholder の値に除外規則を適用する。 +12. 新しいスキーマの型、識別子重複、フィールド混在、placeholder 衝突、矛盾した抽出結果を検出する。除外規則の空のマップ・未定義のキー・未定義の環境・不正な値の型・環境共通の対象への非 null の環境指定も検出する。 +13. 旧形式と廃止したフィールドを拒否する。属性キーが環境ごとに異なる設定と、必須キーの宣言がない設定を受け付ける。 +14. matrix に固定キー・属性・抽出値が正しく入り、ルート由来の出力を持たない。 +15. Config manager の検証・表示・環境一覧・サービス診断・テンプレートが同じ設定モデルを利用する。 実装時には対象のテストと全体の RSpec を実行し、サンプル設定を Config manager で検証する。旧モデルの経路と互換コードが利用側に残っていないことを検索と差分レビューで確認する。 From a22f79f486d3297a970b089dbd430741371a87d0 Mon Sep 17 00:00:00 2001 From: panicboat Date: Fri, 9 Oct 2026 09:24:41 +0900 Subject: [PATCH 04/13] docs: plan stack configuration implementation All consumers depend on the same schema and exclusion matcher, so the replacement needs an ordered implementation plan without compatibility adapters. Signed-off-by: panicboat --- docs/superpowers/plans/stack-configuration.md | 489 ++++++++++++++++++ 1 file changed, 489 insertions(+) create mode 100644 docs/superpowers/plans/stack-configuration.md diff --git a/docs/superpowers/plans/stack-configuration.md b/docs/superpowers/plans/stack-configuration.md new file mode 100644 index 0000000..c82fa69 --- /dev/null +++ b/docs/superpowers/plans/stack-configuration.md @@ -0,0 +1,489 @@ +# Stack Configuration Implementation Plan + +> **For agentic workers:** ユーザーが計画と実行方式を承認した後、本人が実装する場合は `superpowers:executing-plans`、subagent 方式を選んだ場合は `superpowers:subagent-driven-development` を使う。各手順のチェックボックスを完了時に更新する。委譲する場合は AGENTS.md の全ルールを委譲先に渡す。 + +**Goal:** stack のパス・属性・除外条件を一つの定義で扱い、すべての利用側を新しい設定モデルに揃える。 + +**Architecture:** `WorkflowConfig` が新形式を検証し、確定した stack 定義と除外判定を提供する。既存の `PatternMatcher` と `FileSystemClient` を使い、変更検出は変更パスから、matrix とサービス診断は実在ディレクトリから対象を解決する。旧形式への変換器を挟まず、利用側を直接変更する。 + +**Tech Stack:** Ruby、YAML、RSpec、FactoryBot、Thor。バージョンは `action-scripts/.ruby-version` と `action-scripts/Gemfile.lock` を参照する。 + +**Spec:** [Stack Configuration Design](../specs/stack-configuration-design.md) + +## Global Constraints + +- 設計書の新しいスキーマだけを受け付け、旧形式の変換器・互換アクセサー・二重の読み込み経路を作らない。 +- 「トップレベルには必須の `stacks` だけを置く。」 +- 「`attributes` と `environments` は併用しない。」 +- 「属性値の型を変換しない。」 +- 「属性の必須チェックと、環境間で属性キーを揃える検証は行わない。」 +- 「変更検出と matrix 生成は同じ除外判定を利用する。」 +- 新しい依存ライブラリを追加せず、Gemfile・lockfile・Ruby のバージョン指定を変更しない。 +- ドキュメントの見出しは英語、本文は日本語にする。コードの名前・コメント・テスト名・コミットメッセージは英語にする。 +- コメントを追加する場合は、非自明な制約を一行で書く。複数行のコメントと docstring を追加しない。 +- コミットには `git commit -s` を使い、`Co-Authored-By` を付けない。初回 push は `git push -u origin HEAD`、PR は Draft にする。 +- 各タスクは対象テストが通った時点でコミットする。全体の RSpec と設定 CLI の検証が通るまで、実装全体の完了を報告しない。 +- 各コミット前に `superpowers:verification-before-completion` を適用し、そのコミットに含まれる変更の検証コマンドと出力を確認する。 + +## Review Focus + +1. 不正な YAML 構造や任意キーの誤記は、設定内の位置を含むエラーとして各利用側へ届く。Task 1 の検証表と読み込みテストで確認する。 +2. リテラルに glob 記号を含むパスと、同名 placeholder の繰り返しは、ディレクトリ列挙とパス照合で同じ意味になる。Task 2 の実在ディレクトリテストで確認する。 +3. 環境共通だけの構成と環境名がパスにない構成は、環境数に応じた実行回数と属性が得られる。Task 3 の対象数・属性のテストで確認する。 +4. 同じ対象へ複数パスが一致して抽出値が異なる場合は、除外条件やパス順序で矛盾が隠れず失敗する。Task 3 の衝突テストで確認する。 +5. `{team}` がないパス、除外されない別 stack の照合、除外されない別環境の照合は、対象単位で判定する。Task 1・3・4・5 の除外と診断のテストで確認する。 + +## File Map + +| Files | Responsibility | Task | +|---|---|---| +| `action-scripts/shared/entities/workflow_config.rb` | スキーマ検証、正規化した stack、環境一覧、除外判定 | 1 | +| `action-scripts/shared/infrastructure/config_client.rb` | ファイル読み込み、YAML 解析、モデルのキャッシュ | 1 | +| `action-scripts/config-manager/use_cases/validate_config.rb` | モデルの検証結果と設定件数の表示用サマリー | 1 | +| `action-scripts/spec/factories.rb`, `action-scripts/spec/spec_helper.rb` | 新形式の共通 fixture と実在ディレクトリを使うテスト基盤 | 1, 2, 3 | +| `action-scripts/shared/infrastructure/file_system_client.rb` | ソースリポジトリのルートと実在するパスの照合結果 | 2 | +| `action-scripts/shared/entities/deployment_target.rb` | matrix の固定キー、属性、抽出値、対象の同一性 | 3 | +| `action-scripts/label-resolver/use_cases/determine_target_environment.rb`, `action-scripts/label-resolver/use_cases/generated_matrix.rb` | 環境選択と対象列挙 | 3 | +| `action-scripts/label-resolver/application.rb`, `action-scripts/label-resolver/bin/resolver` | ファイルクライアントの注入と CLI 入力の解析 | 3 | +| `action-scripts/label-dispatcher/use_cases/detect_changed_services.rb`, `action-scripts/label-dispatcher/controllers/label_dispatcher_controller.rb` | 照合単位の除外とサービスラベル生成 | 4 | +| `action-scripts/shared/interfaces/presenters/console_presenter.rb`, `action-scripts/shared/interfaces/presenters/github_actions_presenter.rb` | dispatch・設定・サービス診断の表示 | 4, 5 | +| `action-scripts/config-manager/controllers/config_manager_controller.rb`, `action-scripts/config-manager/application.rb`, `action-scripts/config-manager/bin/config-manager` | 設定表示、環境一覧、サービス診断、テンプレート | 5 | +| `action-scripts/workflow-config.yaml`, `README.md`, `README-ja.md` | 実行可能な設定例と設定・matrix の説明 | 5 | +| `action-scripts/config-manager/README.md`, `action-scripts/config-manager/README-ja.md` | CLI コマンドと設定表示の説明 | 5 | +| `action-scripts/label-resolver/README.md`, `action-scripts/label-resolver/README-ja.md` | ラベルからの対象列挙と環境選択の説明 | 5 | +| `action-scripts/label-dispatcher/README.md`, `action-scripts/label-dispatcher/README-ja.md` | 変更照合と除外によるラベル生成の説明 | 5 | + +`PatternMatcher.placeholders(pattern)`、`expand(pattern, values)`、`extract(pattern, path)`、`extract_prefix(pattern, path)` の既存 signature を使う。placeholder の文法を別のクラスへ複製しない。GitHub API クライアント、ラベル管理、設定コピーの JavaScript、composite action の入出力はこの計画の変更対象に含めない。 + +## Execution Preparation + +- [ ] **Step 1: Read the approved documents and repository rules** + +設計書、本計画、AGENTS.md、存在する CLAUDE.md を読む。実行方式の承認と、設計書の15項目が計画のどのタスクに対応するかを確認した時点で完了とする。 + +- [ ] **Step 2: Prepare the implementation workspace** + +`superpowers:using-git-worktrees` の手順で隔離環境を準備する。ブランチ名・作業パス・既存 Draft PR を継続するか新しい Draft PR にするかを記録する。ユーザーの未コミット変更を含めず、実装用ブランチであることを `git status --short --branch` で確認した時点で完了とする。 + +- [ ] **Step 3: Confirm the runtime and dependency setup** + +`action-scripts` を作業ディレクトリとして `ruby --version`、`bundle --version`、`bundle check` を実行し、リポジトリの指定と整合することを確認する。不足がある場合は既存 lockfile に従って環境を用意し、指定ファイルを書き換えない。 + +以降のテストコマンドは `action-scripts` で実行する。テストはフォアグラウンドで実行し、実行者が完了を待ち、終了コードと標準出力・標準エラーを記録する。中断が必要な場合は実行者が停止する。追加の常駐プロセスを起動しない。 + +## Shared Test Inputs + +Task 1 で `:workflow_config` の `config_hash` を次のデータへ置き換える。`default_test_config` は `attributes_for(:workflow_config).fetch(:config_hash).to_yaml` から取得し、別のスキーマ例を重複して管理しない。 + +```yaml +stacks: + - name: terragrunt + id: aws + paths: + - "dystopia/{service}/aws/{environment}" + - "system-components/{service}/infrastructure/aws/{environment}" + environments: + develop: + aws_region: ap-northeast-1 + production: + aws_region: us-west-2 + exclude: [] + - name: container + paths: + - "dystopia/{service}" + - "system-components/{service}" + attributes: + repository: registry.example.com/app +``` + +各タスクで必要な構成は、その spec の `let(:config_hash)` に明記して `Entities::WorkflowConfig.new(config_hash)` で読む。旧メソッドを mock して新モデルを模倣しない。実在ディレクトリを使う spec は `Dir.mktmpdir` のブロック内で作成・削除を完結させ、変更した環境変数を元の値へ戻す。 + +## Task 1: Configuration Model + +**Files:** + +- Modify: `action-scripts/shared/entities/workflow_config.rb` +- Modify: `action-scripts/shared/infrastructure/config_client.rb` +- Modify: `action-scripts/config-manager/use_cases/validate_config.rb` +- Modify: `action-scripts/spec/factories.rb` +- Modify: `action-scripts/spec/spec_helper.rb` +- Test: `action-scripts/spec/shared/entities/workflow_config_spec.rb` +- Test: `action-scripts/spec/shared/infrastructure/config_client_spec.rb` +- Test: `action-scripts/spec/config-manager/use_cases/validate_config_spec.rb` + +**Interfaces:** + +- Consumes: `Entities::PatternMatcher.placeholders(pattern)` → `Array`、既存の `Entities::Result.success(**data)` と `failure(error_message:, **data)`。 +- Produces: `Entities::WorkflowConfig.new(config_hash)` → 検証済みモデル。不正な設定は位置と理由を含む `ArgumentError`。 +- Produces: `WorkflowConfig#stacks` → 文字列キーの正規化した stack マップの配列。`id` は確定値、`exclude` は省略時 `[]`、環境共通の `attributes` は省略時 `{}`。`environments` の有無は保持する。 +- Produces: `WorkflowConfig#environment_names` → 各 stack の環境名の和集合を、最初に現れた順序で返す `Array`。 +- Produces: `WorkflowConfig#excluded?(stack, values)` → `Boolean`。`stack` は `#stacks` の要素、`values` は文字列キーのマップで、`service`・`environment` と存在する任意の抽出値を含む。 +- Preserves: `Infrastructure::ConfigClient#load_workflow_config`、`#clear_cache`。 +- Produces: `UseCases::ConfigManagement::ValidateConfig#execute` → 成功時 `Result` の `valid: true`・`config`・`validation_summary`、失敗時 `error_message`・`validation_errors`。 + +- [ ] **Step 1: Write model, exclusion, and loading tests** + +共通 fixture を新形式へ変更するテストと、次の除外テストを追加する。 + +```ruby +it 'matches fixed and declared placeholder conditions' do + config = described_class.new('stacks' => [{ + 'name' => 'terragrunt', + 'paths' => ['{team}/{service}/aws/{environment}'], + 'environments' => { 'develop' => {}, 'production' => {} }, + 'exclude' => [ + { 'team' => 'platform', 'service' => 'demo', 'environment' => 'production' }, + { 'team' => 'sandbox' } + ] + }]) + stack = config.stacks.first + + expect(config.excluded?(stack, 'team' => 'platform', 'service' => 'demo', 'environment' => 'production')).to be(true) + expect(config.excluded?(stack, 'team' => 'platform', 'service' => 'demo', 'environment' => 'develop')).to be(false) + expect(config.excluded?(stack, 'team' => 'sandbox', 'service' => 'api', 'environment' => 'develop')).to be(true) + expect(config.excluded?(stack, 'service' => 'demo', 'environment' => 'production')).to be(false) +end +``` + +以下の入力とアサーションを個別の example またはパラメーター化した example にする。 + +| Test name | Input and assertion | +|---|---| +| `resolves identities and environment names` | 共通 fixture の `stacks.map { \|s\| s['id'] } == ['aws', 'container']`、`environment_names == ['develop', 'production']`、共通 stack の `environment_names` への追加はない | +| `normalizes relative paths` | `./dystopia/{service}/` → `dystopia/{service}`。入力マップを変更せず正規化したモデルを返す | +| `accepts environment attributes without required keys` | `develop: { aws_region: 'ap-northeast-1' }` と `production: { token: nil }` を受け付け、値と型を保持する | +| `matches single fixed conditions` | `exclude: [{ service: 'demo' }]` は demo の両環境に一致し、`[{ environment: 'production' }]` は production の全サービスに一致する | +| `matches common targets with null environment` | `environments` のない stack の `{ service: 'demo' }` と `{ environment: nil }` は該当する共通対象に一致する | +| `limits exclusion keys to the owning stack` | 自身の `paths` にない `team` と属性名 `aws_region` は、位置 `stacks[0].exclude[0]` を含むエラーになる。他 stack の `{team}` は許可根拠にしない | +| `rejects empty conditions and invalid values` | `{}`、数値・配列・空文字・`a/b`・`.`・`..`、service の `.hidden`、environment 以外の nil を拒否する | +| `validates environment conditions when present` | 環境名の省略を受け付け、未定義の環境名と環境共通 stack の非 nil 環境名を拒否する | +| `rejects invalid schema shapes` | nil・配列のトップレベル、空・非配列の stacks、非マップの stack、空・非文字列の name/id、重複 identity、空・非配列の paths と空・非文字列のパターン、空・非マップの environments、非マップの属性・exclude 要素を拒否する | +| `rejects unknown fields and mixed attributes` | トップレベルの services/stack_conventions/environments、stack の root/directory/required_attributes、未知キー、attributes と environments の併用を拒否する | +| `validates environment names and attribute keys` | 環境名の空文字・非文字列・`a/b`・`.`・`..`、属性キーの空文字・非文字列・固定5キーとの衝突を拒否する | +| `validates paths and placeholder collisions` | 絶対パス、`..` 要素、service の欠落、不正・閉じていない placeholder、環境共通の `{environment}`、固定5キーや当該 stack の全環境の属性キーと任意 placeholder の衝突を拒否する | +| `limits attribute collisions to the owning stack` | 同じ stack の衝突は拒否し、別 stack の属性キーと placeholder 名が同じ場合は受け付ける | + +`ConfigClient` は成功時のキャッシュ、ファイル欠落・権限・YAML 行番号のエラー、モデルの検証位置が伝わることをテストする。`ValidateConfig` は成功サマリーが `stacks: 2`・`environments: 2` を含み、読み込み失敗では `validation_errors` に元のエラーを保持することをテストする。 + +- [ ] **Step 2: Confirm the tests fail for the old model** + +Run: `bundle exec rspec spec/shared/entities/workflow_config_spec.rb spec/shared/infrastructure/config_client_spec.rb spec/config-manager/use_cases/validate_config_spec.rb` + +Expected: 新形式または新アクセサー・除外判定の assertion が FAIL。環境や require の不備は先に解消し、仕様に対する失敗を記録する。 + +- [ ] **Step 3: Implement the model and remove duplicate validation** + +`WorkflowConfig` の constructor で構造・型・整合性を検証し、その後に id と相対パスを正規化する。除外キーの許可範囲は `PatternMatcher.placeholders` から収集する。除外判定は `values.key?(key) && values[key] == expected` を条件内の全キーに要求し、規則の配列のいずれかが一致した場合だけ true にする。 + +`ConfigClient` の `validate_config!`・`validate_config_file`・サマリー生成を削除し、ファイルと YAML の処理後に `WorkflowConfig.new` を呼ぶ。`ValidateConfig` は読み込み済みモデルからサマリーを作り、個別のスキーマ検証を持たない。サマリーは stack 数と環境数を表示し、登録サービス数・除外理由・種別の統計を表示しない。 + +旧 convention・service・属性探索のメソッドと、製品コードに呼び出しのない `raw_config` アクセサー・`safety_check_enabled?` を削除する。旧 `with_excluded_service` trait を `with_target_exclusions` に置き換え、aws stack に service と environment の条件を設定する。旧形式の振る舞いテストは新仕様のテストへ置き換え、旧形式の拒否を検証する入力だけに旧フィールド名を残す。 + +- [ ] **Step 4: Confirm the model tests pass** + +Step 2 と同じコマンドを実行する。Expected: exit 0、`0 failures`。完了条件は、旧アクセサーを使わずに3ファイルのテストが通ることである。 + +- [ ] **Step 5: Commit the configuration model** + +Files に列挙した変更を指定して stage し、`git diff --cached --check` が exit 0 であることを確認する。 + +Run: `git commit -s -m 'feat: define stack configuration and exclusion conditions'` + +## Task 2: Directory Resolution + +**Files:** + +- Modify: `action-scripts/shared/infrastructure/file_system_client.rb` +- Modify: `action-scripts/spec/spec_helper.rb` +- Create/Test: `action-scripts/spec/shared/infrastructure/file_system_client_spec.rb` + +**Interfaces:** + +- Consumes: `PatternMatcher::PLACEHOLDER_REGEX`、`PatternMatcher.extract(pattern, relative_path)` → 抽出値のマップまたは nil。 +- Produces: `FileSystemClient#repository_root(start_path: __dir__)` → 絶対パスの `String`。既存 matrix の SOURCE_REPO_PATH と親ディレクトリの `.git` 探索を移し、`.git` はファイルとディレクトリの双方を受け付ける。 +- Produces: `FileSystemClient#resolve_directories(pattern:, values: {})` → `Array`。各要素は `{ working_directory: String, captures: Hash }`。相対パスを昇順で返す。`values` はパターンに現れるキーだけを照合し、その placeholder の値を完全一致させる。 +- Preserves: `FileSystemClient#get_changed_files(base_ref: nil, head_ref: nil)` の既存 signature と Git diff 処理。 + +- [ ] **Step 1: Write directory and root tests using temporary repositories** + +`tmpdir` と `fileutils` を spec で読み、ブロック内で `.git` とディレクトリを作る。以下を assertion にする。 + +```ruby +expect(client.resolve_directories( + pattern: 'teams/{team}/{service}/aws/{environment}', + values: { 'service' => 'demo', 'environment' => 'production' } +)).to eq([ + { working_directory: 'teams/platform/demo/aws/production', + captures: { 'team' => 'platform', 'service' => 'demo', 'environment' => 'production' } }, + { working_directory: 'teams/sandbox/demo/aws/production', + captures: { 'team' => 'sandbox', 'service' => 'demo', 'environment' => 'production' } } +]) +``` + +この example の client は `described_class.new`、SOURCE_REPO_PATH は作成した一時リポジトリとする。同じリポジトリに別サービス、develop、通常ファイルを作り、いずれも返らないことを確認する。 + +追加 example は `matches repeated placeholders`、`treats glob characters as literals`、`ignores context keys absent from the pattern`、`returns no matches for absent directories`、`uses the source repository root`、`accepts git marker files`、`fails when no repository root exists` とする。繰り返しは `teams/{team}/{service}/{team}` で同じ team だけが一致し、リテラルは `literal[1]/{service}` で `literal1` を返さないことを確認する。`dystopia/{service}/aws` に environment=production の既知値を渡しても実在するパスを返すことを確認する。`Dir.glob` が `Errno::EACCES` を返した場合は、そのエラーが呼び出し側へ届くことを確認する。 + +- [ ] **Step 2: Confirm the directory tests fail** + +Run: `bundle exec rspec spec/shared/infrastructure/file_system_client_spec.rb` + +Expected: `resolve_directories` と `repository_root` の未実装による FAIL。 + +- [ ] **Step 3: Implement exact directory resolution** + +既存の未使用 `find_directories(pattern)` を置き換え、上記2メソッドを公開する。glob はリテラルを escape し、placeholder だけを候補列挙用の `*` にする。`Dir.glob(..., base: repository_root)` の結果を、実在ディレクトリ・`PatternMatcher.extract`・既知値の一致で絞る。繰り返した placeholder の一致は `PatternMatcher` に任せる。 + +`spec_helper` の全 example に対する test-service/demo の `File.directory?` mock を削除する。各 spec に必要なディレクトリを作り、通常ファイルがディレクトリとして誤認されないことをテストする。Git diff の既存処理は変更しない。 + +- [ ] **Step 4: Confirm the directory tests pass** + +Step 2 と同じコマンドを実行する。Expected: exit 0、`0 failures`。実在しないディレクトリの戻り値が `[]`、列挙エラーが例外であることを確認して完了とする。 + +- [ ] **Step 5: Commit directory resolution** + +Files の変更を stage して `git diff --cached --check` を実行する。 + +Run: `git commit -s -m 'feat: resolve stack paths from repository directories'` + +## Task 3: Environment Selection and Matrix + +**Files:** + +- Modify: `action-scripts/shared/entities/deployment_target.rb` +- Modify: `action-scripts/label-resolver/use_cases/determine_target_environment.rb` +- Modify: `action-scripts/label-resolver/use_cases/generated_matrix.rb` +- Modify: `action-scripts/label-resolver/application.rb` +- Modify: `action-scripts/label-resolver/bin/resolver` +- Modify: `action-scripts/spec/factories.rb` +- Test: `action-scripts/spec/shared/entities/deployment_target_spec.rb` +- Test: `action-scripts/spec/label-resolver/use_cases/determine_target_environment_spec.rb` +- Test: `action-scripts/spec/label-resolver/use_cases/generate_matrix_spec.rb` +- Test: `action-scripts/spec/label-resolver/controllers/label_resolver_controller_spec.rb` +- Create/Test: `action-scripts/spec/label-resolver/cli_spec.rb` + +**Interfaces:** + +- Consumes: Task 1 の `WorkflowConfig#stacks`・`#environment_names`・`#excluded?`、Task 2 の `FileSystemClient#resolve_directories`。 +- Produces: `DetermineTargetEnvironment#execute(target_environments:)` → 成功時 `Result(target_environments: Array)`。nil または `[]` は全環境、指定値は重複を除去し、未知名は failure。旧 `environment_configs` は返さない。 +- Produces: `GenerateMatrix.new(config_client:, file_client:)` と `#execute(deploy_labels:, target_environments:)` → 成功時 `deployment_targets`・`has_deployments`・`total_targets`、失敗時 `error_message` を持つ `Result`。 +- Produces: `DeploymentTarget.new(service:, stack:, stack_id:, working_directory:, environment: nil, attributes: {}, captures: {})`。stack_id は確定値を要求する。 +- Produces: `DeploymentTarget#to_matrix_item` → Symbol キーの固定5フィールドと、属性・任意の抽出値を展開したマップ。`#==`・`#eql?`・`#hash` の識別要素は service・stack_id・environment・working_directory。 +- Produces: `LabelResolverCLI#parse_environments(env_string)` → 入力省略・空白だけなら `[]`、それ以外は comma 分割して strip した文字列配列。環境名の検証は use case に任せる。 + +- [ ] **Step 1: Write environment, target, and matrix behavior tests** + +環境選択は入力 nil・空配列・重複・未知名・環境共通だけの設定をテストする。`['production', 'production']` の結果が `['production']`、未知名 `preview` が failure、共通だけの構成の既定値が `[]` になることを確認する。 + +matrix の spec は一時リポジトリと実モデルを使う。生成対象を `to_matrix_item` に変換し、以下の入力・結果を個別 example にする。 + +| Test name | Input and assertion | +|---|---| +| `generates every shared product path` | 共通 fixture の2プロダクトに demo の両環境を作る。aws は4行、container は2行。各 aws 行のリージョンは自身の環境の値 | +| `keeps independent identities distinct` | 同じ terragrunt、id が dystopia-aws/system-components-aws の定義から各属性を持つ2行を生成する | +| `limits each stack to its declared environments` | aws は develop のみ、別 stack は production のみ。両方選択しても各 stack は1環境分で、aws の production ディレクトリは対象にならない | +| `reuses a directory across declared environments` | path が `dystopia/{service}/aws`、develop/production の属性が異なる。同じ working_directory で環境と属性が異なる2行を返す | +| `generates common targets once with attributes` | 環境共通だけの container 設定と両環境選択の設定で、各ディレクトリ1行・environment nil・repository 属性が得られる | +| `discovers services for all labels` | `deploy:all` は実在する demo/api を列挙し、未登録サービスも対象にする。`deploy:all` と `deploy:demo` の併用でも同じ対象は増えない | +| `resolves every arbitrary placeholder value` | teams/platform と teams/sandbox に demo を作る。各 team の値を持つ2行を返す | +| `applies fixed and arbitrary exclusion conditions` | service のみ・environment のみ・team のみ・組み合わせの除外で該当する行だけを除く。環境名がないパスにも環境条件を適用する | +| `does not match missing captures` | 同じ stack に `teams/{team}/{service}/aws/{environment}` と `dystopia/{service}/aws/{environment}` を置く。team 条件は前者の該当 team だけを除く | +| `deduplicates targets and rejects conflicting captures` | 同じパスの重複は1行。`teams/{team}/{service}/aws` と `teams/{product}/{service}/aws` が同じ対象に一致した場合は failure。除外条件と記載順を変えても failure | +| `returns failures without partial targets` | 途中のディレクトリ列挙エラー・未知の環境で failure、`deployment_targets` は返さない。存在しないディレクトリは正常な空配列 | + +`DeploymentTarget` の spec は固定5キー、属性値の型、capture の衝突、stack_id が異なる対象の不一致、同一対象の `hash` 一致を assertion にする。CLI の spec は bin を require してもコマンドが起動しないこと、nil・空白・`' develop,production '` の解析をテストする。controller の spec は検証済み環境配列を生成と表示へ渡すことを確認する。 + +- [ ] **Step 2: Confirm the resolver tests fail** + +Run: `bundle exec rspec spec/shared/entities/deployment_target_spec.rb spec/label-resolver` + +Expected: 新しい constructor、環境選択、複数パス、除外、固定5キーの assertion が FAIL。 + +- [ ] **Step 3: Implement target generation from stack definitions** + +`application.rb` から `FileSystemClient` を注入する。`DetermineTargetEnvironment` は `environment_names` を使い、`GenerateMatrix` も直接呼び出された場合に環境名を検証する。matrix の入力 nil・空配列も全環境として扱い、既定値・重複除去・未知名の判定は両 use case で同じ値になるようテストする。 + +matrix は stack ごとに選択環境と定義環境の共通部分、または環境共通の `[nil]` を使う。各 paths を `resolve_directories` で列挙し、通常ラベルでは service、全体ラベルでは全サービスを照合する。任意の placeholder はすべての一致を対象にする。サービス名が `.` で始まる一致を対象にしない。 + +候補の識別4要素で重複を確認し、同じ対象で異なる capture マップが得られた場合は failure にする。矛盾を確認した後で `excluded?` を適用する。属性は当該 stack の環境マップまたは attributes から取得する。capture から service と environment を除き、固定フィールドへ渡す。 + +旧 convention の探索、属性の名前への fallback、最初のパスへの収束、サービス登録、root の推測、旧ディレクトリ解決の補助メソッドを削除する。`DeploymentTarget` と factory の `stack_convention_root` を削除し、同一性を stack_id に揃える。bin の実行末尾を `$PROGRAM_NAME == __FILE__` で guard し、環境一覧の既定値を補う `all_environments` を削除する。 + +- [ ] **Step 4: Confirm the resolver tests pass** + +Step 2 と同じコマンドを実行する。Expected: exit 0、`0 failures`。行数・属性・capture を実在ディレクトリで確認し、旧メソッドの mock が残っていない時点で完了とする。 + +- [ ] **Step 5: Commit environment selection and matrix generation** + +Files の変更を stage して `git diff --cached --check` を実行する。 + +Run: `git commit -s -m 'feat: generate deployment targets from stack definitions'` + +## Task 4: Changed Service Detection + +**Files:** + +- Modify: `action-scripts/label-dispatcher/use_cases/detect_changed_services.rb` +- Modify: `action-scripts/label-dispatcher/controllers/label_dispatcher_controller.rb` +- Modify: `action-scripts/shared/interfaces/presenters/console_presenter.rb` +- Modify: `action-scripts/shared/interfaces/presenters/github_actions_presenter.rb` +- Test: `action-scripts/spec/label-dispatcher/use_cases/detect_changed_services_spec.rb` +- Test: `action-scripts/spec/label-dispatcher/controllers/label_dispatcher_controller_spec.rb` +- Create/Test: `action-scripts/spec/shared/interfaces/presenters/console_presenter_spec.rb` +- Create/Test: `action-scripts/spec/shared/interfaces/presenters/github_actions_presenter_spec.rb` + +**Interfaces:** + +- Consumes: Task 1 の設定と除外判定、`PatternMatcher.extract_prefix(pattern, changed_file)`、`PatternMatcher.expand(pattern, values)` → `String`、既存 `FileSystemClient#get_changed_files`。 +- Produces: `DetectChangedServices#execute(base_ref: nil, head_ref: nil)` → 成功時 `deploy_labels`・`changed_files`・`services_detected`、失敗時 `error_message` を持つ `Result`。 +- Produces: 両 presenter の `#present_label_dispatch_result(deploy_labels:, labels_added:, labels_removed:, changed_files:)`。controller はこの signature で呼び出す。 + +- [ ] **Step 1: Write per-match exclusion and output tests** + +変更ファイル `teams/platform/demo/aws/production/main.tf` に対し、team=platform・service=demo・environment=production の条件でラベルを生成しないことをテストする。同じ設定で develop の変更を加えると `deploy:demo` を生成することをテストする。 + +追加 example は `detects only declared stack paths`、`ignores undeclared environments`、`evaluates every environment for paths without environment`、`keeps services with a non-excluded stack match`、`does not exclude paths missing custom captures`、`detects common stacks with null environment`、`rejects conflicting captures before exclusions` とする。サービスディレクトリのパスを定義しない設定で、その兄弟ファイルだけが変わった場合にラベルを生成しないことを assertion にする。Task 3 と同じ team/product の衝突を持つパスで、除外条件やパス順序にかかわらず failure を返すことを確認する。 + +controller の spec は presenter 呼び出しから `excluded_services` が消えることを確認する。GitHub presenter の spec は一時ファイルを `GITHUB_ENV`・`GITHUB_OUTPUT` とし、現行公開 outputs のラベル・サービス・変更有無が出力され、`EXCLUDED_SERVICES`・`HAS_EXCLUDED_SERVICES`・`excluded-services`・`has-excluded-services` が出力されないことを確認する。Console presenter の spec は、ラベルとサービス結果を表示することを確認する。 + +- [ ] **Step 2: Confirm the dispatcher tests fail** + +Run: `bundle exec rspec spec/label-dispatcher spec/shared/interfaces/presenters` + +Expected: stack 別・照合別の判定と旧除外出力の assertion が FAIL。 + +- [ ] **Step 3: Implement service detection from valid matches** + +stack と paths を走査し、変更ファイルから service・環境・任意の placeholder を抽出する。サービス名が `.` で始まる照合は対象にしない。環境名がパスにある場合は当該 stack の定義内であることを確認し、ない場合は定義された各環境、環境共通なら nil を使う。`PatternMatcher.expand(pattern, captures)` で照合したディレクトリを確定し、service・stack_id・environment・working_directory が同じ照合の抽出値が矛盾する場合は failure にする。その後に各照合を `excluded?` で判定し、除外されない照合が一つでもあるサービスを一度だけラベル対象にする。 + +全体のサービス除外、理由・種別のログ、旧結果フィールド、controller と presenter の旧引数・内部環境変数・内部出力を削除する。対象を列挙する際に filesystem の存在は要求せず、削除されたファイルの変更パスにも同じ照合を適用する。 + +- [ ] **Step 4: Confirm the dispatcher tests pass** + +Step 2 と同じコマンドを実行する。Expected: exit 0、`0 failures`。除外された照合だけのサービスと、除外されない照合を持つサービスの双方を確認して完了とする。 + +- [ ] **Step 5: Commit changed service detection** + +Files の変更を stage して `git diff --cached --check` を実行する。 + +Run: `git commit -s -m 'feat: apply stack exclusions to changed service matches'` + +## Task 5: Configuration Commands and Examples + +**Files:** + +- Modify: `action-scripts/config-manager/controllers/config_manager_controller.rb` +- Modify: `action-scripts/config-manager/application.rb` +- Modify: `action-scripts/config-manager/bin/config-manager` +- Modify: `action-scripts/shared/interfaces/presenters/console_presenter.rb` +- Modify: `action-scripts/shared/interfaces/presenters/github_actions_presenter.rb` +- Modify: `action-scripts/workflow-config.yaml` +- Modify: `README.md`, `README-ja.md` +- Modify: `action-scripts/config-manager/README.md`, `action-scripts/config-manager/README-ja.md` +- Modify: `action-scripts/label-resolver/README.md`, `action-scripts/label-resolver/README-ja.md` +- Modify: `action-scripts/label-dispatcher/README.md`, `action-scripts/label-dispatcher/README-ja.md` +- Test: `action-scripts/spec/config-manager/controllers/config_manager_controller_spec.rb` +- Test: `action-scripts/spec/shared/interfaces/presenters/console_presenter_spec.rb` +- Test: `action-scripts/spec/shared/interfaces/presenters/github_actions_presenter_spec.rb` +- Create/Test: `action-scripts/spec/config-manager/cli_spec.rb` + +**Interfaces:** + +- Consumes: Task 1 のモデル、Task 2 のディレクトリ解決、Task 3 の `DeploymentTarget`。 +- Produces: `ConfigManagerController.new(validate_config_use_case:, config_client:, file_client:, presenter:)`。 +- Produces: `ConfigManagerController#test_service_configuration(service_name:, environment: nil)` → `present_service_test_result(service_name:, matches:)` を呼ぶ。環境省略時は全定義環境と環境共通を診断する。 +- Produces: `matches` → `Array`。各要素は `{ target: Entities::DeploymentTarget, excluded: Boolean }`。除外対象も状態を表示し、登録サービスを要求しない。 +- Preserves: `#validate_configuration`・`#show_configuration`・`#run_diagnostics`・`#generate_config_template`、presenter の `#present_config_details(config:)` と `#present_config_template(template:)`。 +- Produces: `ConfigManagerCLI#test(service_name, environment = nil)` と `#environments`。`services` と `excluded_services` のコマンドは削除する。 + +- [ ] **Step 1: Write configuration display and diagnostic tests** + +`show_configuration` がそのまま検証済みモデルを presenter へ渡すこと、両 presenter が stack_id ごとに全パス・各環境属性または共通属性・除外条件を表示することをテストする。非 AWS 属性 `repository` と `token` が表示されることを assertion にする。 + +サービス診断は一時リポジトリの demo を使い、登録なしで2プロダクトの各ディレクトリを表示すること、環境省略時に共通 stack を一度だけ表示すること、指定した環境の属性を保持すること、除外対象に `excluded: true` が付くこと、空の一致が空配列になることをテストする。識別4要素の重複は一行、異なる captures は error にする。 + +```ruby +it 'generates a template accepted by the configuration model' do + template = controller.send(:build_config_template) + config = Entities::WorkflowConfig.new(YAML.safe_load(template)) + + expect(config.stacks.map { |stack| stack['id'] }).to include('aws', 'container') + expect(config.environment_names).to include('develop', 'production') +end +``` + +CLI の spec は require 時の自動起動がないこと、environments が和集合を表示すること、削除した2コマンドが Thor の登録コマンドにないことを確認する。テンプレートとリポジトリの sample YAML が同じモデルで検証できるテストを追加する。 + +- [ ] **Step 2: Confirm the configuration command tests fail** + +Run: `bundle exec rspec spec/config-manager spec/shared/interfaces/presenters` + +Expected: 新しい診断引数、汎用属性の表示、旧コマンドの削除、新形式のテンプレートで FAIL。 + +- [ ] **Step 3: Implement configuration commands and update their documentation** + +Config manager にファイルクライアントを注入する。診断は stack ごとに環境を選択し、各パスを Task 2 のメソッドで解決する。属性・capture を持つ `DeploymentTarget` と Task 1 の除外判定を組にして presenter へ渡す。未知の環境・設定・列挙・capture の矛盾は `present_error` へ伝える。 + +設定表示は属性名を列挙し、特定のプロバイダーのキーを前提にしない。CLI の environments は `environment_names` を表示する。bin の末尾を `$PROGRAM_NAME == __FILE__` で guard し、旧サービス一覧と旧除外一覧を削除する。テンプレートは stacks だけの新スキーマを生成し、YAML に説明コメントを付けない。 + +sample と README の設定例・matrix 出力・除外説明・コマンド例を変更する。設定スキーマと matrix の説明は root README を参照先とし、各 component README には担当するコマンドとその結果を記載する。本文を同じ媒体間で複製せず、新しい見出しを英語・新しい本文を日本語にする。現在の仕様として示す旧形式の例、旧コマンド、旧 root 出力を残さない。履歴文書は変更しない。 + +- [ ] **Step 4: Confirm configuration command tests pass** + +Run: `bundle exec rspec spec/config-manager spec/shared/interfaces/presenters` + +Expected: exit 0、`0 failures`。 + +- [ ] **Step 5: Confirm the complete suite passes** + +Run: `bundle exec rspec` + +Expected: exit 0、`0 failures`。 + +- [ ] **Step 6: Validate the sample configuration through the CLI** + +Run: `bundle exec ruby config-manager/bin/config-manager validate` + +Expected: exit 0、`Configuration is valid` と新モデルの stack・環境数のサマリー。 + +- [ ] **Step 7: Confirm stack configuration display through the CLI** + +Run: `bundle exec ruby config-manager/bin/config-manager show` + +Expected: exit 0、各 stack のパス・属性・除外条件を表示する。 + +- [ ] **Step 8: Review removal scope and added comments** + +リポジトリルートで次を実行し、旧モデルに依存する製品コード・fixture・現在の設定例が検索結果に残っていないことを確認する。旧形式を拒否するテスト入力は残してよい。`rg` の exit 1 は該当なしを表す。 + +Run: `rg -n 'stack_conventions|stack_convention_root|directory_stacks|required_attributes|exclude_from_automation|exclusion_config|excluded_services|EXCLUDED_SERVICES|HAS_EXCLUDED_SERVICES|environment_configs|all_directory_patterns|stack_attributes_for' action-scripts label-dispatcher label-resolver README.md README-ja.md` + +`git diff` に現れた追加・変更コメントをすべて AGENTS.md の Content Rules と照合し、複数行・自明な説明・タスクへの言及を削除する。Files 外の変更がないことを確認し、`git diff --check` が exit 0 であることを完了条件にする。 + +- [ ] **Step 9: Review the implementation** + +`superpowers:requesting-code-review` を適用し、計画と設計書の要件、除外判定、全利用側の旧モデル削除をレビューする。指摘を解消し、変更した箇所に必要な検証を再実行した時点で完了とする。 + +- [ ] **Step 10: Commit the configuration commands and examples** + +Files の変更を指定して stage し、`git diff --cached --check` を実行する。 + +Run: `git commit -s -m 'feat: expose stack configuration through config commands'` + +- [ ] **Step 11: Publish the branch as a Draft PR** + +`superpowers:verification-before-completion` を適用して、コミット内容と検証結果を確認する。新規ブランチなら `git push -u origin HEAD`、追跡済みなら `git push` を実行する。既存 Draft PR を継続する場合は設計と実装をレビューできる説明へ更新し、新規 PR なら `gh pr create --draft` を使う。PR が Draft、タイトルが英語、変更ファイルと検証結果が確認できる時点で完了とする。 + +## Coverage Map + +| Spec verification criteria | Tasks | +|---|---| +| 1–2: プロダクト間の共有と独立 | 1, 3, 5 | +| 3–5: 定義環境、共通対象、環境名がないパス | 1, 3, 4, 5 | +| 6: 複数一致と重複 | 2, 3, 5 | +| 7–9: 固定・任意キーと環境共通の除外 | 1, 3, 4, 5 | +| 10: 除外後の変更検出 | 4 | +| 11: deploy:all のディレクトリ探索 | 2, 3 | +| 12–13: 検証と旧形式の拒否 | 1, 3, 5 | +| 14: matrix 固定キー、属性、capture | 1, 3 | +| 15: Config manager とテンプレート | 1, 5 | From c8b07903e7a9ce6bc6ab035fb4a30b33da0b6d7e Mon Sep 17 00:00:00 2001 From: panicboat Date: Fri, 9 Oct 2026 09:45:22 +0900 Subject: [PATCH 05/13] feat: define stack configuration and exclusion conditions Signed-off-by: panicboat --- .../use_cases/validate_config.rb | 378 +--------- .../shared/entities/workflow_config.rb | 290 +++----- .../shared/infrastructure/config_client.rb | 140 +--- .../use_cases/validate_config_spec.rb | 555 +-------------- action-scripts/spec/factories.rb | 77 +- .../shared/entities/workflow_config_spec.rb | 655 +++++------------- .../infrastructure/config_client_spec.rb | 207 ++---- action-scripts/spec/spec_helper.rb | 44 +- docs/superpowers/plans/stack-configuration.md | 16 +- 9 files changed, 394 insertions(+), 1968 deletions(-) diff --git a/action-scripts/config-manager/use_cases/validate_config.rb b/action-scripts/config-manager/use_cases/validate_config.rb index 4e5b175..f6ff5a7 100644 --- a/action-scripts/config-manager/use_cases/validate_config.rb +++ b/action-scripts/config-manager/use_cases/validate_config.rb @@ -1,6 +1,3 @@ -# Use case for validating workflow configuration -# Comprehensive validation of YAML configuration structure and content - module UseCases module ConfigManagement class ValidateConfig @@ -8,375 +5,16 @@ def initialize(config_client:) @config_client = config_client end - # Execute configuration validation def execute - begin - config = @config_client.load_workflow_config - validation_errors = [] - - # Perform comprehensive validation - validation_errors.concat(validate_environments(config)) - validation_errors.concat(validate_services(config)) - validation_errors.concat(validate_stack_conventions(config)) - validation_errors.concat(validate_service_exclusions(config)) - - if validation_errors.any? - Entities::Result.failure( - error_message: "Configuration validation failed with #{validation_errors.length} errors", - validation_errors: validation_errors - ) - else - Entities::Result.success( - valid: true, - config: config, - validation_summary: generate_validation_summary(config) - ) - end - rescue => error - Entities::Result.failure( - error_message: "Failed to load or validate configuration: #{error.message}", - validation_errors: [error.message] - ) - end - end - - private - - # Validate environments configuration - def validate_environments(config) - errors = [] - environments = config.environments - - if environments.empty? - errors << "No environments defined" - return errors - end - - environments.each do |env_name, env_config| - errors.concat(validate_environment_config(env_name, env_config, config)) - end - - errors - end - - # Validate individual environment configuration - def validate_environment_config(env_name, env_config, config) - errors = [] - - # Validate stacks structure if present - if env_config.key?('stacks') - stacks = env_config['stacks'] - unless stacks.is_a?(Hash) - errors << "Environment '#{env_name}' 'stacks' must be a Hash" - return errors - end - end - - # Check required_attributes for each stack declared in stack_conventions - config.stack_conventions_config.each do |convention| - (convention['stacks'] || []).each do |stack_def| - stack_name = stack_def['name'] - required = stack_def['required_attributes'] || [] - next if required.empty? - - # Skip when the environment does not declare this stack — it opts out of the stack - stack_attrs = env_config.dig('stacks', stack_name) - next if stack_attrs.nil? - - required.each do |attr| - unless stack_attrs.key?(attr) - errors << "Environment '#{env_name}' missing required attribute for stack '#{stack_name}': #{attr}" - end - end - end - end - - errors - end - - # Validate services configuration - def validate_services(config) - errors = [] - services = config.services - - services.each do |service_name, service_config| - if service_name.start_with?('.') - errors << "Service name cannot start with dot: #{service_name}" - end - - if service_config['stack_conventions'] - service_config['stack_conventions'].each do |stack, pattern| - unless pattern.include?('{service}') - errors << "Service '#{service_name}' directory convention for '#{stack}' must include {service} placeholder" - end - end - end - end - - errors - end - - # Validate directory conventions - def validate_stack_conventions(config) - errors = [] - conventions = config.stack_conventions - - if conventions.empty? - errors << "No directory conventions defined" - return errors - end - - dynamic_reserved = collect_attribute_keys(config) - - # Validate each directory convention - conventions.each_with_index do |convention, conv_index| - unless convention.is_a?(Hash) - errors << "Directory convention at index #{conv_index} must be a Hash" - next - end - - # Validate root pattern (can be empty string) - unless convention.key?('root') - errors << "Directory convention at index #{conv_index} missing required 'root' field" - end - - # Only validate {service} placeholder if root is not empty - if convention['root'] && !convention['root'].empty? && !convention['root'].include?('{service}') - errors << "Directory convention at index #{conv_index} root must include {service} placeholder" - end - - # Validate stacks - stacks = convention['stacks'] - unless stacks.is_a?(Array) - errors << "Directory convention at index #{conv_index} 'stacks' must be an Array" - next - end - - if stacks.empty? - errors << "Directory convention at index #{conv_index} 'stacks' cannot be empty" - next - end - - # Validate each stack - stacks.each_with_index do |stack, index| - unless stack['name'] - errors << "Stack at index #{index} in convention #{conv_index} missing required 'name' field" - end - - unless stack['directory'] - errors << "Stack at index #{index} in convention #{conv_index} missing required 'directory' field" - end - - errors.concat(validate_placeholder_names(convention, stack, conv_index, dynamic_reserved)) - end - end - - errors.concat(validate_structural_equivalence(conventions)) - - errors - end - - - # Placeholder names matching DeploymentTarget fixed field names would shadow - # those fields in the matrix output Hash, so reject them at validate time. - # {service} and {environment} are intentionally excluded: they are required / - # allowed parts of the pattern grammar and map to dedicated keyword args on - # DeploymentTarget rather than into the captures Hash. - FIXED_RESERVED_PLACEHOLDERS = %w[stack working_directory stack_convention_root].freeze - - # Matches any literal that looks like a placeholder slot: '{...}' where - # ... is one or more characters that are not braces. Combined with - # PatternMatcher.placeholders this lets us find {...} forms that don't - # match the placeholder grammar (e.g. {Team}, {my-var}, {a b}). - INVALID_PLACEHOLDER_LITERAL = /\{[^{}]*\}/ - def validate_placeholder_names(convention, stack, conv_index, dynamic_reserved) - errors = [] - root_pattern = convention['root'] || '' - dir_pattern = stack['directory'] || '' - - placeholders = Entities::PatternMatcher.placeholders(root_pattern) + - Entities::PatternMatcher.placeholders(dir_pattern) - - placeholders.uniq.each do |name| - if FIXED_RESERVED_PLACEHOLDERS.include?(name) - errors << "Convention #{conv_index} stack '#{stack['name']}' uses reserved placeholder name '{#{name}}'" - end - if dynamic_reserved.include?(name) - errors << "Convention #{conv_index} stack '#{stack['name']}' placeholder '{#{name}}' collides with environments attribute key '#{name}'" - end - end - - all_braced = [root_pattern, dir_pattern].flat_map { |p| p.to_s.scan(INVALID_PLACEHOLDER_LITERAL) } - valid_braced = placeholders.map { |n| "{#{n}}" } - invalid = all_braced - valid_braced - invalid.uniq.each do |literal| - errors << "Convention #{conv_index} stack '#{stack['name']}' contains invalid placeholder literal '#{literal}'" - end - - errors - end - - # Collect every key used under environments[].stacks[] across all - # environments. These keys end up as top-level keys in matrix output via - # DeploymentTarget#to_matrix_item. - def collect_attribute_keys(config) - keys = [] - config.environments.each_value do |env_config| - stacks = env_config['stacks'] - next unless stacks.is_a?(Hash) - stacks.each_value do |attrs| - next unless attrs.is_a?(Hash) - keys.concat(attrs.keys.map(&:to_s)) - end - end - keys.uniq - end - - # Detect conventions that share a stack name and have the same placeholder - # *positions* but use different placeholder *names*. If consumers point - # both at the same on-disk path, the matrix output's captures key name - # becomes YAML-order dependent — reject this at validate time. - def validate_structural_equivalence(conventions) - errors = [] - - full_patterns = [] - conventions.each_with_index do |convention, conv_index| - (convention['stacks'] || []).each do |stack| - name = stack['name'] - next if name.nil? - root = convention['root'] || '' - dir = stack['directory'] || '' - joined = root.empty? ? dir : "#{root}/#{dir}" - full_patterns << { - stack: name, - pattern: joined, - conv_index: conv_index - } - end - end - - full_patterns.group_by { |p| p[:stack] }.each_value do |group| - next if group.length < 2 - - by_signature = group.group_by { |p| structural_signature(p[:pattern]) } - by_signature.each_value do |patterns| - next if patterns.length < 2 - - name_lists = patterns.map { |p| Entities::PatternMatcher.placeholders(p[:pattern]) } - next if name_lists.uniq.length == 1 - - entries = patterns.zip(name_lists).map do |p, names| - "convention #{p[:conv_index]}=#{names.inspect}" - end - errors << "Stack '#{patterns.first[:stack]}' has conventions sharing the same placeholder structure but using different names: #{entries.join(', ')}" - end - end - - errors - end - - # Return a structural signature for a pattern by replacing each placeholder - # with a positional marker {X0}, {X1}, ... in occurrence order. Two - # patterns share a signature iff they have the same literal text and the - # same placeholder positions, regardless of placeholder names. - def structural_signature(pattern) - index = 0 - pattern.gsub(Entities::PatternMatcher::PLACEHOLDER_REGEX) do - token = "{X#{index}}" - index += 1 - token - end - end - - # Validate service exclusion configuration - def validate_service_exclusions(config) - errors = [] - services = config.services - - services.each do |service_name, service_config| - if service_config['exclude_from_automation'] - errors.concat(validate_service_exclusion_config(service_name, service_config)) - end - end - - errors - end - - # Validate individual service exclusion configuration - def validate_service_exclusion_config(service_name, service_config) - errors = [] - - # Check if exclusion is boolean true or valid object - exclusion_setting = service_config['exclude_from_automation'] - unless [true, false].include?(exclusion_setting) - errors << "Service '#{service_name}' exclude_from_automation must be boolean (true/false)" - return errors - end - - # If excluded, validate exclusion_config - if exclusion_setting == true - exclusion_config = service_config['exclusion_config'] - - # exclusion_config is required when excluded - if exclusion_config.nil? - errors << "Service '#{service_name}' excluded from automation but missing exclusion_config" - return errors - end - - # Validate required fields in exclusion_config - unless exclusion_config['reason'] - errors << "Service '#{service_name}' exclusion_config missing required field: reason" - end - - # Validate exclusion type if provided - if exclusion_config['type'] - valid_types = %w[permanent temporary conditional] - unless valid_types.include?(exclusion_config['type']) - errors << "Service '#{service_name}' exclusion_config type must be one of: #{valid_types.join(', ')}" - end - end - - # Check reason length (should be descriptive) - if exclusion_config['reason'] && exclusion_config['reason'].length < 10 - errors << "Service '#{service_name}' exclusion_config reason should be more descriptive (at least 10 characters)" - end - - unless service_config['stack_conventions'] - puts "INFO: Service '#{service_name}' is excluded and has no stack_conventions defined" - end - end - - errors - end - - # Generate validation summary including exclusion statistics - def generate_validation_summary(config) - excluded_services = config.services.select { |_, service_config| - service_config['exclude_from_automation'] == true - } - - excluded_by_type = excluded_services.group_by { |_, service_config| - service_config.dig('exclusion_config', 'type') || 'unspecified' - } - - # Count total stacks across all conventions - total_stacks = config.stack_conventions_config.sum { |conv| conv['stacks']&.length || 0 } - - summary_data = { - environments_count: config.environments.length, - services_count: config.services.length, - excluded_services_count: excluded_services.length, - excluded_services_by_type: excluded_by_type.transform_values(&:length), - directory_stacks_count: total_stacks, - } - - [ - "✅ Configuration validation successful", - "📋 Summary:", - " - environments: #{summary_data[:environments_count]} configured", - " - services: #{summary_data[:services_count]} configured (#{summary_data[:excluded_services_count]} excluded)", - " - directory stacks: #{summary_data[:directory_stacks_count]} configured", + config = @config_client.load_workflow_config + summary = [ + "Configuration validation successful", + "stacks: #{config.stacks.length} configured", + "environments: #{config.environment_names.length} configured" ].join("\n") + Entities::Result.success(valid: true, config: config, validation_summary: summary) + rescue StandardError => error + Entities::Result.failure(error_message: error.message, validation_errors: [error.message]) end end end diff --git a/action-scripts/shared/entities/workflow_config.rb b/action-scripts/shared/entities/workflow_config.rb index 00885ad..fe43713 100644 --- a/action-scripts/shared/entities/workflow_config.rb +++ b/action-scripts/shared/entities/workflow_config.rb @@ -1,218 +1,148 @@ -# Workflow configuration entity representing the parsed YAML configuration -# Provides access to environments, services, and directory conventions - module Entities class WorkflowConfig - attr_reader :raw_config - - def initialize(config_hash) - @raw_config = config_hash - validate! - end - - # Get environment configuration (no defaults merging) - def environment_config(env_name) - environments[env_name] - end + STACK_FIELDS = %w[name id paths environments attributes exclude].freeze + MATRIX_KEYS = %w[service environment stack stack_id working_directory].freeze - # Get stack-specific attribute hash for an environment+stack pair. - # Resolves by identity (id || name), falling back to the stack's name so - # configs written before ids existed keep working. Returns {} rather than - # nil for an unresolvable key: DeploymentTarget.new(attributes:) cannot - # take nil. - def stack_attributes_for(env_name, stack_key) - env = environments[env_name] - return {} unless env - return {} unless stack_conventions_config.any? { |c| (c['stacks'] || []).any? { |s| (s['id'] || s['name']) == stack_key } } - by_id = env.dig('stacks', stack_key) - return by_id if by_id - name = stack_name_for(stack_key) - return {} unless name - env.dig('stacks', name) || {} - end - - # Get required attribute keys declared for a stack in stack_conventions - def required_attributes_for(stack_key) - stack_conventions_config.each do |convention| - stack = (convention['stacks'] || []).find { |s| (s['id'] || s['name']) == stack_key } - next unless stack - return stack['required_attributes'] || [] - end - [] - end + attr_reader :stacks, :environment_names - # Get directory conventions for a service and stack with hierarchical structure - def stack_conventions_for(service_name, stack) - service_config = services[service_name] - if service_config && service_config['stack_conventions'] - # If service has stack_conventions, only return service-specific pattern if it exists - if service_config['stack_conventions'][stack] - return [service_config['stack_conventions'][stack]] + def initialize(config_hash) + validate_map!(config_hash, 'configuration', allowed: ['stacks']) + validate_array!(config_hash['stacks'], 'stacks') + identities = [] + @stacks = config_hash['stacks'].each_with_index.map do |stack, index| + position = "stacks[#{index}]" + validate_stack!(stack, position) + identity = stack.fetch('id', stack['name']) + invalid!("#{position}.id", "duplicate identity '#{identity}'") if identities.include?(identity) + identities << identity + normalized = stack.merge( + 'id' => identity, + 'paths' => stack['paths'].map { |pattern| pattern.split('/').reject { |part| part.empty? || part == '.' }.join('/') }, + 'exclude' => stack.fetch('exclude', []).map(&:dup) + ) + if stack.key?('environments') + normalized['environments'] = stack['environments'].transform_values(&:dup) else - # Service has stack_conventions but not for this stack - return [] + normalized['attributes'] = stack.fetch('attributes', {}).dup end + normalized end + @environment_names = stacks.flat_map { |stack| stack.fetch('environments', {}).keys }.uniq + end - # Use hierarchical structure: root + stack directory - patterns = [] - stack_conventions_config.each do |convention| - root_pattern = convention['root'] - stack_config = convention['stacks']&.find { |s| (s['id'] || s['name']) == stack } - next unless stack_config - - # Handle empty root pattern - if root_pattern.nil? || root_pattern.empty? - patterns << stack_config['directory'] - else - patterns << "#{root_pattern}/#{stack_config['directory']}" - end + def excluded?(stack, values) + stack['exclude'].any? do |rule| + rule.all? { |key, expected| values.key?(key) && values[key] == expected } end - patterns end - # Get directory convention for a service and stack (returns first match) - def stack_convention_for(service_name, stack) - conventions = stack_conventions_for(service_name, stack) - conventions.first - end + private - # Get all environments as a hash - def environments - @environments ||= (raw_config['environments'] || []).each_with_object({}) do |env, hash| - hash[env['environment']] = env + def validate_stack!(stack, position) + validate_map!(stack, position, allowed: STACK_FIELDS) + validate_string!(stack['name'], "#{position}.name") + validate_string!(stack['id'], "#{position}.id") if stack.key?('id') + validate_array!(stack['paths'], "#{position}.paths") + if stack.key?('environments') && stack.key?('attributes') + invalid!(position, 'attributes and environments cannot be combined') end - end - # Get all services as a hash - def services - @services ||= (raw_config['services'] || []).each_with_object({}) do |service, hash| - hash[service['name']] = service + attribute_keys = [] + if stack.key?('environments') + environments = stack['environments'] + validate_map!(environments, "#{position}.environments") + invalid!("#{position}.environments", 'must not be empty') if environments.empty? + environments.each do |name, attributes| + validate_segment!(name, "#{position}.environments.#{name}") + validate_attributes!(attributes, "#{position}.environments.#{name}") + attribute_keys.concat(attributes.keys) + end + else + attributes = stack.fetch('attributes', {}) + validate_attributes!(attributes, "#{position}.attributes") + attribute_keys.concat(attributes.keys) end - end - - - # Check if safety check is enabled - def safety_check_enabled?(check_name) - false - end - # Get list of services excluded from automation - def excluded_services - @excluded_services ||= services.select { |_, service| - service['exclude_from_automation'] == true - }.keys - end - - - # Get directory conventions (for backward compatibility) - def stack_conventions - stack_conventions_config - end - - # Get directory conventions root patterns - def stack_convention_roots - stack_conventions_config.map { |conv| conv['root'] }.compact - end - - # Get directory conventions root pattern (returns first pattern) - def stack_convention_root - stack_convention_roots.first - end - - # Validate configuration structure - def validate! - errors = [] - - errors << "Missing required section: environments" unless raw_config['environments'] - errors << "Missing required section: stack_conventions" unless raw_config['stack_conventions'] - - if raw_config['environments'] - raw_config['environments'].each_with_index do |env, index| - unless env['environment'] - errors << "Environment at index #{index} missing required field: environment" - end + placeholders = [] + stack['paths'].each_with_index do |pattern, index| + path_position = "#{position}.paths[#{index}]" + validate_string!(pattern, path_position) + invalid!(path_position, 'must be repository-relative') if pattern.start_with?('/') + invalid!(path_position, 'must not contain parent-directory segments') if pattern.split('/').include?('..') + names = PatternMatcher.placeholders(pattern) + remaining = pattern.gsub(PatternMatcher::PLACEHOLDER_REGEX, '') + invalid!(path_position, 'invalid placeholder syntax') if remaining.include?('{') || remaining.include?('}') + invalid!(path_position, 'must include {service}') unless names.include?('service') + if !stack.key?('environments') && names.include?('environment') + invalid!(path_position, '{environment} requires environments') end + custom_names = names - %w[service environment] + collision = custom_names.find { |name| MATRIX_KEYS.include?(name) || attribute_keys.include?(name) } + invalid!(path_position, "placeholder '#{collision}' collides with a matrix or attribute key") if collision + placeholders.concat(names) end - if raw_config['stack_conventions'] - unless raw_config['stack_conventions'].is_a?(Array) - errors << "stack_conventions must be an array" - else - raw_config['stack_conventions'].each_with_index do |conv, index| - unless conv['root'] - errors << "stack_conventions[#{index}] missing required field: root" - end - unless conv['stacks'] - errors << "stack_conventions[#{index}] missing required field: stacks" - end - seen = {} - (conv['stacks'] || []).each do |stack| - identity = stack['id'] || stack['name'] - if identity && seen.key?(identity) - errors << "stack_conventions[#{index}].stacks has duplicate identity '#{identity}' (entries with the same 'name' need distinct 'id' values)" - break + validate_exclusions!(stack, position, placeholders) + end + + def validate_exclusions!(stack, position, placeholders) + rules = stack.fetch('exclude', []) + validate_array!(rules, "#{position}.exclude", allow_empty: true) + allowed = (%w[service environment] + placeholders).uniq + rules.each_with_index do |rule, index| + rule_position = "#{position}.exclude[#{index}]" + validate_map!(rule, rule_position, allowed: allowed) + invalid!(rule_position, 'must contain at least one condition') if rule.empty? + rule.each do |key, value| + condition_position = "#{rule_position}.#{key}" + if key == 'environment' + if stack.key?('environments') + validate_segment!(value, condition_position) + unless stack['environments'].key?(value) + invalid!(condition_position, "environment '#{value}' is not declared in this stack") end - seen[identity] = true if identity + elsif !value.nil? + invalid!(condition_position, 'common targets require a null environment') end + else + validate_segment!(value, condition_position) + invalid!(condition_position, 'service must not start with a dot') if key == 'service' && value.start_with?('.') end end end + end - raise StandardError, "Configuration validation failed: #{errors.join(', ')}" unless errors.empty? + def validate_attributes!(attributes, position) + validate_map!(attributes, position) + attributes.each_key do |key| + invalid!("#{position}.#{key}", 'collides with a matrix key') if MATRIX_KEYS.include?(key) + end end - def stack_name_for(stack_key) - stack_conventions_config.each do |convention| - (convention['stacks'] || []).each do |stack| - return stack['name'] if (stack['id'] || stack['name']) == stack_key - end + def validate_map!(value, position, allowed: nil) + invalid!(position, 'must be a map') unless value.is_a?(Hash) + value.each_key do |key| + validate_string!(key, "#{position}.keys") + invalid!("#{position}.#{key}", 'unknown field') if allowed && !allowed.include?(key) end - nil end - # Get directory conventions configuration - def stack_conventions_config - @stack_conventions_config ||= raw_config['stack_conventions'] || [] + def validate_array!(value, position, allow_empty: false) + invalid!(position, 'must be an array') unless value.is_a?(Array) + invalid!(position, 'must not be empty') if !allow_empty && value.empty? end - # Get all possible directory patterns for service discovery - def all_directory_patterns - patterns = [] - - stack_conventions_config.each do |convention| - root_pattern = convention['root'] - stacks = convention['stacks'] || [] - - # Add root pattern for detecting any change within service directory - if root_pattern && root_pattern.include?('{service}') - patterns << root_pattern - end - - stacks.each do |stack_config| - stack_directory = stack_config['directory'] - next unless stack_directory - - # Build full pattern using root + stack directory - full_pattern = if root_pattern.nil? || root_pattern.empty? - stack_directory - else - "#{root_pattern}/#{stack_directory}" - end - - # Only include patterns that contain {service} - patterns << full_pattern if full_pattern.include?('{service}') - end - end - - patterns.uniq + def validate_string!(value, position) + invalid!(position, 'must be a non-empty string') unless value.is_a?(String) && !value.empty? end - private + def validate_segment!(value, position) + validate_string!(value, position) + invalid!(position, 'must be a single path segment') if value.include?('/') || %w[. ..].include?(value) + end - # Get directory stacks configuration - def directory_stacks - @directory_stacks ||= stack_conventions_config.first&.fetch('stacks', []) || [] + def invalid!(position, reason) + raise ArgumentError, "Configuration validation failed: #{position}: #{reason}" end end end diff --git a/action-scripts/shared/infrastructure/config_client.rb b/action-scripts/shared/infrastructure/config_client.rb index 6c61c52..c8ff62c 100644 --- a/action-scripts/shared/infrastructure/config_client.rb +++ b/action-scripts/shared/infrastructure/config_client.rb @@ -1,6 +1,3 @@ -# Configuration client for loading and parsing YAML configuration files -# Handles workflow configuration loading with validation - require 'yaml' module Infrastructure @@ -10,141 +7,22 @@ def initialize(config_path: nil) @config_cache = nil end - # Load workflow configuration from YAML file def load_workflow_config return @config_cache if @config_cache - unless File.exist?(@config_path) - raise "Configuration file not found: #{@config_path}" - end - - begin - content = File.read(@config_path) - config_data = YAML.safe_load(content) - validate_config!(config_data) - @config_cache = Entities::WorkflowConfig.new(config_data) - rescue Errno::EACCES - raise "Permission denied accessing configuration file: #{@config_path}" - rescue Psych::SyntaxError => e - raise "YAML parsing error at line #{e.line}: #{e.message}" - rescue => error - raise "Failed to load configuration from #{@config_path}: #{error.message}" - end - end - - # Validate configuration file and return result - def validate_config_file - begin - config = load_workflow_config - validation_summary = build_validation_summary(config) - - Entities::Result.success( - config: config, - validation_summary: validation_summary - ) - rescue => error - if error.message.include?("Configuration validation failed:") - validation_errors = error.message.sub("Configuration validation failed: ", "").split(", ") - Entities::Result.failure( - error_message: error.message, - validation_errors: validation_errors - ) - else - Entities::Result.failure(error_message: error.message) - end - end + @config_cache = Entities::WorkflowConfig.new(YAML.safe_load(File.read(@config_path))) + rescue Errno::ENOENT + raise "Configuration file not found: #{@config_path}" + rescue Errno::EACCES + raise "Permission denied accessing configuration file: #{@config_path}" + rescue Psych::SyntaxError => error + raise "YAML parsing error at line #{error.line}: #{error.message}" + rescue StandardError => error + raise "Failed to load configuration from #{@config_path}: #{error.message}" end - # Clear configuration cache (useful for testing) def clear_cache @config_cache = nil end - - private - - # Get the configuration file path - def config_file_path - @config_path - end - - # Build validation summary for successful validation - def build_validation_summary(config) - env_count = config.environments.length - service_count = config.services.length - excluded_count = config.excluded_services.length - stack_count = config.send(:directory_stacks).length - - [ - "✅ Configuration validation successful", - "📋 Summary:", - " - environments: #{env_count} configured", - " - services: #{service_count} configured (#{excluded_count} excluded)", - " - directory stacks: #{stack_count} configured", - ].join("\n") - end - - # Validate the configuration structure - def validate_config!(config_data) - raise "Configuration must be a Hash" unless config_data.is_a?(Hash) - - # Validate required sections - required_sections = %w[environments stack_conventions] - missing_sections = required_sections - config_data.keys - if missing_sections.any? - raise "Missing required configuration sections: #{missing_sections.join(', ')}" - end - - # Validate environments section - environments = config_data['environments'] - raise "environments must be an Array" unless environments.is_a?(Array) - - environments.each_with_index do |env, index| - raise "Environment #{index} must have 'environment' key" unless env['environment'] - - if env.key?('stacks') - stacks = env['stacks'] - raise "Environment #{index} 'stacks' must be a Hash" unless stacks.is_a?(Hash) - stacks.each do |stack_name, stack_attrs| - unless stack_attrs.nil? || stack_attrs.is_a?(Hash) - raise "Environment #{index} stack '#{stack_name}' must be a Hash or null" - end - end - end - end - - # Validate stack_conventions structure - conventions = config_data['stack_conventions'] - raise "stack_conventions must be an Array" unless conventions.is_a?(Array) - - conventions.each_with_index do |convention, conv_index| - raise "stack_conventions[#{conv_index}] must have 'root' key" unless convention.key?('root') - raise "stack_conventions[#{conv_index}] must have 'stacks' key" unless convention['stacks'] - - stacks = convention['stacks'] - raise "stack_conventions[#{conv_index}].stacks must be an Array" unless stacks.is_a?(Array) - - stacks.each_with_index do |stack, stack_index| - raise "stack_conventions[#{conv_index}].stacks[#{stack_index}] must have 'name' key" unless stack['name'] - raise "stack_conventions[#{conv_index}].stacks[#{stack_index}] must have 'directory' key" unless stack['directory'] - - if stack.key?('required_attributes') - req = stack['required_attributes'] - unless req.is_a?(Array) && req.all? { |k| k.is_a?(String) } - raise "stack_conventions[#{conv_index}].stacks[#{stack_index}].required_attributes must be an Array of String" - end - end - end - end - - # Validate services section if present - if config_data['services'] - services = config_data['services'] - raise "services must be an Array" unless services.is_a?(Array) - - services.each_with_index do |service, index| - raise "Service #{index} must have 'name' key" unless service['name'] - end - end - end end end diff --git a/action-scripts/spec/config-manager/use_cases/validate_config_spec.rb b/action-scripts/spec/config-manager/use_cases/validate_config_spec.rb index 112e47a..640450f 100644 --- a/action-scripts/spec/config-manager/use_cases/validate_config_spec.rb +++ b/action-scripts/spec/config-manager/use_cases/validate_config_spec.rb @@ -1,529 +1,42 @@ -# spec/config-manager/use_cases/validate_config_spec.rb - require 'spec_helper' +require 'tempfile' RSpec.describe UseCases::ConfigManagement::ValidateConfig do - let(:config_client) { double('ConfigClient') } - subject(:use_case) { described_class.new(config_client: config_client) } - - describe '#execute' do - context 'with valid configuration' do - let(:config) { build(:workflow_config) } - - before do - allow(config_client).to receive(:load_workflow_config).and_return(config) - end - - it 'returns successful result with config and summary' do - result = use_case.execute - - expect(result).to be_success - expect(result.config).to eq(config) - expect(result.validation_summary).to include('Configuration validation successful') - end - end - - context 'with invalid configuration' do - let(:config) do - Entities::WorkflowConfig.new({ - 'environments' => [], - 'services' => [], - 'stack_conventions' => [ - { - 'root' => '{service}', - 'stacks' => [] - } - ] - }) - end - - before do - allow(config_client).to receive(:load_workflow_config).and_return(config) - end - - it 'returns failure result with validation errors' do - result = use_case.execute - - expect(result).to be_failure - expect(result.validation_errors).to include('No environments defined') - expect(result.error_message).to include('Configuration validation failed') - end - end - - context 'with configuration file not found' do - before do - allow(config_client).to receive(:load_workflow_config).and_raise(StandardError.new('Configuration file not found')) - end - - it 'returns failure result with file error' do - result = use_case.execute - - expect(result).to be_failure - expect(result.error_message).to eq('Failed to load or validate configuration: Configuration file not found') - end - end - - context 'with config client exception' do - let(:error) { StandardError.new('Unexpected error during validation') } - - before do - allow(config_client).to receive(:load_workflow_config).and_raise(error) - end - - it 'handles exceptions and returns failure result' do - result = use_case.execute - - expect(result).to be_failure - expect(result.error_message).to include('Failed to load or validate configuration: Unexpected error during validation') - end - end - - context 'with comprehensive validation checks' do - let(:config) { build(:workflow_config, :with_excluded_service) } - - before do - allow(config_client).to receive(:load_workflow_config).and_return(config) - end - - it 'includes detailed validation information' do - result = use_case.execute - - expect(result).to be_success - expect(result.validation_summary).to include('environments: 3 configured') - expect(result.validation_summary).to include('services: 2 configured') - expect(result.validation_summary).to include('excluded') - end - end - - context 'with empty configuration' do - before do - allow(config_client).to receive(:load_workflow_config).and_raise( - StandardError.new('Configuration validation failed: Missing required configuration sections: stack_conventions') - ) - end - - it 'reports all missing sections' do - result = use_case.execute - - expect(result).to be_failure - expect(result.error_message).to include('Configuration validation failed') - expect(result.error_message).to include('stack_conventions') - end - end - - context 'when required_attributes are not satisfied' do - let(:config_hash) do - { - 'environments' => [ - { - 'environment' => 'develop', - 'stacks' => { - 'terragrunt' => { - 'aws_region' => 'ap-northeast-1' - # iam_role_plan / iam_role_apply missing - } - } - } - ], - 'stack_conventions' => [ - { - 'root' => '{service}', - 'stacks' => [ - { - 'name' => 'terragrunt', - 'directory' => 'terragrunt/{environment}', - 'required_attributes' => ['aws_region', 'iam_role_plan', 'iam_role_apply'] - } - ] - } - ], - 'services' => [] - } - end - let(:config) { Entities::WorkflowConfig.new(config_hash) } - - before do - allow(config_client).to receive(:load_workflow_config).and_return(config) - end - - it 'reports missing required attributes' do - result = use_case.execute - - expect(result).to be_failure - expect(result.validation_errors.join(' ')).to include('iam_role_plan') - expect(result.validation_errors.join(' ')).to include('iam_role_apply') - end - end - - context 'when required_attributes is empty array' do - let(:config_hash) do - { - 'environments' => [{ 'environment' => 'develop', 'stacks' => { 'terragrunt' => {} } }], - 'stack_conventions' => [ - { - 'root' => '{service}', - 'stacks' => [ - { - 'name' => 'terragrunt', - 'directory' => 'terragrunt/{environment}', - 'required_attributes' => [] - } - ] - } - ], - 'services' => [] - } - end - let(:config) { Entities::WorkflowConfig.new(config_hash) } - - before do - allow(config_client).to receive(:load_workflow_config).and_return(config) - end - - it 'skips validation' do - result = use_case.execute - expect(result).to be_success - end - end - - context 'when required_attributes is omitted' do - let(:config_hash) do - { - 'environments' => [{ 'environment' => 'develop' }], - 'stack_conventions' => [ - { - 'root' => '{service}', - 'stacks' => [ - { - 'name' => 'kubernetes', - 'directory' => 'kubernetes/overlays/{environment}' - } - ] - } - ], - 'services' => [] - } - end - let(:config) { Entities::WorkflowConfig.new(config_hash) } - - before do - allow(config_client).to receive(:load_workflow_config).and_return(config) - end - - it 'is treated as no required attributes' do - result = use_case.execute - expect(result).to be_success - end - end - - context 'when stack is not declared in environment.stacks' do - let(:config_hash) do - { - 'environments' => [ - { - 'environment' => 'local', - 'stacks' => { - 'kubernetes' => {} - } - } - ], - 'stack_conventions' => [ - { - 'root' => '{service}', - 'stacks' => [ - { - 'name' => 'terragrunt', - 'directory' => 'terragrunt/{environment}', - 'required_attributes' => ['aws_region', 'iam_role_plan', 'iam_role_apply'] - }, - { - 'name' => 'kubernetes', - 'directory' => 'kubernetes/overlays/{environment}' - } - ] - } - ], - 'services' => [] - } - end - let(:config) { Entities::WorkflowConfig.new(config_hash) } - - before do - allow(config_client).to receive(:load_workflow_config).and_return(config) - end - - it 'skips required_attributes validation for the undeclared stack' do - result = use_case.execute - expect(result).to be_success - end - end - - context 'when a stack_conventions placeholder uses a fixed reserved name' do - let(:config) do - Entities::WorkflowConfig.new({ - 'environments' => [ - { - 'environment' => 'develop', - 'stacks' => { - 'terragrunt' => { - 'aws_region' => 'ap-northeast-1', - 'iam_role_plan' => 'arn:aws:iam::1:role/plan', - 'iam_role_apply' => 'arn:aws:iam::1:role/apply' - } - } - } - ], - 'stack_conventions' => [ - { - 'root' => '{service}', - 'stacks' => [ - { - 'name' => 'terragrunt', - 'directory' => 'terragrunt/{stack}', - 'required_attributes' => ['aws_region', 'iam_role_plan', 'iam_role_apply'] - } - ] - } - ], - 'services' => [] - }) - end - - before do - allow(config_client).to receive(:load_workflow_config).and_return(config) - end - - it 'returns a validation error mentioning the reserved name' do - result = use_case.execute - expect(result).to be_failure - expect(result.validation_errors.join("\n")).to match(/reserved.*stack/i) - end - end - - context 'when a stack_conventions placeholder uses an attribute key name' do - let(:config) do - Entities::WorkflowConfig.new({ - 'environments' => [ - { - 'environment' => 'develop', - 'stacks' => { - 'terragrunt' => { - 'aws_region' => 'ap-northeast-1', - 'iam_role_plan' => 'arn:aws:iam::1:role/plan', - 'iam_role_apply' => 'arn:aws:iam::1:role/apply' - } - } - } - ], - 'stack_conventions' => [ - { - 'root' => '{service}/{aws_region}', - 'stacks' => [ - { - 'name' => 'terragrunt', - 'directory' => 'terragrunt/{environment}', - 'required_attributes' => ['aws_region', 'iam_role_plan', 'iam_role_apply'] - } - ] - } - ], - 'services' => [] - }) - end - - before do - allow(config_client).to receive(:load_workflow_config).and_return(config) - end - - it 'returns a validation error mentioning the attribute key collision' do - result = use_case.execute - expect(result).to be_failure - expect(result.validation_errors.join("\n")).to match(/attribute.*aws_region/i) - end - end - - context 'when a stack_conventions pattern contains a syntactically invalid placeholder literal' do - let(:config) do - Entities::WorkflowConfig.new({ - 'environments' => [ - { - 'environment' => 'develop', - 'stacks' => { - 'terragrunt' => { - 'aws_region' => 'ap-northeast-1', - 'iam_role_plan' => 'arn:aws:iam::1:role/plan', - 'iam_role_apply' => 'arn:aws:iam::1:role/apply' - } - } - } - ], - 'stack_conventions' => [ - { - 'root' => '{service}/{Team}', - 'stacks' => [ - { - 'name' => 'terragrunt', - 'directory' => 'terragrunt/{environment}', - 'required_attributes' => ['aws_region', 'iam_role_plan', 'iam_role_apply'] - } - ] - } - ], - 'services' => [] - }) - end - - before do - allow(config_client).to receive(:load_workflow_config).and_return(config) - end - - it 'returns a validation error for the invalid literal' do - result = use_case.execute - expect(result).to be_failure - expect(result.validation_errors.join("\n")).to match(/\{Team\}/) - end - end - - context 'when two conventions share a stack name and same structural shape but different placeholder names' do - let(:config) do - Entities::WorkflowConfig.new({ - 'environments' => [ - { - 'environment' => 'develop', - 'stacks' => { - 'terragrunt' => { - 'aws_region' => 'ap-northeast-1', - 'iam_role_plan' => 'arn:aws:iam::1:role/plan', - 'iam_role_apply' => 'arn:aws:iam::1:role/apply' - } - } - } - ], - 'stack_conventions' => [ - { - 'root' => '{team}/{service}', - 'stacks' => [ - { - 'name' => 'terragrunt', - 'directory' => 'terragrunt/{environment}', - 'required_attributes' => ['aws_region', 'iam_role_plan', 'iam_role_apply'] - } - ] - }, - { - 'root' => '{team99}/{service}', - 'stacks' => [ - { - 'name' => 'terragrunt', - 'directory' => 'terragrunt/{environment}', - 'required_attributes' => ['aws_region', 'iam_role_plan', 'iam_role_apply'] - } - ] - } - ], - 'services' => [] - }) - end - - before do - allow(config_client).to receive(:load_workflow_config).and_return(config) - end - - it 'returns a validation error mentioning the conflicting placeholder names' do - result = use_case.execute - expect(result).to be_failure - joined = result.validation_errors.join("\n") - expect(joined).to match(/team/) - expect(joined).to match(/team99/) - end - end - - context 'when two conventions share the same root but different stack names' do - let(:config) do - Entities::WorkflowConfig.new({ - 'environments' => [ - { - 'environment' => 'develop', - 'stacks' => { - 'terragrunt' => { - 'aws_region' => 'ap-northeast-1', - 'iam_role_plan' => 'arn:aws:iam::1:role/plan', - 'iam_role_apply' => 'arn:aws:iam::1:role/apply' - }, - 'kubernetes' => {} - } - } - ], - 'stack_conventions' => [ - { - 'root' => '{team}/{service}', - 'stacks' => [ - { - 'name' => 'terragrunt', - 'directory' => 'terragrunt/{environment}', - 'required_attributes' => ['aws_region', 'iam_role_plan', 'iam_role_apply'] - } - ] - }, - { - 'root' => '{team}/{service}', - 'stacks' => [ - { - 'name' => 'kubernetes', - 'directory' => 'kubernetes/overlays/{environment}' - } - ] - } - ], - 'services' => [] - }) - end - - before do - allow(config_client).to receive(:load_workflow_config).and_return(config) - end - - it 'passes validation (different stack names are not compared)' do - result = use_case.execute - expect(result).to be_success - end - end + let(:file) { Tempfile.new(['workflow-config', '.yaml']) } + let(:client) { Infrastructure::ConfigClient.new(config_path: file.path) } + subject(:use_case) { described_class.new(config_client: client) } + + after { file.close! } + + it 'summarizes validated stacks and their environment union' do + file.write(default_test_config) + file.flush + result = use_case.execute + expect(result).to be_success + expect(result.valid).to be(true) + expect(result.config).to be_a(Entities::WorkflowConfig) + expect(result.validation_summary).to include('stacks: 2', 'environments: 2') end - describe 'integration with real config client' do - let(:real_config_client) { Infrastructure::ConfigClient.new(config_path: temp_config.path) } - let(:use_case) { described_class.new(config_client: real_config_client) } - let(:temp_config) { create_test_config(config_content) } - - after { temp_config.unlink if temp_config } - - context 'with valid real configuration' do - let(:config_content) { default_test_config } - - it 'successfully validates real configuration' do - result = use_case.execute - - expect(result).to be_success - expect(result.config).to be_a(Entities::WorkflowConfig) - expect(result.validation_summary).to be_present - end - end - - context 'with invalid real configuration' do - let(:config_content) do - <<~YAML - # Missing required sections - invalid_section: - - some_data - YAML - end + it 'accepts configurations with only common stacks' do + file.write({ 'stacks' => [{ 'name' => 'container', 'paths' => ['apps/{service}'] }] }.to_yaml) + file.flush + expect(use_case.execute).to be_success + end - it 'properly validates and reports errors' do - result = use_case.execute + it 'returns the original configuration error in validation errors' do + file.write("stacks:\n - name: container\n paths: []\n") + file.flush + result = use_case.execute + expect(result).to be_failure + expect(result.validation_errors).to eq([result.error_message]) + expect(result.error_message).to include('stacks[0].paths') + end - expect(result).to be_failure - expect(result.validation_errors).to be_present - end - end + it 'returns a failed result for missing files' do + client = Infrastructure::ConfigClient.new(config_path: "#{file.path}.missing") + result = described_class.new(config_client: client).execute + expect(result).to be_failure + expect(result.validation_errors.first).to include('.missing') end -end \ No newline at end of file +end diff --git a/action-scripts/spec/factories.rb b/action-scripts/spec/factories.rb index cfa3eaa..3f212bc 100644 --- a/action-scripts/spec/factories.rb +++ b/action-scripts/spec/factories.rb @@ -60,60 +60,24 @@ factory :workflow_config, class: 'Entities::WorkflowConfig' do config_hash do { - 'environments' => [ + 'stacks' => [ { - 'environment' => 'develop', - 'stacks' => { - 'terragrunt' => { - 'aws_region' => 'ap-northeast-1', - 'iam_role_plan' => 'arn:aws:iam::123456789012:role/plan-role', - 'iam_role_apply' => 'arn:aws:iam::123456789012:role/apply-role' - }, - 'kubernetes' => {} - } + 'name' => 'terragrunt', + 'id' => 'aws', + 'paths' => [ + 'dystopia/{service}/aws/{environment}', + 'system-components/{service}/infrastructure/aws/{environment}' + ], + 'environments' => { + 'develop' => { 'aws_region' => 'ap-northeast-1' }, + 'production' => { 'aws_region' => 'us-west-2' } + }, + 'exclude' => [] }, { - 'environment' => 'staging', - 'stacks' => { - 'terragrunt' => { - 'aws_region' => 'ap-northeast-1', - 'iam_role_plan' => 'arn:aws:iam::123456789012:role/staging-plan-role', - 'iam_role_apply' => 'arn:aws:iam::123456789012:role/staging-apply-role' - }, - 'kubernetes' => {} - } - }, - { - 'environment' => 'production', - 'stacks' => { - 'terragrunt' => { - 'aws_region' => 'ap-northeast-1', - 'iam_role_plan' => 'arn:aws:iam::123456789012:role/production-plan-role', - 'iam_role_apply' => 'arn:aws:iam::123456789012:role/production-apply-role' - }, - 'kubernetes' => {} - } - } - ], - 'stack_conventions' => [ - { - 'root' => '{service}', - 'stacks' => [ - { - 'name' => 'terragrunt', - 'directory' => 'terragrunt/{environment}', - 'required_attributes' => ['aws_region', 'iam_role_plan', 'iam_role_apply'] - }, - { - 'name' => 'kubernetes', - 'directory' => 'kubernetes/overlays/{environment}' - } - ] - } - ], - 'services' => [ - { - 'name' => 'test-service' + 'name' => 'container', + 'paths' => ['dystopia/{service}', 'system-components/{service}'], + 'attributes' => { 'repository' => 'registry.example.com/app' } } ] } @@ -121,17 +85,10 @@ initialize_with { new(config_hash) } - trait :with_excluded_service do + trait :with_target_exclusions do config_hash do base_config = attributes_for(:workflow_config)[:config_hash] - base_config['services'] << { - 'name' => 'excluded-service', - 'exclude_from_automation' => true, - 'exclusion_config' => { - 'reason' => 'Manual deployment required', - 'type' => 'permanent' - } - } + base_config['stacks'].first['exclude'] = [{ 'service' => 'demo', 'environment' => 'production' }] base_config end end diff --git a/action-scripts/spec/shared/entities/workflow_config_spec.rb b/action-scripts/spec/shared/entities/workflow_config_spec.rb index c2acd08..130b784 100644 --- a/action-scripts/spec/shared/entities/workflow_config_spec.rb +++ b/action-scripts/spec/shared/entities/workflow_config_spec.rb @@ -1,562 +1,233 @@ -# spec/shared/entities/workflow_config_spec.rb - require 'spec_helper' RSpec.describe Entities::WorkflowConfig do - let(:config_hash) do + let(:stack) do { - 'environments' => [ - { - 'environment' => 'develop', - 'stacks' => { - 'terragrunt' => { - 'aws_region' => 'ap-northeast-1', - 'iam_role_plan' => 'arn:aws:iam::123456789012:role/plan-role', - 'iam_role_apply' => 'arn:aws:iam::123456789012:role/apply-role' - }, - 'kubernetes' => {} - } - }, - { - 'environment' => 'staging', - 'stacks' => { - 'terragrunt' => { - 'aws_region' => 'us-west-2', - 'iam_role_plan' => 'arn:aws:iam::123456789012:role/staging-plan', - 'iam_role_apply' => 'arn:aws:iam::123456789012:role/staging-apply' - } - } - }, - { - 'environment' => 'production', - 'stacks' => { - 'terragrunt' => { - 'aws_region' => 'us-west-2', - 'iam_role_plan' => 'arn:aws:iam::123456789012:role/production-plan', - 'iam_role_apply' => 'arn:aws:iam::123456789012:role/production-apply' - } - } - } - ], - 'stack_conventions' => [ - { - 'root' => '{service}', - 'stacks' => [ - { - 'name' => 'terragrunt', - 'directory' => 'terragrunt/{environment}', - 'required_attributes' => ['aws_region', 'iam_role_plan', 'iam_role_apply'] - }, - { - 'name' => 'kubernetes', - 'directory' => 'kubernetes/overlays/{environment}' - } - ] - } - ], - 'services' => [ - { - 'name' => 'test-service', - 'stack_conventions' => { - 'terragrunt' => 'services/{service}/terragrunt/envs/{environment}' - } - }, - { - 'name' => 'excluded-service', - 'exclude_from_automation' => true, - 'exclusion_config' => { - 'reason' => 'Manual deployment required', - 'type' => 'permanent' - } - } - ] + 'name' => 'terragrunt', + 'paths' => ['dystopia/{service}/aws/{environment}'], + 'environments' => { 'develop' => { 'aws_region' => 'ap-northeast-1' }, 'production' => {} } } end + let(:config_hash) { { 'stacks' => [stack] } } + subject(:config) { described_class.new(config_hash) } - subject(:workflow_config) { described_class.new(config_hash) } - - describe '#initialize' do - it 'initializes with config hash' do - expect(workflow_config.raw_config).to eq(config_hash) - end + it 'resolves omitted identities and environment names' do + config_hash['stacks'] << { 'name' => 'container', 'paths' => ['dystopia/{service}'] } + expect(config.stacks.map { |entry| entry['id'] }).to eq(%w[terragrunt container]) + expect(config.environment_names).to eq(%w[develop production]) + expect(config.stacks.last['attributes']).to eq({}) + expect(config.stacks.first['exclude']).to eq([]) end - describe '#environments' do - it 'returns environments hash keyed by environment name' do - environments = workflow_config.environments - - expect(environments).to be_a(Hash) - expect(environments.keys).to contain_exactly('develop', 'staging', 'production') - expect(environments['develop']['stacks']['terragrunt']['aws_region']).to eq('ap-northeast-1') - expect(environments['staging']['stacks']['terragrunt']['aws_region']).to eq('us-west-2') - expect(environments['production']['stacks']['terragrunt']['aws_region']).to eq('us-west-2') - end + it 'collects environment names from every stack without duplicates' do + config_hash['stacks'] << { + 'name' => 'kubernetes', 'paths' => ['kubernetes/{service}'], + 'environments' => { 'production' => {}, 'preview' => {} } + } + expect(config.environment_names).to eq(%w[develop production preview]) end - describe '#services' do - it 'returns services hash keyed by service name' do - services = workflow_config.services - - expect(services).to be_a(Hash) - expect(services.keys).to contain_exactly('test-service', 'excluded-service') - expect(services['test-service']['name']).to eq('test-service') - expect(services['excluded-service']['exclude_from_automation']).to be true - end + it 'normalizes relative paths without changing the input' do + stack['paths'] = ['./dystopia/./{service}/aws/{environment}/'] + expect(config.stacks.first['paths']).to eq(['dystopia/{service}/aws/{environment}']) + expect(stack['paths']).to eq(['./dystopia/./{service}/aws/{environment}/']) + expect(stack).not_to have_key('id') end - describe '#environment_config' do - context 'with existing environment' do - it 'returns environment configuration including stacks' do - config = workflow_config.environment_config('develop') - - expect(config['environment']).to eq('develop') - expect(config['stacks']['terragrunt']['aws_region']).to eq('ap-northeast-1') - expect(config['stacks']['terragrunt']['iam_role_plan']).to eq('arn:aws:iam::123456789012:role/plan-role') - end - end - - context 'with non-existing environment' do - it 'returns nil' do - config = workflow_config.environment_config('non-existing') - expect(config).to be_nil - end - end + it 'preserves attributes with different keys and value types' do + stack['environments']['production'] = { 'token' => nil, 'enabled' => false, 'count' => 2, 'regions' => ['west'] } + expect(config.stacks.first['environments']).to eq( + 'develop' => { 'aws_region' => 'ap-northeast-1' }, + 'production' => { 'token' => nil, 'enabled' => false, 'count' => 2, 'regions' => ['west'] } + ) end - describe '#stack_attributes_for' do - let(:config_hash) do - { - 'environments' => [ - { - 'environment' => 'develop', - 'stacks' => { - 'terragrunt' => { - 'aws_region' => 'ap-northeast-1', - 'iam_role_plan' => 'arn:aws:iam::123:role/plan' - }, - 'kubernetes' => {} - } - } - ], - 'stack_conventions' => [ - { 'root' => '{service}', 'stacks' => [ - { 'name' => 'terragrunt', 'directory' => 'terragrunt/{environment}' } - ] } - ] - } - end - - it 'returns attributes hash for an existing environment and stack' do - expect(workflow_config.stack_attributes_for('develop', 'terragrunt')).to eq( - 'aws_region' => 'ap-northeast-1', - 'iam_role_plan' => 'arn:aws:iam::123:role/plan' - ) - end - - it 'returns empty hash for stack defined as empty hash' do - expect(workflow_config.stack_attributes_for('develop', 'kubernetes')).to eq({}) - end - - it 'returns empty hash for stack not defined under environment' do - expect(workflow_config.stack_attributes_for('develop', 'docker')).to eq({}) - end - - it 'returns empty hash for non-existent environment' do - expect(workflow_config.stack_attributes_for('non-existent', 'terragrunt')).to eq({}) - end - - it 'returns empty hash when environment has no stacks key' do - hash = config_hash.dup - hash['environments'] = [{ 'environment' => 'develop' }] - config = described_class.new(hash) - expect(config.stack_attributes_for('develop', 'terragrunt')).to eq({}) - end + it 'accepts repeated placeholders' do + stack['paths'] = ['teams/{team}/{service}/{team}/aws/{environment}'] + expect { config }.not_to raise_error end - describe '#required_attributes_for' do - let(:config_hash) do - { - 'environments' => [{ 'environment' => 'develop' }], - 'stack_conventions' => [ - { - 'root' => '{service}', - 'stacks' => [ - { - 'name' => 'terragrunt', - 'directory' => 'terragrunt/{environment}', - 'required_attributes' => ['aws_region', 'iam_role_plan'] - }, - { - 'name' => 'kubernetes', - 'directory' => 'kubernetes/overlays/{environment}' - } - ] - } - ] - } - end - - it 'returns required attributes list for the stack' do - expect(workflow_config.required_attributes_for('terragrunt')).to eq(['aws_region', 'iam_role_plan']) - end - - it 'returns empty array when required_attributes is not defined' do - expect(workflow_config.required_attributes_for('kubernetes')).to eq([]) - end - - it 'returns empty array when stack is not in any convention' do - expect(workflow_config.required_attributes_for('docker')).to eq([]) - end + it 'matches all conditions in a rule and any rule in the array' do + stack['paths'] = ['{team}/{service}/aws/{environment}'] + stack['exclude'] = [ + { 'team' => 'platform', 'service' => 'demo', 'environment' => 'production' }, + { 'team' => 'sandbox' } + ] + entry = config.stacks.first + expect(config.excluded?(entry, 'team' => 'platform', 'service' => 'demo', 'environment' => 'production')).to be(true) + expect(config.excluded?(entry, 'team' => 'platform', 'service' => 'demo', 'environment' => 'develop')).to be(false) + expect(config.excluded?(entry, 'team' => 'platform', 'service' => 'api', 'environment' => 'production')).to be(false) + expect(config.excluded?(entry, 'team' => 'sandbox', 'service' => 'api', 'environment' => 'develop')).to be(true) + expect(config.excluded?(entry, 'service' => 'demo', 'environment' => 'production')).to be(false) end - - describe '#stack_conventions_for' do - context 'with service-specific convention' do - it 'returns service-specific convention' do - conventions = workflow_config.stack_conventions_for('test-service', 'terragrunt') - expect(conventions).to eq(['services/{service}/terragrunt/envs/{environment}']) - end - end - - context 'with default convention' do - it 'returns default convention for service without specific configuration' do - conventions = workflow_config.stack_conventions_for('other-service', 'terragrunt') - expect(conventions).to eq(['{service}/terragrunt/{environment}']) - end + it 'matches a service condition across environments' do + stack['exclude'] = [{ 'service' => 'demo' }] + %w[develop production].each do |environment| + expect(config.excluded?(config.stacks.first, 'service' => 'demo', 'environment' => environment)).to be(true) + expect(config.excluded?(config.stacks.first, 'service' => 'api', 'environment' => environment)).to be(false) end + end - context 'with non-existing stack' do - it 'returns empty array' do - conventions = workflow_config.stack_conventions_for('test-service', 'non-existing') - expect(conventions).to be_empty - end + it 'matches an environment condition when the path has no environment' do + stack['paths'] = ['dystopia/{service}/aws'] + stack['exclude'] = [{ 'environment' => 'production' }] + %w[demo api].each do |service| + expect(config.excluded?(config.stacks.first, 'service' => service, 'environment' => 'production')).to be(true) + expect(config.excluded?(config.stacks.first, 'service' => service, 'environment' => 'develop')).to be(false) end + end - context 'when stack argument is omitted' do - it 'raises ArgumentError' do - expect { workflow_config.stack_conventions_for('test-service') }.to raise_error(ArgumentError) - end - end + it 'allows custom keys from the union of stack paths without matching missing captures' do + stack['paths'] << 'teams/{team}/{service}/aws/{environment}' + stack['exclude'] = [{ 'team' => 'sandbox' }] + expect(config.excluded?(config.stacks.first, 'service' => 'demo', 'environment' => 'production')).to be(false) end - describe '#stack_convention_for' do - context 'with service-specific convention' do - it 'returns first service-specific convention' do - convention = workflow_config.stack_convention_for('test-service', 'terragrunt') - expect(convention).to eq('services/{service}/terragrunt/envs/{environment}') - end - end + it 'allows another stack to use an attribute named after a placeholder' do + stack['paths'] = ['teams/{team}/{service}/aws/{environment}'] + config_hash['stacks'] << { 'name' => 'container', 'paths' => ['dystopia/{service}'], 'attributes' => { 'team' => 'platform' } } + expect { config }.not_to raise_error + end - context 'with default convention' do - it 'returns first default convention for service without specific configuration' do - convention = workflow_config.stack_convention_for('other-service', 'terragrunt') - expect(convention).to eq('{service}/terragrunt/{environment}') - end - end + context 'with common targets' do + let(:stack) { { 'name' => 'container', 'paths' => ['dystopia/{service}'] } } - context 'with non-existing stack' do - it 'returns nil' do - convention = workflow_config.stack_convention_for('test-service', 'non-existing') - expect(convention).to be_nil - end + it 'derives an empty environment list' do + expect(config.environment_names).to eq([]) end - context 'when stack argument is omitted' do - it 'raises ArgumentError' do - expect { workflow_config.stack_convention_for('test-service') }.to raise_error(ArgumentError) - end + it 'matches service rules without an environment condition' do + stack['exclude'] = [{ 'service' => 'demo' }] + expect(config.excluded?(config.stacks.first, 'service' => 'demo', 'environment' => nil)).to be(true) + expect(config.excluded?(config.stacks.first, 'service' => 'api', 'environment' => nil)).to be(false) end - end - describe '#stack_convention_root' do - it 'returns the first root pattern from directory conventions' do - expect(workflow_config.stack_convention_root).to eq('{service}') + it 'distinguishes a null environment from an absent key' do + stack['exclude'] = [{ 'environment' => nil }] + expect(config.excluded?(config.stacks.first, 'service' => 'demo', 'environment' => nil)).to be(true) + expect(config.excluded?(config.stacks.first, 'service' => 'demo')).to be(false) end - it 'returns all root patterns' do - expect(workflow_config.stack_convention_roots).to eq(['{service}']) + it 'rejects a named environment in a common exclusion' do + stack['exclude'] = [{ 'environment' => 'production' }] + expect { config }.to raise_error(ArgumentError, /stacks\[0\].exclude\[0\].environment/) end - context 'with empty root' do - let(:config_hash) do - super().tap do |config| - config['stack_conventions'][0]['root'] = '' - end - end - - it 'returns empty string' do - expect(workflow_config.stack_convention_root).to eq('') - end + it 'rejects environment placeholders' do + stack['paths'] = ['dystopia/{service}/{environment}'] + expect { config }.to raise_error(ArgumentError, /stacks\[0\].paths\[0\]/) end - context 'with missing stack_conventions' do - let(:config_hash) { super().except('stack_conventions') } - - it 'raises validation error' do - expect { workflow_config }.to raise_error(/stack_conventions/) + [nil, [], 'value', { '' => true }, { 1 => true }, { 'stack_id' => 'other' }].each do |value| + it "rejects invalid common attributes #{value.inspect}" do + stack['attributes'] = value + expect { config }.to raise_error(ArgumentError, /stacks\[0\].attributes/) end end end - - describe '#safety_check_enabled?' do - it 'always returns false (safety checks removed)' do - expect(workflow_config.safety_check_enabled?('require_merged_pr')).to be false - expect(workflow_config.safety_check_enabled?('any_check')).to be false + [nil, [], {}, { 'stacks' => nil }, { 'stacks' => {} }, { 'stacks' => [] }, { 'stacks' => [nil] }].each do |value| + it "rejects invalid configuration structure #{value.inspect}" do + expect { described_class.new(value) }.to raise_error(ArgumentError, /configuration|stacks/) end end - describe '#excluded_services' do - it 'returns list of excluded service names' do - excluded = workflow_config.excluded_services - expect(excluded).to contain_exactly('excluded-service') - end - - context 'with no excluded services' do - let(:config_hash) do - super().tap do |config| - config['services'] = [{ 'name' => 'test-service' }] - end - end - - it 'returns empty array' do - excluded = workflow_config.excluded_services - expect(excluded).to be_empty - end - end - - context 'with multiple root patterns' do - let(:config_hash) do - super().tap do |config| - config['stack_conventions'] = [ - { - 'root' => 'apps/web/{service}', - 'stacks' => [ - { - 'name' => 'terragrunt', - 'directory' => 'terragrunt/{environment}' - }, - { - 'name' => 'kubernetes', - 'directory' => 'kubernetes/overlays/{environment}' - } - ] - }, - { - 'root' => 'services/{service}', - 'stacks' => [ - { - 'name' => 'terragrunt', - 'directory' => 'terragrunt/{environment}' - }, - { - 'name' => 'kubernetes', - 'directory' => 'kubernetes/overlays/{environment}' - } - ] - } - ] - end - end - - it 'returns all possible conventions' do - conventions = workflow_config.stack_conventions_for('other-service', 'terragrunt') - expect(conventions).to contain_exactly( - 'apps/web/{service}/terragrunt/{environment}', - 'services/{service}/terragrunt/{environment}' - ) - end - - it 'returns first convention for stack_convention_for' do - convention = workflow_config.stack_convention_for('other-service', 'terragrunt') - expect(convention).to eq('apps/web/{service}/terragrunt/{environment}') - end - - it 'returns all root patterns' do - roots = workflow_config.stack_convention_roots - expect(roots).to contain_exactly('apps/web/{service}', 'services/{service}') - end - - it 'returns first root pattern' do - root = workflow_config.stack_convention_root - expect(root).to eq('apps/web/{service}') - end - - it 'returns all directory patterns including root patterns' do - patterns = workflow_config.all_directory_patterns - expect(patterns).to contain_exactly( - 'apps/web/{service}', # root pattern - 'apps/web/{service}/terragrunt/{environment}', - 'apps/web/{service}/kubernetes/overlays/{environment}', - 'services/{service}', # root pattern - 'services/{service}/terragrunt/{environment}', - 'services/{service}/kubernetes/overlays/{environment}' - ) - end + %w[services environments stack_conventions unknown].each do |key| + it "rejects the unknown top-level field #{key}" do + config_hash[key] = [] + expect { config }.to raise_error(ArgumentError, /#{key}/) end end - describe '#validate!' do - context 'with valid configuration' do - it 'does not raise error' do - expect { workflow_config.validate! }.not_to raise_error - end - end - - context 'with missing environments' do - let(:config_hash) { super().except('environments') } - - it 'raises validation error' do - expect { workflow_config.validate! }.to raise_error(/environments/) - end + %w[root directory required_attributes unknown].each do |key| + it "rejects the unknown stack field #{key}" do + stack[key] = 'value' + expect { config }.to raise_error(ArgumentError, /stacks\[0\].#{key}/) end + end - context 'with missing stack_conventions' do - let(:config_hash) { super().except('stack_conventions') } - - it 'raises validation error' do - expect { workflow_config.validate! }.to raise_error(/stack_conventions/) + { + 'name' => [nil, '', 1], + 'id' => [nil, '', 1], + 'paths' => [nil, [], 'path', [nil], ['']], + 'environments' => [nil, [], {}, { 'develop' => [] }, { '' => {} }, { 1 => {} }, { 'a/b' => {} }, { '.' => {} }, { '..' => {} }], + 'exclude' => [nil, {}, [nil], [{}]] + }.each do |key, values| + values.each do |value| + it "rejects invalid #{key} #{value.inspect}" do + stack[key] = value + expect { config }.to raise_error(ArgumentError, /stacks\[0\].#{key}/) end end + end - - context 'with missing stack_conventions root' do - let(:config_hash) do - super().tap do |config| - config['stack_conventions'] = [{ 'stacks' => [] }] - end - end - - it 'raises validation error' do - expect { workflow_config.validate! }.to raise_error(/root/) - end + [ + '/dystopia/{service}', 'dystopia/../{service}', 'dystopia/aws', + 'dystopia/{Service}', 'dystopia/{service}/{my-team}', 'dystopia/{service}/{team', + 'dystopia/{{service}}', 'dystopia/{service}/{stack}', + 'dystopia/{service}/{stack_id}', 'dystopia/{service}/{working_directory}' + ].each do |pattern| + it "rejects invalid path #{pattern}" do + stack['paths'] = [pattern] + expect { config }.to raise_error(ArgumentError, /stacks\[0\].paths\[0\]/) end + end - context 'with missing stack_conventions stacks' do - let(:config_hash) do - super().tap do |config| - config['stack_conventions'] = [{ 'root' => '{service}' }] - end - end - - it 'raises validation error' do - expect { workflow_config.validate! }.to raise_error(/stacks/) - end + [nil, 1, [], '', 'a/b', '.', '..', '.hidden'].each do |value| + it "rejects invalid service conditions #{value.inspect}" do + stack['exclude'] = [{ 'service' => value }] + expect { config }.to raise_error(ArgumentError, /stacks\[0\].exclude\[0\].service/) end + end - context 'with non-array stack_conventions' do - let(:config_hash) do - super().tap do |config| - config['stack_conventions'] = { 'root' => '{service}', 'stacks' => [] } - end - end - - it 'raises validation error' do - expect { workflow_config.validate! }.to raise_error(/must be an array/) - end + [nil, 'preview', 1].each do |value| + it "rejects undeclared environment conditions #{value.inspect}" do + stack['exclude'] = [{ 'environment' => value }] + expect { config }.to raise_error(ArgumentError, /stacks\[0\].exclude\[0\].environment/) end + end - context 'with invalid environment structure' do - let(:config_hash) do - super().tap do |config| - config['environments'] = [{ 'invalid' => 'structure' }] - end - end - - it 'raises validation error' do - expect { workflow_config.validate! }.to raise_error(/Environment.*missing required field: environment/) - end + %w[team aws_region].each do |key| + it "rejects undeclared exclusion keys #{key}" do + stack['exclude'] = [{ key => 'value' }] + expect { config }.to raise_error(ArgumentError, /stacks\[0\].exclude\[0\].#{key}/) end end - describe 'identity uniqueness (id || name) within a convention' do - let(:environments) { [{ 'environment' => 'production', 'stacks' => {} }] } - it('accepts distinct ids') { expect { described_class.new('environments'=>environments,'stack_conventions'=>[{'root'=>'x','stacks'=>[{'name'=>'t','id'=>'a'},{'name'=>'t','id'=>'b'}]}]) }.not_to raise_error } - it('rejects duplicate names') { expect { described_class.new('environments'=>environments,'stack_conventions'=>[{'root'=>'x','stacks'=>[{'name'=>'t'},{'name'=>'t'}]}]) }.to raise_error(/duplicate identity 't'/) } - it('accepts same id in different conventions') { expect { described_class.new('environments'=>environments,'stack_conventions'=>[{'root'=>'a','stacks'=>[{'name'=>'t','id'=>'x'}]},{'root'=>'b','stacks'=>[{'name'=>'t','id'=>'x'}]}]) }.not_to raise_error } - it('rejects id colliding with another name') { expect { described_class.new('environments'=>environments,'stack_conventions'=>[{'root'=>'x','stacks'=>[{'name'=>'k'},{'name'=>'t','id'=>'k'}]}]) }.to raise_error(/duplicate identity 'k'/) } - end - describe '#stack_attributes_for with id fallback' do - let(:env_hash) do - { 'environments'=>[{ 'environment'=>'production', 'stacks'=>{ 'terragrunt'=>{ 'iam_role_plan'=>'arn:plan' } } }], - 'stack_conventions'=>[{ 'root'=>'dystopia/{service}', 'stacks'=>[{ 'name'=>'terragrunt','id'=>'aws','directory'=>'infrastructure/aws/{environment}' }, { 'name'=>'terragrunt','id'=>'stripe','directory'=>'infrastructure/stripe/{environment}' }] }] } - end - it 'returns attributes keyed on id when present' do - env_hash['environments'][0]['stacks']['aws'] = { 'aws_region'=>'us-east-1' } - expect(described_class.new(env_hash).stack_attributes_for('production','aws')).to include('aws_region'=>'us-east-1') - end - it 'falls back to attributes keyed on name when id key is absent' do - expect(described_class.new(env_hash).stack_attributes_for('production','aws')).to include('iam_role_plan'=>'arn:plan') - end - it 'returns {} when neither id nor name resolves' do - expect(described_class.new(env_hash).stack_attributes_for('production','nonexistent')).to eq({}) - end - it 'returns {} (not nil) when the bare name no longer resolves because every entry sharing it has an id' do - result=described_class.new(env_hash).stack_attributes_for('production','terragrunt') - expect(result).to eq({}) - expect { Entities::DeploymentTarget.new(service:'x', stack:'terragrunt', working_directory:'y', attributes:result) }.not_to raise_error - end + + it 'does not permit placeholders declared in another stack as exclusion keys' do + config_hash['stacks'] << { 'name' => 'container', 'paths' => ['teams/{team}/{service}'] } + stack['exclude'] = [{ 'team' => 'sandbox' }] + expect { config }.to raise_error(ArgumentError, /stacks\[0\].exclude\[0\].team/) end - describe '#stack_conventions_for with id' do - let(:data) { { 'environments'=>[{ 'environment'=>'production','stacks'=>{} }], 'stack_conventions'=>[{ 'root'=>'dystopia/{service}', 'stacks'=>[{ 'name'=>'terragrunt','id'=>'aws','directory'=>'infrastructure/aws/{environment}' }, { 'name'=>'terragrunt','id'=>'stripe','directory'=>'infrastructure/stripe/{environment}' }] }] } } - it 'resolves each id to its own directory pattern independently' do - config=described_class.new(data); expect(config.stack_conventions_for('monolith','aws')).to eq(['dystopia/{service}/infrastructure/aws/{environment}']); expect(config.stack_conventions_for('monolith','stripe')).to eq(['dystopia/{service}/infrastructure/stripe/{environment}']) - end - it 'no longer resolves the bare name once every entry sharing it has an id' do - expect(described_class.new(data).stack_conventions_for('monolith','terragrunt')).to eq([]) - end - it 'still resolves by name when no entry declares an id (backward compatible)' do - expect(described_class.new('environments'=>data['environments'],'stack_conventions'=>[{ 'root'=>'aws/{service}','stacks'=>[{ 'name'=>'terragrunt','directory'=>'envs/{environment}' }] }]).stack_conventions_for('eks','terragrunt')).to eq(['aws/{service}/envs/{environment}']) - end + it 'rejects attribute inheritance between common and environment attributes' do + stack['attributes'] = {} + expect { config }.to raise_error(ArgumentError, /stacks\[0\]/) end - describe '#required_attributes_for with id fallback' do - let(:two_id_config) do - { - 'environments' => [{ 'environment' => 'production', 'stacks' => {} }], - 'stack_conventions' => [ - { 'root' => 'dystopia/{service}', - 'stacks' => [ - { 'name' => 'terragrunt', 'id' => 'aws', 'directory' => 'infrastructure/aws/{environment}', - 'required_attributes' => %w[aws_region] }, - { 'name' => 'terragrunt', 'id' => 'stripe', 'directory' => 'infrastructure/stripe/{environment}', - 'required_attributes' => %w[stripe_secret_ref] } - ] } - ] - } - end - it 'returns the required attributes of the entry matched by id' do - expect(described_class.new(two_id_config).required_attributes_for('stripe')).to eq(%w[stripe_secret_ref]) - end + it 'rejects identities shared by separate definitions' do + stack['id'] = 'aws' + config_hash['stacks'] << { 'name' => 'aws', 'paths' => ['other/{service}'] } + expect { config }.to raise_error(ArgumentError, /stacks\[1\].id/) + end - # A bare name that every co-named entry has replaced with an id is no longer - # an identity, so it resolves to nothing — the same answer stack_conventions_for - # and stack_attributes_for give for that input. - it 'returns [] for a bare name that ids have fully consumed' do - expect(described_class.new(two_id_config).required_attributes_for('terragrunt')).to eq([]) - end + it 'accepts distinct identities for the same stack kind' do + stack['id'] = 'aws' + config_hash['stacks'] << { 'name' => 'terragrunt', 'id' => 'stripe', 'paths' => ['other/{service}'] } + expect(config.stacks.map { |entry| entry['id'] }).to eq(%w[aws stripe]) end - describe '#stack_attributes_for with id fallback' do - it 'resolves the bare-name entry when only some co-named siblings carry an id' do - data = { - 'environments' => [ - { 'environment' => 'production', - 'stacks' => { 'aws' => { 'aws_region' => 'ap-northeast-1' }, - 'terragrunt' => { 'plain_attr' => 'PLAIN' } } } - ], - 'stack_conventions' => [ - { 'root' => 'r/{service}', - 'stacks' => [ - { 'name' => 'terragrunt', 'id' => 'aws', 'directory' => 'a/{environment}' }, - { 'name' => 'terragrunt', 'id' => 'stripe', 'directory' => 's/{environment}' }, - { 'name' => 'terragrunt', 'directory' => 't/{environment}' } - ] } - ] - } - config = described_class.new(data) - expect(config.stack_attributes_for('production', 'terragrunt')).to eq('plain_attr' => 'PLAIN') - expect(config.stack_attributes_for('production', 'aws')).to eq('aws_region' => 'ap-northeast-1') + + ['', 1, 'service', 'environment', 'stack', 'stack_id', 'working_directory'].each do |key| + it "rejects invalid environment attribute keys #{key.inspect}" do + stack['environments']['production'] = { key => 'value' } + expect { config }.to raise_error(ArgumentError, /stacks\[0\].environments/) end end + it 'checks placeholder collisions against attributes in every environment' do + stack['paths'] = ['teams/{team}/{service}/aws/{environment}'] + stack['environments']['production'] = { 'team' => 'platform' } + expect { config }.to raise_error(ArgumentError, /stacks\[0\].paths\[0\]/) + end end diff --git a/action-scripts/spec/shared/infrastructure/config_client_spec.rb b/action-scripts/spec/shared/infrastructure/config_client_spec.rb index 38625dd..fefb0b6 100644 --- a/action-scripts/spec/shared/infrastructure/config_client_spec.rb +++ b/action-scripts/spec/shared/infrastructure/config_client_spec.rb @@ -1,187 +1,68 @@ -# spec/shared/infrastructure/config_client_spec.rb - require 'spec_helper' +require 'tempfile' RSpec.describe Infrastructure::ConfigClient do - subject(:config_client) { described_class.new } - - describe '#load_workflow_config' do - context 'with valid configuration file' do - let(:temp_config) { create_test_config(default_test_config) } - - after { temp_config.unlink } - - it 'loads and returns WorkflowConfig object' do - config = config_client.load_workflow_config - - expect(config).to be_a(Entities::WorkflowConfig) - expect(config.environments.keys).to include('develop', 'staging', 'production') - expect(config.services.keys).to include('demo') - end - - it 'caches the loaded configuration' do - config1 = config_client.load_workflow_config - config2 = config_client.load_workflow_config - - expect(config1).to be(config2) - end - end - - context 'with non-existent configuration file' do - before { ENV['WORKFLOW_CONFIG_PATH'] = '/non/existent/path.yaml' } - - it 'raises file not found error' do - expect { config_client.load_workflow_config }.to raise_error(/not found/) - end - end - - context 'with invalid YAML' do - let(:temp_config) { create_test_config("invalid: [\n - key: value\n - another: ]\n missing_close") } + let(:content) { { 'stacks' => [{ 'name' => 'container', 'paths' => ['apps/{service}'], 'attributes' => { 'repository' => 'registry.example.com/app' } }] }.to_yaml } + let(:file) { Tempfile.new(['workflow-config', '.yaml']) } + let(:client) { described_class.new(config_path: file.path) } - before { temp_config } # Force temp_config creation before config_client - after { temp_config.unlink } - - it 'raises YAML parsing error' do - config_client = described_class.new - expect { config_client.load_workflow_config }.to raise_error(/YAML/) - end - end - - context 'with default configuration path' do - before do - ENV.delete('WORKFLOW_CONFIG_PATH') - allow(File).to receive(:exist?).with('workflow-config.yaml').and_return(true) - allow(File).to receive(:read).with('workflow-config.yaml').and_return(default_test_config) - end - - it 'uses default path when environment variable not set' do - config = config_client.load_workflow_config - - expect(config).to be_a(Entities::WorkflowConfig) - expect(File).to have_received(:read).with('workflow-config.yaml') - end - end + before do + file.write(content) + file.flush end - describe '#validate_config_file' do - context 'with valid configuration file' do - let(:temp_config) { create_test_config(default_test_config) } - - after { temp_config.unlink } - - it 'returns success result with validation summary' do - result = config_client.validate_config_file - - expect(result).to be_success - expect(result.config).to be_a(Entities::WorkflowConfig) - expect(result.validation_summary).to include('environments') - expect(result.validation_summary).to include('services') - end - end - - context 'with invalid configuration' do - let(:invalid_config) do - <<~YAML - environments: [] - # Missing stack_conventions - YAML - end - let(:temp_config) { create_test_config(invalid_config) } + after { file.close! } - before { temp_config } # Force temp_config creation before config_client - after { temp_config.unlink } - - it 'returns failure result with validation errors' do - config_client = described_class.new - result = config_client.validate_config_file - - expect(result).to be_failure - expect(result.error_message).to include('Missing required configuration sections') - end - end - - context 'with file not found' do - before { ENV['WORKFLOW_CONFIG_PATH'] = '/non/existent/path.yaml' } - - it 'returns failure result with file error' do - result = config_client.validate_config_file - - expect(result).to be_failure - expect(result.error_message).to include('not found') - end - end + it 'loads a configuration from the requested file' do + config = client.load_workflow_config + expect(config.stacks.first).to include('id' => 'container', 'paths' => ['apps/{service}']) + expect(config.environment_names).to eq([]) end - describe '#config_file_path' do - context 'with environment variable set' do - before { ENV['WORKFLOW_CONFIG_PATH'] = '/custom/path.yaml' } - - it 'returns custom path' do - expect(config_client.send(:config_file_path)).to eq('/custom/path.yaml') - end - end - - context 'without environment variable' do - before { ENV.delete('WORKFLOW_CONFIG_PATH') } - - it 'returns default path' do - expect(config_client.send(:config_file_path)).to eq('workflow-config.yaml') - end - end + it 'keeps the parsed configuration cached until explicitly cleared' do + first = client.load_workflow_config + File.write(file.path, content.sub('registry.example.com/app', 'registry.example.com/other')) + expect(client.load_workflow_config).to be(first) + expect(client.load_workflow_config.stacks.first['attributes']).to eq('repository' => 'registry.example.com/app') + client.clear_cache + expect(client.load_workflow_config.stacks.first['attributes']).to eq('repository' => 'registry.example.com/other') end - describe 'caching behavior' do - let(:temp_config) { create_test_config(default_test_config) } - - after { temp_config.unlink } - - it 'caches configuration after first load' do - allow(File).to receive(:read).and_call_original - - config1 = config_client.load_workflow_config - config2 = config_client.load_workflow_config - - expect(File).to have_received(:read).once - expect(config1).to be(config2) - end + it 'uses the environment-specified path when no path is passed' do + ENV['WORKFLOW_CONFIG_PATH'] = file.path + expect(described_class.new.load_workflow_config.stacks.first['id']).to eq('container') + end - it 'clears cache when configuration file changes' do - original_time = Time.now - 3600 - new_time = Time.now + it 'reports the missing file path' do + expect { described_class.new(config_path: "#{file.path}.missing").load_workflow_config }.to raise_error(/not found.*\.missing/) + end - allow(File).to receive(:mtime).and_return(original_time, new_time) + it 'reports permission errors with the file path' do + allow(File).to receive(:read).with(file.path).and_raise(Errno::EACCES) + expect { client.load_workflow_config }.to raise_error(/Permission denied.*#{Regexp.escape(file.path)}/) + end - config1 = config_client.load_workflow_config - config_client.clear_cache - config2 = config_client.load_workflow_config + context 'with malformed YAML' do + let(:content) { "stacks: [\n {name: container\n" } - expect(config1).not_to be(config2) + it 'reports the YAML line number' do + expect { client.load_workflow_config }.to raise_error(/YAML.*line \d+/) end end - describe 'error handling' do - context 'with permission denied' do - before do - ENV['WORKFLOW_CONFIG_PATH'] = '/root/config.yaml' - allow(File).to receive(:exist?).with('/root/config.yaml').and_return(true) - allow(File).to receive(:read).with('/root/config.yaml').and_raise(Errno::EACCES) - end + context 'with an invalid configuration' do + let(:content) { "stacks:\n - name: container\n paths: []\n" } - it 'raises permission error with helpful message' do - expect { config_client.load_workflow_config }.to raise_error(/permission/i) - end + it 'preserves the validation position and file path' do + expect { client.load_workflow_config }.to raise_error(/#{Regexp.escape(file.path)}.*stacks\[0\].paths/) end + end - context 'with malformed YAML' do - let(:temp_config) { create_test_config("invalid: [\n - unclosed\n - array: {\n missing_close_brace") } - - before { temp_config } # Force temp_config creation before config_client - after { temp_config.unlink } + context 'with an empty configuration file' do + let(:content) { '' } - it 'raises YAML error with line information' do - config_client = described_class.new - expect { config_client.load_workflow_config }.to raise_error(/YAML.*line/i) - end + it 'reports a configuration error' do + expect { client.load_workflow_config }.to raise_error(/configuration/i) end end -end \ No newline at end of file +end diff --git a/action-scripts/spec/spec_helper.rb b/action-scripts/spec/spec_helper.rb index 468db11..f6b8f70 100644 --- a/action-scripts/spec/spec_helper.rb +++ b/action-scripts/spec/spec_helper.rb @@ -115,49 +115,7 @@ def create_test_config(content) # Default test configuration def default_test_config - <<~YAML - environments: - - environment: develop - stacks: - terragrunt: - aws_region: ap-northeast-1 - iam_role_plan: arn:aws:iam::123456789012:role/plan-role - iam_role_apply: arn:aws:iam::123456789012:role/apply-role - kubernetes: {} - - environment: staging - stacks: - terragrunt: - aws_region: ap-northeast-1 - iam_role_plan: arn:aws:iam::123456789012:role/staging-plan-role - iam_role_apply: arn:aws:iam::123456789012:role/staging-apply-role - kubernetes: {} - - environment: production - stacks: - terragrunt: - aws_region: ap-northeast-1 - iam_role_plan: arn:aws:iam::123456789012:role/production-plan-role - iam_role_apply: arn:aws:iam::123456789012:role/production-apply-role - kubernetes: {} - - stack_conventions: - - root: "{service}" - stacks: - - name: terragrunt - directory: "terragrunt/{environment}" - required_attributes: [aws_region, iam_role_plan, iam_role_apply] - - name: kubernetes - directory: "kubernetes/overlays/{environment}" - - services: - - name: demo - stack_conventions: - terragrunt: "services/{service}/terragrunt/envs/{environment}" - - name: excluded-service - exclude_from_automation: true - exclusion_config: - reason: "Manual deployment required" - type: "permanent" - YAML + attributes_for(:workflow_config).fetch(:config_hash).to_yaml end # Mock GitHub API responses diff --git a/docs/superpowers/plans/stack-configuration.md b/docs/superpowers/plans/stack-configuration.md index c82fa69..21e6871 100644 --- a/docs/superpowers/plans/stack-configuration.md +++ b/docs/superpowers/plans/stack-configuration.md @@ -57,15 +57,15 @@ ## Execution Preparation -- [ ] **Step 1: Read the approved documents and repository rules** +- [x] **Step 1: Read the approved documents and repository rules** 設計書、本計画、AGENTS.md、存在する CLAUDE.md を読む。実行方式の承認と、設計書の15項目が計画のどのタスクに対応するかを確認した時点で完了とする。 -- [ ] **Step 2: Prepare the implementation workspace** +- [x] **Step 2: Prepare the implementation workspace** `superpowers:using-git-worktrees` の手順で隔離環境を準備する。ブランチ名・作業パス・既存 Draft PR を継続するか新しい Draft PR にするかを記録する。ユーザーの未コミット変更を含めず、実装用ブランチであることを `git status --short --branch` で確認した時点で完了とする。 -- [ ] **Step 3: Confirm the runtime and dependency setup** +- [x] **Step 3: Confirm the runtime and dependency setup** `action-scripts` を作業ディレクトリとして `ruby --version`、`bundle --version`、`bundle check` を実行し、リポジトリの指定と整合することを確認する。不足がある場合は既存 lockfile に従って環境を用意し、指定ファイルを書き換えない。 @@ -121,7 +121,7 @@ stacks: - Preserves: `Infrastructure::ConfigClient#load_workflow_config`、`#clear_cache`。 - Produces: `UseCases::ConfigManagement::ValidateConfig#execute` → 成功時 `Result` の `valid: true`・`config`・`validation_summary`、失敗時 `error_message`・`validation_errors`。 -- [ ] **Step 1: Write model, exclusion, and loading tests** +- [x] **Step 1: Write model, exclusion, and loading tests** 共通 fixture を新形式へ変更するテストと、次の除外テストを追加する。 @@ -165,13 +165,13 @@ end `ConfigClient` は成功時のキャッシュ、ファイル欠落・権限・YAML 行番号のエラー、モデルの検証位置が伝わることをテストする。`ValidateConfig` は成功サマリーが `stacks: 2`・`environments: 2` を含み、読み込み失敗では `validation_errors` に元のエラーを保持することをテストする。 -- [ ] **Step 2: Confirm the tests fail for the old model** +- [x] **Step 2: Confirm the tests fail for the old model** Run: `bundle exec rspec spec/shared/entities/workflow_config_spec.rb spec/shared/infrastructure/config_client_spec.rb spec/config-manager/use_cases/validate_config_spec.rb` Expected: 新形式または新アクセサー・除外判定の assertion が FAIL。環境や require の不備は先に解消し、仕様に対する失敗を記録する。 -- [ ] **Step 3: Implement the model and remove duplicate validation** +- [x] **Step 3: Implement the model and remove duplicate validation** `WorkflowConfig` の constructor で構造・型・整合性を検証し、その後に id と相対パスを正規化する。除外キーの許可範囲は `PatternMatcher.placeholders` から収集する。除外判定は `values.key?(key) && values[key] == expected` を条件内の全キーに要求し、規則の配列のいずれかが一致した場合だけ true にする。 @@ -179,11 +179,11 @@ Expected: 新形式または新アクセサー・除外判定の assertion が F 旧 convention・service・属性探索のメソッドと、製品コードに呼び出しのない `raw_config` アクセサー・`safety_check_enabled?` を削除する。旧 `with_excluded_service` trait を `with_target_exclusions` に置き換え、aws stack に service と environment の条件を設定する。旧形式の振る舞いテストは新仕様のテストへ置き換え、旧形式の拒否を検証する入力だけに旧フィールド名を残す。 -- [ ] **Step 4: Confirm the model tests pass** +- [x] **Step 4: Confirm the model tests pass** Step 2 と同じコマンドを実行する。Expected: exit 0、`0 failures`。完了条件は、旧アクセサーを使わずに3ファイルのテストが通ることである。 -- [ ] **Step 5: Commit the configuration model** +- [x] **Step 5: Commit the configuration model** Files に列挙した変更を指定して stage し、`git diff --cached --check` が exit 0 であることを確認する。 From 7b977220264b28e9c08328d00f78040b6d34f8f9 Mon Sep 17 00:00:00 2001 From: panicboat Date: Fri, 9 Oct 2026 09:46:35 +0900 Subject: [PATCH 06/13] feat: resolve stack paths from repository directories Signed-off-by: panicboat --- .../infrastructure/file_system_client.rb | 40 ++++++-- .../infrastructure/file_system_client_spec.rb | 91 +++++++++++++++++++ action-scripts/spec/spec_helper.rb | 6 -- docs/superpowers/plans/stack-configuration.md | 10 +- 4 files changed, 130 insertions(+), 17 deletions(-) create mode 100644 action-scripts/spec/shared/infrastructure/file_system_client_spec.rb diff --git a/action-scripts/shared/infrastructure/file_system_client.rb b/action-scripts/shared/infrastructure/file_system_client.rb index 7d2ac3f..7ca9afb 100644 --- a/action-scripts/shared/infrastructure/file_system_client.rb +++ b/action-scripts/shared/infrastructure/file_system_client.rb @@ -29,6 +29,40 @@ def get_changed_files(base_ref: nil, head_ref: nil) get_current_changes(git_dir) end + def repository_root(start_path: __dir__) + source_path = ENV['SOURCE_REPO_PATH'] + if source_path && !source_path.empty? + root = File.expand_path(source_path, start_path) + git_path = File.join(root, '.git') + return root if File.directory?(root) && (File.directory?(git_path) || File.file?(git_path)) + end + + # FALLBACK: Local commands use the enclosing checkout when SOURCE_REPO_PATH does not identify a repository. + current = File.expand_path(start_path) + loop do + git_path = File.join(current, '.git') + return current if File.directory?(git_path) || File.file?(git_path) + parent = File.dirname(current) + break if parent == current + current = parent + end + raise "Could not find repository root starting from #{start_path}" + end + + def resolve_directories(pattern:, values: {}) + root = repository_root + placeholders = Entities::PatternMatcher.placeholders(pattern) + glob = pattern.gsub(/#{Entities::PatternMatcher::PLACEHOLDER_REGEX.source}|[\\*?\[\]{}]/) do |token| + token.match?(/\A#{Entities::PatternMatcher::PLACEHOLDER_REGEX.source}\z/) ? '*' : "\\#{token}" + end + Dir.glob(glob, base: root).sort.filter_map do |path| + next unless File.directory?(File.join(root, path)) + captures = Entities::PatternMatcher.extract(pattern, path) + next unless captures && placeholders.all? { |key| !values.key?(key) || captures[key] == values[key] } + { working_directory: path, captures: captures } + end + end + private # Get the source repository path for composite actions @@ -82,12 +116,6 @@ def file_exists?(path) File.exist?(path) end - # Get all directories matching a pattern - def find_directories(pattern) - Dir.glob(pattern).select { |path| File.directory?(path) } - end - - # Check if git repository is clean def git_clean? `git status --porcelain`.strip.empty? diff --git a/action-scripts/spec/shared/infrastructure/file_system_client_spec.rb b/action-scripts/spec/shared/infrastructure/file_system_client_spec.rb new file mode 100644 index 0000000..c8d026f --- /dev/null +++ b/action-scripts/spec/shared/infrastructure/file_system_client_spec.rb @@ -0,0 +1,91 @@ +require 'spec_helper' +require 'tmpdir' +require 'fileutils' + +RSpec.describe Infrastructure::FileSystemClient do + subject(:client) { described_class.new } + + around do |example| + original = ENV['SOURCE_REPO_PATH'] + Dir.mktmpdir do |root| + @root = root + FileUtils.mkdir_p(File.join(root, '.git')) + ENV['SOURCE_REPO_PATH'] = root + example.run + end + ensure + ENV['SOURCE_REPO_PATH'] = original + end + + def directory(path) + FileUtils.mkdir_p(File.join(@root, path)) + end + + it 'resolves every matching directory in sorted order with exact context values' do + %w[teams/sandbox/demo/aws/production teams/platform/demo/aws/production teams/platform/demo/aws/develop teams/platform/api/aws/production].each { |path| directory(path) } + directory('teams/file/demo/aws') + File.write(File.join(@root, 'teams/file/demo/aws/production'), '') + expect(client.resolve_directories(pattern: 'teams/{team}/{service}/aws/{environment}', values: { 'service' => 'demo', 'environment' => 'production' })).to eq([ + { working_directory: 'teams/platform/demo/aws/production', captures: { 'team' => 'platform', 'service' => 'demo', 'environment' => 'production' } }, + { working_directory: 'teams/sandbox/demo/aws/production', captures: { 'team' => 'sandbox', 'service' => 'demo', 'environment' => 'production' } } + ]) + end + + it 'matches repeated placeholders only when their values agree' do + directory('teams/platform/demo/platform') + directory('teams/platform/demo/sandbox') + expect(client.resolve_directories(pattern: 'teams/{team}/{service}/{team}').map { |match| match[:working_directory] }).to eq(['teams/platform/demo/platform']) + end + + it 'treats glob characters as literals' do + ['literal[1]', 'literal*', 'literal?', 'literal{a,b}', 'literal\\x'].each do |prefix| + directory("#{prefix}/demo") + expect(client.resolve_directories(pattern: "#{prefix}/{service}").map { |match| match[:working_directory] }).to eq(["#{prefix}/demo"]) + end + directory('literal1/demo') + expect(client.resolve_directories(pattern: 'literal[1]/{service}').length).to eq(1) + end + + it 'ignores context keys absent from the pattern' do + directory('dystopia/demo/aws') + expect(client.resolve_directories(pattern: 'dystopia/{service}/aws', values: { 'service' => 'demo', 'environment' => 'production' })).to eq([ + { working_directory: 'dystopia/demo/aws', captures: { 'service' => 'demo' } } + ]) + end + + it 'returns no matches for absent directories' do + expect(client.resolve_directories(pattern: 'dystopia/{service}/aws')).to eq([]) + end + + it 'propagates directory enumeration errors' do + allow(Dir).to receive(:glob).and_raise(Errno::EACCES) + expect { client.resolve_directories(pattern: 'dystopia/{service}') }.to raise_error(Errno::EACCES) + end + + it 'uses the source repository root' do + expect(client.repository_root).to eq(@root) + end + + it 'accepts git marker files' do + FileUtils.rm_r(File.join(@root, '.git')) + File.write(File.join(@root, '.git'), 'gitdir: elsewhere') + expect(client.repository_root).to eq(@root) + end + + it 'searches parent directories from the supplied start path' do + ENV.delete('SOURCE_REPO_PATH') + directory('nested/deep') + expect(client.repository_root(start_path: File.join(@root, 'nested/deep'))).to eq(@root) + end + + it 'resolves relative source paths from the supplied start path' do + directory('nested') + ENV['SOURCE_REPO_PATH'] = '..' + expect(client.repository_root(start_path: File.join(@root, 'nested'))).to eq(@root) + end + + it 'fails when no repository root exists' do + FileUtils.rm_r(File.join(@root, '.git')) + expect { client.repository_root(start_path: @root) }.to raise_error(/Could not find repository root/) + end +end diff --git a/action-scripts/spec/spec_helper.rb b/action-scripts/spec/spec_helper.rb index f6b8f70..e8e2980 100644 --- a/action-scripts/spec/spec_helper.rb +++ b/action-scripts/spec/spec_helper.rb @@ -68,12 +68,6 @@ def present? FactoryBot.find_definitions end - # Mock File.directory? globally for tests that check directory existence - config.before(:each) do - allow(File).to receive(:directory?).and_call_original - allow(File).to receive(:directory?).with(/test-service|demo/).and_return(true) - end - # Clean up environment variables after each test config.after(:each) do # Restore original environment variables if modified diff --git a/docs/superpowers/plans/stack-configuration.md b/docs/superpowers/plans/stack-configuration.md index 21e6871..1c56dc9 100644 --- a/docs/superpowers/plans/stack-configuration.md +++ b/docs/superpowers/plans/stack-configuration.md @@ -204,7 +204,7 @@ Run: `git commit -s -m 'feat: define stack configuration and exclusion condition - Produces: `FileSystemClient#resolve_directories(pattern:, values: {})` → `Array`。各要素は `{ working_directory: String, captures: Hash }`。相対パスを昇順で返す。`values` はパターンに現れるキーだけを照合し、その placeholder の値を完全一致させる。 - Preserves: `FileSystemClient#get_changed_files(base_ref: nil, head_ref: nil)` の既存 signature と Git diff 処理。 -- [ ] **Step 1: Write directory and root tests using temporary repositories** +- [x] **Step 1: Write directory and root tests using temporary repositories** `tmpdir` と `fileutils` を spec で読み、ブロック内で `.git` とディレクトリを作る。以下を assertion にする。 @@ -224,23 +224,23 @@ expect(client.resolve_directories( 追加 example は `matches repeated placeholders`、`treats glob characters as literals`、`ignores context keys absent from the pattern`、`returns no matches for absent directories`、`uses the source repository root`、`accepts git marker files`、`fails when no repository root exists` とする。繰り返しは `teams/{team}/{service}/{team}` で同じ team だけが一致し、リテラルは `literal[1]/{service}` で `literal1` を返さないことを確認する。`dystopia/{service}/aws` に environment=production の既知値を渡しても実在するパスを返すことを確認する。`Dir.glob` が `Errno::EACCES` を返した場合は、そのエラーが呼び出し側へ届くことを確認する。 -- [ ] **Step 2: Confirm the directory tests fail** +- [x] **Step 2: Confirm the directory tests fail** Run: `bundle exec rspec spec/shared/infrastructure/file_system_client_spec.rb` Expected: `resolve_directories` と `repository_root` の未実装による FAIL。 -- [ ] **Step 3: Implement exact directory resolution** +- [x] **Step 3: Implement exact directory resolution** 既存の未使用 `find_directories(pattern)` を置き換え、上記2メソッドを公開する。glob はリテラルを escape し、placeholder だけを候補列挙用の `*` にする。`Dir.glob(..., base: repository_root)` の結果を、実在ディレクトリ・`PatternMatcher.extract`・既知値の一致で絞る。繰り返した placeholder の一致は `PatternMatcher` に任せる。 `spec_helper` の全 example に対する test-service/demo の `File.directory?` mock を削除する。各 spec に必要なディレクトリを作り、通常ファイルがディレクトリとして誤認されないことをテストする。Git diff の既存処理は変更しない。 -- [ ] **Step 4: Confirm the directory tests pass** +- [x] **Step 4: Confirm the directory tests pass** Step 2 と同じコマンドを実行する。Expected: exit 0、`0 failures`。実在しないディレクトリの戻り値が `[]`、列挙エラーが例外であることを確認して完了とする。 -- [ ] **Step 5: Commit directory resolution** +- [x] **Step 5: Commit directory resolution** Files の変更を stage して `git diff --cached --check` を実行する。 From d156838e761de7b62c5000a1df13a7b67c8d9114 Mon Sep 17 00:00:00 2001 From: panicboat Date: Fri, 9 Oct 2026 09:49:06 +0900 Subject: [PATCH 07/13] feat: generate deployment targets from stack definitions Signed-off-by: panicboat --- action-scripts/label-resolver/application.rb | 4 +- action-scripts/label-resolver/bin/resolver | 14 +- .../use_cases/determine_target_environment.rb | 25 +- .../use_cases/generated_matrix.rb | 441 +---------- .../shared/entities/deployment_target.rb | 62 +- action-scripts/spec/factories.rb | 2 - .../spec/label-resolver/cli_spec.rb | 30 + .../label_resolver_controller_spec.rb | 13 + .../determine_target_environment_spec.rb | 120 +-- .../use_cases/generate_matrix_spec.rb | 745 ++++-------------- .../shared/entities/deployment_target_spec.rb | 244 +----- docs/superpowers/plans/stack-configuration.md | 10 +- 12 files changed, 310 insertions(+), 1400 deletions(-) create mode 100644 action-scripts/spec/label-resolver/cli_spec.rb diff --git a/action-scripts/label-resolver/application.rb b/action-scripts/label-resolver/application.rb index c6724dc..3cd083f 100644 --- a/action-scripts/label-resolver/application.rb +++ b/action-scripts/label-resolver/application.rb @@ -31,6 +31,7 @@ def self.build_container # Infrastructure clients container[:config_client] = Infrastructure::ConfigClient.new + container[:file_client] = Infrastructure::FileSystemClient.new # GitHub client (only in GitHub Actions or with credentials) if ENV['GITHUB_ACTIONS'] || (ENV['GITHUB_TOKEN'] && ENV['GITHUB_REPOSITORY']) @@ -56,7 +57,8 @@ def self.build_container ) container[:generate_matrix] = UseCases::LabelResolver::GenerateMatrix.new( - config_client: container[:config_client] + config_client: container[:config_client], + file_client: container[:file_client] ) # Presenters diff --git a/action-scripts/label-resolver/bin/resolver b/action-scripts/label-resolver/bin/resolver index e1735cd..cf5a4a6 100644 --- a/action-scripts/label-resolver/bin/resolver +++ b/action-scripts/label-resolver/bin/resolver @@ -64,19 +64,9 @@ class LabelResolverCLI < Thor private def parse_environments(env_string) - return all_environments if env_string.nil? || env_string.empty? - + return [] if env_string.nil? || env_string.strip.empty? env_string.split(',').map(&:strip) end - - def all_environments - config_client = LabelResolverContainer.resolve(:config_client) - config = config_client.load_workflow_config - config.environments.keys - rescue => e - puts "Warning: Could not load workflow config, using default environments: #{e.message}" - ['develop', 'staging', 'production'] - end end -LabelResolverCLI.start(ARGV) +LabelResolverCLI.start(ARGV) if $PROGRAM_NAME == __FILE__ diff --git a/action-scripts/label-resolver/use_cases/determine_target_environment.rb b/action-scripts/label-resolver/use_cases/determine_target_environment.rb index 235e09f..b688574 100644 --- a/action-scripts/label-resolver/use_cases/determine_target_environment.rb +++ b/action-scripts/label-resolver/use_cases/determine_target_environment.rb @@ -1,5 +1,3 @@ -# Use case for determining target environment - module UseCases module LabelResolver class DetermineTargetEnvironment @@ -7,28 +5,13 @@ def initialize(config_client:) @config_client = config_client end - # Execute target environments determination def execute(target_environments:) config = @config_client.load_workflow_config + environments = target_environments.nil? || target_environments.empty? ? config.environment_names : target_environments.uniq + unknown = environments - config.environment_names + return Entities::Result.failure(error_message: "Target environments not found in configuration: #{unknown.join(', ')}") unless unknown.empty? - # Validate all environments exist in configuration - validated_environments = [] - environment_configs = {} - - target_environments.each do |env| - unless config.environments.key?(env) - return Entities::Result.failure( - error_message: "Target environment '#{env}' not found in configuration" - ) - end - validated_environments << env - environment_configs[env] = config.environment_config(env) - end - - Entities::Result.success( - target_environments: validated_environments, - environment_configs: environment_configs - ) + Entities::Result.success(target_environments: environments) rescue => error Entities::Result.failure(error_message: "Failed to determine target environments: #{error.message}") end diff --git a/action-scripts/label-resolver/use_cases/generated_matrix.rb b/action-scripts/label-resolver/use_cases/generated_matrix.rb index 8e359e8..de837dd 100644 --- a/action-scripts/label-resolver/use_cases/generated_matrix.rb +++ b/action-scripts/label-resolver/use_cases/generated_matrix.rb @@ -1,417 +1,58 @@ -# Use case for generating deployment matrix from deploy labels -# Creates deployment targets with all necessary configuration - module UseCases module LabelResolver class GenerateMatrix - def initialize(config_client:) + def initialize(config_client:, file_client:) @config_client = config_client + @file_client = file_client end - # Execute matrix generation from deploy labels def execute(deploy_labels:, target_environments:) - @target_environments = target_environments config = @config_client.load_workflow_config - - # Validate all target_environments exist - target_environments.each do |env| - env_config = config.environment_config(env) - unless env_config - return Entities::Result.failure( - error_message: "Environment configuration not found for: #{env}" - ) - end - end - - deployment_targets = [] - - deploy_labels.each do |deploy_label| - next unless deploy_label.valid? - - # Handle deploy:all - expand to all non-excluded services - if deploy_label.service == 'all' - all_services = config.services.keys.reject do |service_name| - config.excluded_services.include?(service_name) - end - - all_services.each do |service_name| - service_label = Entities::DeployLabel.from_service(service: service_name) - targets_for_service = generate_targets_for_service(service_label, config) - deployment_targets.concat(targets_for_service) - end - else - # Check if service is excluded from automation - if service_excluded_from_automation?(deploy_label.service, config) - next - end - - # Generate targets for the service using stack-specific targets - targets_for_service = generate_targets_for_service(deploy_label, config) - deployment_targets.concat(targets_for_service) - end - end - - Entities::Result.success( - deployment_targets: deployment_targets, - has_deployments: deployment_targets.any?, - total_targets: deployment_targets.length - ) - rescue => error - puts "❌ Matrix generation failed: #{error.message}" - puts "Error backtrace:" - puts error.backtrace.first(10).join("\n") - Entities::Result.failure(error_message: "Matrix generation failed: #{error.message}") - end - - private - - # Generate deployment targets for a service across all applicable environments and stacks - def generate_targets_for_service(deploy_label, config) - targets = [] - - # A service can legitimately span multiple conventions (e.g. aws/{service} for - # terragrunt + kubernetes/components/{service} for kubernetes manifests). - # Collect stacks from every matching convention; dedup by identity (first wins) - # so duplicate stack names across conventions don't multiply targets — downstream - # lookup is keyed on stack name and converges to the same working directory. - matching_conventions = find_matching_conventions(deploy_label.service, config) - return targets if matching_conventions.empty? - - stacks = matching_conventions.flat_map { |c| c['stacks'] || [] }.uniq { |s| s['id'] || s['name'] } - - stacks.each do |stack_config| - stack_name = stack_config['name'] - stack_id = stack_config['id'] || stack_name - stack_directory = stack_config['directory'] - - # Check if stack is environment-specific (contains {environment} placeholder) - is_environment_specific = stack_directory&.include?('{environment}') - - if is_environment_specific - # Generate targets for all target environments - @target_environments.each do |env| - # Validate that the target environment exists - env_config = config.environment_config(env) - unless env_config - raise "environment configuration not found for: #{env} (should have been caught by validate_config)" + environments = target_environments.nil? || target_environments.empty? ? config.environment_names : target_environments.uniq + unknown = environments - config.environment_names + return Entities::Result.failure(error_message: "Target environments not found in configuration: #{unknown.join(', ')}") unless unknown.empty? + + labels = deploy_labels.select(&:valid?) + services = labels.any?(&:deploy_all?) ? [nil] : labels.map(&:service).uniq + candidates = {} + config.stacks.each do |stack| + selected = stack.key?('environments') ? environments & stack['environments'].keys : [nil] + selected.each do |environment| + services.each do |service| + values = { 'environment' => environment } + values['service'] = service if service + stack['paths'].each do |pattern| + @file_client.resolve_directories(pattern: pattern, values: values).each do |match| + captures = match.fetch(:captures) + name = captures.fetch('service') + next if name.start_with?('.') + directory = match.fetch(:working_directory) + custom = captures.reject { |key, _| %w[service environment].include?(key) } + identity = [name, stack['id'], environment, directory] + if candidates.key?(identity) && candidates[identity][:captures] != custom + raise "Conflicting captures for stack '#{stack['id']}' at '#{directory}'" + end + candidates[identity] = { stack: stack, environment: environment, service: name, working_directory: directory, captures: custom } + end end - - # Check if stack directory exists for this service/environment combination - exists = stack_config_directory_exists?(deploy_label.service, env, stack_config, config) - target = generate_deployment_target_for_instance(deploy_label, env, stack_config, config) if exists - targets << target if target end - else - # Environment-agnostic stack - generate only one target with nil environment - # Check if stack directory exists (use first environment for validation) - first_env = @target_environments.first - exists = stack_config_directory_exists?(deploy_label.service, first_env, stack_config, config) - target = generate_deployment_target_for_instance(deploy_label, nil, stack_config, config) if exists - targets << target if target end end - targets - end - - # Find every convention that has existing directories for this service. - # Returns conventions in declaration order so callers can rely on first-wins dedup - # for stack names that appear in more than one convention. - def find_matching_conventions(service_name, config) - repo_root = find_repository_root - - config.stack_conventions_config.select do |convention| - stacks = convention['stacks'] || [] - - stacks.any? do |stack_config| - @target_environments.any? do |env| - root_pattern = convention['root'] - stack_directory = stack_config['directory'] - - full_pattern = if root_pattern.nil? || root_pattern.empty? - stack_directory - else - "#{root_pattern}/#{stack_directory}" - end - - begin - expanded = expand_directory_pattern(full_pattern, service_name, env) - rescue Entities::UnresolvedPlaceholderError - next false - end - next false unless expanded - - File.directory?(File.join(repo_root, expanded)) - end - end - end - end - - # Check if service is excluded from automation - def service_excluded_from_automation?(service_name, config) - service_config = config.services[service_name] - return false unless service_config - - # Basic exclusion check: exclude_from_automation: true - service_config['exclude_from_automation'] == true - end - - # Detect available stacks by checking directory existence - def detect_available_stacks(service_name, target_environment, config) - # Skip stack detection for excluded services - if service_excluded_from_automation?(service_name, config) - return [] - end - - available_stacks = [] - - # Get repository root by finding .git directory - puts "🔍 Current working directory: #{Dir.pwd}" - puts "🔍 Script directory (__dir__): #{__dir__}" - repo_root = find_repository_root - puts "🔍 Repository root detected: #{repo_root}" - - # Check all configured stacks using hierarchical structure - stacks = config.send(:directory_stacks) || [] - stacks.each do |stack_config| - stack_name = stack_config['name'] - next unless stack_name - - # Get all possible directory patterns for this service and stack - dir_patterns = config.stack_conventions_for(service_name, stack_name) - next if dir_patterns.empty? - - # Check each possible directory pattern - dir_patterns.each do |dir_pattern| - # Get directory path by expanding placeholders - dir_path = expand_directory_pattern(dir_pattern, service_name, target_environment) - next unless dir_path - - # Resolve path relative to repository root - full_path = File.join(repo_root, dir_path) - puts "🔍 Checking directory: #{full_path}" - - # Check if directory exists - if File.directory?(full_path) - puts "✅ Found #{stack_name} stack for #{service_name}:#{target_environment}" - available_stacks << stack_name - break # Found one, no need to check other patterns for this stack - else - puts "❌ Directory not found: #{full_path}" - end - end - end - - # Also check service-specific directory conventions - service_config = config.services[service_name] - if service_config && service_config['stack_conventions'] - service_config['stack_conventions'].each do |stack, pattern| - # Get directory path by expanding placeholders - dir_path = expand_directory_pattern(pattern, service_name, target_environment) - next unless dir_path - - # Resolve path relative to repository root - full_path = File.join(repo_root, dir_path) - - # Check if directory exists and not already added - if File.directory?(full_path) && !available_stacks.include?(stack) - available_stacks << stack - end - end + targets = candidates.values.filter_map do |candidate| + stack = candidate.fetch(:stack) + environment = candidate.fetch(:environment) + values = candidate.fetch(:captures).merge('service' => candidate.fetch(:service), 'environment' => environment) + next if config.excluded?(stack, values) + attributes = stack.key?('environments') ? stack['environments'].fetch(environment) : stack['attributes'] + Entities::DeploymentTarget.new( + service: candidate.fetch(:service), environment: environment, stack: stack['name'], stack_id: stack['id'], + working_directory: candidate.fetch(:working_directory), captures: candidate.fetch(:captures), attributes: attributes + ) end - - available_stacks - end - - # Resolve the directory patterns for one stack instance. Delegates to - # stack_conventions_for so per-service overrides (services[].stack_conventions) - # keep taking precedence over the global conventions; matching there is by - # identity (id || name), so two entries sharing a name resolve separately. - def candidate_patterns_for(service_name, stack_config, config) - config.stack_conventions_for(service_name, stack_config['id'] || stack_config['name']) - end - - # First candidate pattern whose expanded directory exists on disk, paired - # with the pattern that produced it. Cross-convention ties stay first-wins. - def resolve_instance_directory(service_name, environment, stack_config, config) - repo_root = find_repository_root - candidate_patterns_for(service_name, stack_config, config).each do |pattern| - begin - expanded = expand_directory_pattern(pattern, service_name, environment) - rescue Entities::UnresolvedPlaceholderError - next - end - next unless expanded - return [expanded, pattern] if File.directory?(File.join(repo_root, expanded)) - end - nil - end - - def stack_config_directory_exists?(service_name, environment, stack_config, config) - !resolve_instance_directory(service_name, environment, stack_config, config).nil? - end - - def generate_deployment_target_for_instance(deploy_label, target_environment, stack_config, config) - resolved = resolve_instance_directory(deploy_label.service, target_environment, stack_config, config) - return nil unless resolved - - working_dir, pattern = resolved - stack_id = stack_config['id'] || stack_config['name'] - - Entities::DeploymentTarget.new( - service: deploy_label.service, - environment: target_environment, - stack: stack_config['name'], - stack_id: stack_id, - working_directory: working_dir, - stack_convention_root: extract_root_from_working_dir(working_dir, deploy_label.service, target_environment, config), - attributes: target_environment ? config.stack_attributes_for(target_environment, stack_id) : {}, - captures: extract_captures(pattern, working_dir) - ) - end - - # Build the captures map for one target. Drops {service} and - # {environment} since they map to dedicated DeploymentTarget fields. - # Raises if the working_dir doesn't match the pattern (invariant - # violation: the pattern was used to build the path moments ago). - def extract_captures(full_match_pattern, working_dir) - return {} unless full_match_pattern - - raw = Entities::PatternMatcher.extract(full_match_pattern, working_dir) - if raw.nil? - raise "PatternMatcher.extract returned nil for pattern '#{full_match_pattern}' and working_dir '#{working_dir}'" - end - - raw.reject { |k, _| k == 'service' || k == 'environment' } - end - - # Expand directory pattern with placeholders. Delegates to PatternMatcher - # so all placeholder rules live in one place. Builds the values map - # conditionally because {environment} is optional for env-agnostic stacks. - # For patterns with arbitrary placeholders (e.g. {team}), replaces unknown - # segments with "*" and uses Dir.glob to find the matching directory on disk. - def expand_directory_pattern(pattern, service_name, target_environment) - return nil unless pattern - - values = { 'service' => service_name } - if pattern.include?('{environment}') - if target_environment.nil? - raise Entities::UnresolvedPlaceholderError, - "{environment} appears in pattern '#{pattern}' but target_environment is nil" - end - values['environment'] = target_environment - end - - begin - Entities::PatternMatcher.expand(pattern, values) - rescue Entities::UnresolvedPlaceholderError - # Pattern contains arbitrary placeholders beyond {service}/{environment}. - # Replace unknown placeholders with "*" and resolve via Dir.glob. - glob_pattern = pattern.gsub(Entities::PatternMatcher::PLACEHOLDER_REGEX) do - name = Regexp.last_match(1) - values.key?(name) ? values[name] : '*' - end - repo_root = find_repository_root - matches = Dir.glob(glob_pattern, base: repo_root).select do |rel| - File.directory?(File.join(repo_root, rel)) - end - return nil if matches.empty? - if matches.length > 1 - raise "Pattern '#{pattern}' matches multiple directories (glob '#{glob_pattern}'): #{matches.inspect}. " \ - "Refine stack_conventions or rename services to make the resolution deterministic." - end - - matches.first - end - end - - # Extract root directory from working directory based on configuration - def extract_root_from_working_dir(working_dir, service_name, target_environment, config) - # Try to match working_dir against all possible patterns to find the root - config.stack_conventions_config.each do |convention| - root_pattern = convention['root'] - stacks = convention['stacks'] || [] - - stacks.each do |stack_config| - full_pattern = if root_pattern.nil? || root_pattern.empty? - stack_config['directory'] - else - "#{root_pattern}/#{stack_config['directory']}" - end - - begin - expanded_pattern = expand_directory_pattern(full_pattern, service_name, target_environment) - rescue Entities::UnresolvedPlaceholderError - # Convention requires {environment} but target_environment is nil - # for this call. Skip this convention and let another match. - next - end - next unless expanded_pattern - - if working_dir == expanded_pattern - # full_pattern matched, so root_pattern (its prefix) is guaranteed - # to expand with the same values. expand_directory_pattern never - # returns nil for a non-nil pattern. - return expand_directory_pattern(root_pattern || '', service_name, target_environment) - end - end - end - - # Fallback: if no pattern matches, try to extract service name - # This handles service-specific directory conventions - service_name - end - - # Find repository root by looking for .git directory - def find_repository_root(start_path = __dir__) - puts "🔍 SOURCE_REPO_PATH env var: #{ENV['SOURCE_REPO_PATH'].inspect}" - - # Check if SOURCE_REPO_PATH environment variable is set (for composite actions) - if ENV['SOURCE_REPO_PATH'] && !ENV['SOURCE_REPO_PATH'].empty? - source_repo_path = File.expand_path(ENV['SOURCE_REPO_PATH'], __dir__) - puts "🔍 Expanded source_repo_path: #{source_repo_path}" - puts "🔍 Directory exists: #{File.directory?(source_repo_path)}" - - if File.directory?(source_repo_path) - git_path = File.join(source_repo_path, '.git') - puts "🔍 Checking git path: #{git_path}" - puts "🔍 Git directory exists: #{File.directory?(git_path) || File.file?(git_path)}" - - if File.directory?(git_path) || File.file?(git_path) - puts "✅ Using SOURCE_REPO_PATH as repository root: #{source_repo_path}" - return source_repo_path - else - puts "❌ No .git found in SOURCE_REPO_PATH, falling back to default search" - end - else - puts "❌ SOURCE_REPO_PATH directory does not exist, falling back to default search" - end - else - puts "❌ SOURCE_REPO_PATH not set or empty, using default .git search" - end - - puts "❌ SOURCE_REPO_PATH not found, unable to locate source repository" - - # Default behavior: search for .git directory - current_path = File.expand_path(start_path) - - loop do - # Check if .git directory exists - git_path = File.join(current_path, '.git') - return current_path if File.directory?(git_path) || File.file?(git_path) - - # Move up one directory - parent_path = File.dirname(current_path) - - # Stop if we've reached the root directory - break if parent_path == current_path - - current_path = parent_path - end - - # Fallback: if .git not found, raise error with helpful message - raise "Could not find repository root (.git directory) starting from #{start_path}. " \ - "Make sure this script is run within a Git repository or set SOURCE_REPO_PATH environment variable." + Entities::Result.success(deployment_targets: targets, has_deployments: !targets.empty?, total_targets: targets.length) + rescue => error + Entities::Result.failure(error_message: "Failed to generate deployment matrix: #{error.message}") end end end diff --git a/action-scripts/shared/entities/deployment_target.rb b/action-scripts/shared/entities/deployment_target.rb index 6258562..9cd995d 100644 --- a/action-scripts/shared/entities/deployment_target.rb +++ b/action-scripts/shared/entities/deployment_target.rb @@ -1,61 +1,41 @@ -# Deployment target entity representing a specific deployment configuration -# Contains all necessary information for a deployment matrix item - module Entities class DeploymentTarget - FIXED_RESERVED_KEYS = %w[service environment stack stack_id working_directory stack_convention_root].freeze - - attr_reader :service, :environment, :stack, :stack_id, - :working_directory, :stack_convention_root, :attributes, :captures + FIXED_RESERVED_KEYS = %w[service environment stack stack_id working_directory].freeze - def initialize(service:, stack:, working_directory:, - environment: nil, stack_convention_root: nil, stack_id: nil, - attributes: {}, captures: {}) - raise ArgumentError, "service is required" if service.nil? || service.empty? - raise ArgumentError, "stack is required" if stack.nil? || stack.empty? - raise ArgumentError, "working_directory is required" if working_directory.nil? || working_directory.empty? + attr_reader :service, :environment, :stack, :stack_id, :working_directory, :attributes, :captures - attr_keys = attributes.keys.map(&:to_s) + def initialize(service:, stack:, stack_id:, working_directory:, environment: nil, attributes: {}, captures: {}) + { service: service, stack: stack, stack_id: stack_id, working_directory: working_directory }.each do |key, value| + raise ArgumentError, "#{key} is required" unless value.is_a?(String) && !value.empty? + end + attribute_keys = attributes.keys.map(&:to_s) captures.each_key do |raw_key| key = raw_key.to_s - if FIXED_RESERVED_KEYS.include?(key) - raise ArgumentError, "captures key '#{key}' collides with a reserved DeploymentTarget field" - end - if attr_keys.include?(key) - raise ArgumentError, "captures key '#{key}' collides with an attributes key" - end + raise ArgumentError, "captures key '#{key}' collides with a reserved DeploymentTarget field" if FIXED_RESERVED_KEYS.include?(key) + raise ArgumentError, "captures key '#{key}' collides with an attributes key" if attribute_keys.include?(key) end - @service = service - @environment = environment - @stack = stack - @stack_id = stack_id || stack - @working_directory = working_directory - @stack_convention_root = stack_convention_root - @attributes = attributes.freeze - @captures = captures.freeze + @service = service + @environment = environment + @stack = stack + @stack_id = stack_id + @working_directory = working_directory + @attributes = attributes.dup.freeze + @captures = captures.dup.freeze end def to_matrix_item - { - service: service, - environment: environment, - stack: stack, - stack_id: stack_id, - working_directory: working_directory, - stack_convention_root: stack_convention_root, - }.merge(attributes.transform_keys(&:to_sym)) - .merge(captures.transform_keys(&:to_sym)) + { service: service, environment: environment, stack: stack, stack_id: stack_id, working_directory: working_directory } + .merge(attributes.transform_keys(&:to_sym)).merge(captures.transform_keys(&:to_sym)) end def ==(other) - return false unless other.is_a?(DeploymentTarget) - [service, environment, stack, working_directory] == - [other.service, other.environment, other.stack, other.working_directory] + other.is_a?(DeploymentTarget) && + [service, stack_id, environment, working_directory] == [other.service, other.stack_id, other.environment, other.working_directory] end def hash - [service, environment, stack, working_directory].hash + [service, stack_id, environment, working_directory].hash end alias eql? == diff --git a/action-scripts/spec/factories.rb b/action-scripts/spec/factories.rb index 3f212bc..1bb1e8d 100644 --- a/action-scripts/spec/factories.rb +++ b/action-scripts/spec/factories.rb @@ -23,7 +23,6 @@ stack { "terragrunt" } stack_id { stack } working_directory { "test-service/terragrunt/develop" } - stack_convention_root { "test-service" } attributes do { "aws_region" => "ap-northeast-1", @@ -39,7 +38,6 @@ stack: stack, stack_id: stack_id, working_directory: working_directory, - stack_convention_root: stack_convention_root, attributes: attributes ) end diff --git a/action-scripts/spec/label-resolver/cli_spec.rb b/action-scripts/spec/label-resolver/cli_spec.rb new file mode 100644 index 0000000..130e7ab --- /dev/null +++ b/action-scripts/spec/label-resolver/cli_spec.rb @@ -0,0 +1,30 @@ +require 'spec_helper' +require 'thor' + +RSpec.describe 'LabelResolverCLI' do + before(:all) do + load File.expand_path('../../label-resolver/bin/resolver', __dir__) + end + + it 'does not start commands when loaded as a library' do + expect(LabelResolverCLI).not_to receive(:start) + load File.expand_path('../../label-resolver/bin/resolver', __dir__) + end + + [nil, '', ' '].each do |value| + it "parses #{value.inspect} as all configured environments" do + expect(LabelResolverCLI.new.send(:parse_environments, value)).to eq([]) + end + end + + it 'parses comma-separated environment names' do + expect(LabelResolverCLI.new.send(:parse_environments, ' develop,production ')).to eq(%w[develop production]) + end + + it 'passes omitted environments to the use case without reading configuration' do + controller = instance_double(Interfaces::Controllers::LabelResolverController) + allow(LabelResolverContainer).to receive(:resolve).with(:label_resolver_controller).and_return(controller) + expect(controller).to receive(:resolve_from_labels).with(pr_number: 123, target_environments: []) + LabelResolverCLI.new.resolve('123') + end +end diff --git a/action-scripts/spec/label-resolver/controllers/label_resolver_controller_spec.rb b/action-scripts/spec/label-resolver/controllers/label_resolver_controller_spec.rb index 8ab0e1d..eee8949 100644 --- a/action-scripts/spec/label-resolver/controllers/label_resolver_controller_spec.rb +++ b/action-scripts/spec/label-resolver/controllers/label_resolver_controller_spec.rb @@ -173,6 +173,19 @@ end end + it 'passes the validated environment selection to matrix generation and presentation' do + labels = [build(:deploy_label, :valid_service)] + allow(get_labels_use_case).to receive(:execute).and_return(Entities::Result.success(deploy_labels: labels)) + allow(determine_target_environment_use_case).to receive(:execute).with(target_environments: []).and_return(Entities::Result.success(target_environments: %w[develop production])) + allow(validate_deployment_safety_use_case).to receive(:execute).and_return(Entities::Result.success(safety_status: 'passed')) + allow(generate_matrix_use_case).to receive(:execute).and_return(Entities::Result.success(deployment_targets: [])) + + controller.resolve_from_labels(pr_number: 123, target_environments: []) + + expect(generate_matrix_use_case).to have_received(:execute).with(deploy_labels: labels, target_environments: %w[develop production]) + expect(presenter).to have_received(:present_deployment_matrix).with(deployment_targets: [], deploy_labels: labels, pr_number: 123, target_environments: %w[develop production], safety_status: 'passed') + end + describe '#test_deployment_workflow' do it 'calls resolve_from_labels with test parameters' do allow(controller).to receive(:resolve_from_labels) diff --git a/action-scripts/spec/label-resolver/use_cases/determine_target_environment_spec.rb b/action-scripts/spec/label-resolver/use_cases/determine_target_environment_spec.rb index 9977124..d59f236 100644 --- a/action-scripts/spec/label-resolver/use_cases/determine_target_environment_spec.rb +++ b/action-scripts/spec/label-resolver/use_cases/determine_target_environment_spec.rb @@ -1,103 +1,37 @@ -# spec/label-resolver/use_cases/determine_target_environment_spec.rb - require 'spec_helper' RSpec.describe UseCases::LabelResolver::DetermineTargetEnvironment do - let(:config_client) { double('ConfigClient') } let(:config) { build(:workflow_config) } - - subject(:use_case) { described_class.new(config_client: config_client) } - - before do - allow(config_client).to receive(:load_workflow_config).and_return(config) - end - - describe '#execute' do - context 'with valid target environments' do - let(:target_environments) { ['develop'] } - - before do - allow(config).to receive(:environments).and_return({ 'develop' => { 'environment' => 'develop' } }) - allow(config).to receive(:environment_config).with('develop').and_return({ 'environment' => 'develop' }) - end - - it 'returns success result with target environments' do - result = use_case.execute(target_environments: target_environments) - - expect(result).to be_success - expect(result.target_environments).to eq(['develop']) - expect(result.environment_configs).to eq({ 'develop' => { 'environment' => 'develop' } }) - end - end - - context 'with multiple environments' do - let(:target_environments) { ['develop', 'staging'] } - - before do - allow(config).to receive(:environments).and_return({ - 'develop' => { 'environment' => 'develop' }, - 'staging' => { 'environment' => 'staging' } - }) - allow(config).to receive(:environment_config).with('develop').and_return({ 'environment' => 'develop' }) - allow(config).to receive(:environment_config).with('staging').and_return({ 'environment' => 'staging' }) - end - - it 'returns success result with multiple environments' do - result = use_case.execute(target_environments: target_environments) - - expect(result).to be_success - expect(result.target_environments).to eq(['develop', 'staging']) - expect(result.environment_configs).to eq({ - 'develop' => { 'environment' => 'develop' }, - 'staging' => { 'environment' => 'staging' } - }) - end - end - - context 'with production environment' do - let(:target_environments) { ['production'] } - - before do - allow(config).to receive(:environments).and_return({ 'production' => { 'environment' => 'production' } }) - allow(config).to receive(:environment_config).with('production').and_return({ 'environment' => 'production' }) - end - - it 'returns success result with production environment' do - result = use_case.execute(target_environments: target_environments) - - expect(result).to be_success - expect(result.target_environments).to eq(['production']) - end - end - - context 'with invalid environment' do - let(:target_environments) { ['invalid'] } - - before do - allow(config).to receive(:environments).and_return({}) - end - - it 'returns failure result' do - result = use_case.execute(target_environments: target_environments) - - expect(result).to be_failure - expect(result.error_message).to include("Target environment 'invalid' not found in configuration") - end + let(:client) { instance_double(Infrastructure::ConfigClient, load_workflow_config: config) } + subject(:use_case) { described_class.new(config_client: client) } + + [nil, []].each do |input| + it "selects all declared environments for #{input.inspect}" do + result = use_case.execute(target_environments: input) + expect(result).to be_success + expect(result.data).to eq(target_environments: %w[develop production]) end + end - context 'when configuration loading fails' do - let(:target_environments) { ['develop'] } + it 'deduplicates requested environments preserving their order' do + expect(use_case.execute(target_environments: %w[production production develop]).target_environments).to eq(%w[production develop]) + end - before do - allow(config_client).to receive(:load_workflow_config).and_raise(StandardError.new('Config error')) - end + it 'rejects unknown environment names' do + result = use_case.execute(target_environments: ['preview']) + expect(result).to be_failure + expect(result.error_message).to include('preview') + expect(result.data).not_to have_key(:target_environments) + end - it 'returns failure result' do - result = use_case.execute(target_environments: target_environments) + it 'selects no environment for a common-only configuration' do + config = Entities::WorkflowConfig.new('stacks' => [{ 'name' => 'container', 'paths' => ['dystopia/{service}'] }]) + allow(client).to receive(:load_workflow_config).and_return(config) + expect(use_case.execute(target_environments: nil).target_environments).to eq([]) + end - expect(result).to be_failure - expect(result.error_message).to include('Failed to determine target environments: Config error') - end - end + it 'propagates configuration errors as failures' do + allow(client).to receive(:load_workflow_config).and_raise('invalid configuration') + expect(use_case.execute(target_environments: []).error_message).to include('invalid configuration') end -end \ No newline at end of file +end diff --git a/action-scripts/spec/label-resolver/use_cases/generate_matrix_spec.rb b/action-scripts/spec/label-resolver/use_cases/generate_matrix_spec.rb index c1ff244..c74178c 100644 --- a/action-scripts/spec/label-resolver/use_cases/generate_matrix_spec.rb +++ b/action-scripts/spec/label-resolver/use_cases/generate_matrix_spec.rb @@ -1,634 +1,175 @@ -# spec/label-resolver/use_cases/generate_matrix_spec.rb - require 'spec_helper' +require 'tmpdir' +require 'fileutils' RSpec.describe UseCases::LabelResolver::GenerateMatrix do - let(:config_client) { double('ConfigClient') } - let(:config) { build(:workflow_config) } - - subject(:use_case) { described_class.new(config_client: config_client) } - - before do - allow(config_client).to receive(:load_workflow_config).and_return(config) + let(:config_hash) { attributes_for(:workflow_config).fetch(:config_hash) } + let(:config) { Entities::WorkflowConfig.new(config_hash) } + let(:config_client) { instance_double(Infrastructure::ConfigClient, load_workflow_config: config) } + let(:file_client) { Infrastructure::FileSystemClient.new } + subject(:use_case) { described_class.new(config_client: config_client, file_client: file_client) } + + around do |example| + original = ENV['SOURCE_REPO_PATH'] + Dir.mktmpdir do |root| + @root = root + FileUtils.mkdir_p(File.join(root, '.git')) + ENV['SOURCE_REPO_PATH'] = root + example.run + end + ensure + ENV['SOURCE_REPO_PATH'] = original end - describe '#execute' do - let(:target_environments) { ['develop'] } - let(:deploy_labels) { [build(:deploy_label, :valid_service)] } - - context 'with valid service labels' do - let(:env_config) do - { - 'environment' => 'develop', - 'stacks' => { - 'terragrunt' => { - 'aws_region' => 'ap-northeast-1', - 'iam_role_plan' => 'arn:aws:iam::123456789012:role/plan-role', - 'iam_role_apply' => 'arn:aws:iam::123456789012:role/apply-role' - }, - 'kubernetes' => {} - } - } - end - - before do - allow(config).to receive(:environment_config).with('develop').and_return(env_config) - allow(config).to receive(:stack_convention_for).with('test-service', 'terragrunt').and_return('test-service/terragrunt/{environment}') - allow(config).to receive(:stack_convention_for).with('test-service', 'kubernetes').and_return('test-service/kubernetes/overlays/{environment}') - allow(config).to receive(:stack_convention_root).and_return('{service}') - allow(config).to receive(:stack_attributes_for).with('develop', 'terragrunt').and_return(env_config['stacks']['terragrunt']) - allow(config).to receive(:stack_attributes_for).with('develop', 'kubernetes').and_return({}) - - # Mock new directory structure - allow(config).to receive(:send).with(:directory_stacks).and_return([ - { 'name' => 'terragrunt', 'directory' => 'terragrunt/{environment}' }, - { 'name' => 'kubernetes', 'directory' => 'kubernetes/overlays/{environment}' } - ]) - - # Mock service existence check and services hash access - services_mock = { 'test-service' => {} } - allow(config).to receive(:services).and_return(services_mock) - - # Mock directory existence checks - allow(File).to receive(:directory?).and_return(true) - end - - it 'generates deployment targets for both stacks' do - result = use_case.execute(deploy_labels: deploy_labels, target_environments: target_environments) - - expect(result).to be_success - expect(result.deployment_targets.length).to eq(2) - - terragrunt_target = result.deployment_targets.find { |t| t.stack == 'terragrunt' } - kubernetes_target = result.deployment_targets.find { |t| t.stack == 'kubernetes' } - - expect(terragrunt_target).not_to be_nil - expect(terragrunt_target.service).to eq('test-service') - expect(terragrunt_target.environment).to eq('develop') - expect(terragrunt_target.working_directory).to eq('test-service/terragrunt/develop') - expect(terragrunt_target.stack_convention_root).to eq('test-service') - - expect(kubernetes_target).not_to be_nil - expect(kubernetes_target.service).to eq('test-service') - expect(kubernetes_target.environment).to eq('develop') - expect(kubernetes_target.working_directory).to eq('test-service/kubernetes/overlays/develop') - expect(kubernetes_target.stack_convention_root).to eq('test-service') - end - end - - context 'with deploy:all label' do - let(:deploy_labels) { [build(:deploy_label, :valid_all)] } - - before do - # Mock directory stacks - allow(config).to receive(:send).with(:directory_stacks).and_return([ - { 'name' => 'terragrunt', 'directory' => 'terragrunt/{environment}' }, - { 'name' => 'kubernetes', 'directory' => 'kubernetes/overlays/{environment}' } - ]) - - # Mock all services - services_mock = { - 'service1' => {}, - 'service2' => {}, - 'excluded-service' => { 'exclude_from_automation' => true } - } - allow(config).to receive(:services).and_return(services_mock) - allow(config).to receive(:excluded_services).and_return(['excluded-service']) - allow(config).to receive(:environment_config).with('develop').and_return({ - 'environment' => 'develop', - 'aws_region' => 'ap-northeast-1', - 'iam_role_plan' => 'arn:aws:iam::123456789012:role/plan-role', - 'iam_role_apply' => 'arn:aws:iam::123456789012:role/apply-role' - }) - allow(config).to receive(:stack_attributes_for).with('develop', 'terragrunt').and_return({ - 'aws_region' => 'ap-northeast-1', - 'iam_role_plan' => 'arn:aws:iam::123456789012:role/plan-role', - 'iam_role_apply' => 'arn:aws:iam::123456789012:role/apply-role' - }) - allow(config).to receive(:stack_attributes_for).with('develop', 'kubernetes').and_return({}) - allow(config).to receive(:stack_convention_root).and_return('{service}') - - # Mock directory conventions for both services - ['service1', 'service2'].each do |service| - allow(config).to receive(:stack_convention_for).with(service, 'terragrunt').and_return("#{service}/terragrunt/{environment}") - allow(config).to receive(:stack_convention_for).with(service, 'kubernetes').and_return("#{service}/kubernetes/overlays/{environment}") - end - - # Mock directory existence checks - allow(File).to receive(:directory?).and_return(true) - end - - it 'generates targets for all non-excluded services' do - result = use_case.execute(deploy_labels: deploy_labels, target_environments: target_environments) - - expect(result).to be_success - expect(result.deployment_targets.length).to eq(4) # 2 services × 2 stacks - - service_names = result.deployment_targets.map(&:service).uniq - expect(service_names).to contain_exactly('service1', 'service2') - expect(service_names).not_to include('excluded-service') - end - end - - context 'with non-existent environment' do - before do - allow(config).to receive(:environment_config).with('develop').and_return(nil) - end - - it 'returns failure result' do - result = use_case.execute(deploy_labels: deploy_labels, target_environments: target_environments) - - expect(result).to be_failure - expect(result.error_message).to include('Environment configuration not found') - expect(result.error_message).to include('develop') - end - end - - context 'with non-existent service' do - before do - allow(config).to receive(:environment_config).with('develop').and_return({ - 'environment' => 'develop' - }) - allow(config).to receive(:send).with(:directory_stacks).and_return([ - { 'name' => 'terragrunt', 'directory' => 'terragrunt/{environment}' }, - { 'name' => 'kubernetes', 'directory' => 'kubernetes/overlays/{environment}' } - ]) - allow(config).to receive(:services).and_return({}) - # Mock stack_conventions_for to return empty for non-existent service - allow(config).to receive(:stack_conventions_for).and_return([]) - end - - it 'skips non-existent services' do - result = use_case.execute(deploy_labels: deploy_labels, target_environments: target_environments) - - expect(result).to be_success - expect(result.deployment_targets).to be_empty - end - end - - context 'with empty deploy labels' do - let(:deploy_labels) { [] } - - it 'returns empty deployment targets' do - result = use_case.execute(deploy_labels: deploy_labels, target_environments: target_environments) - - expect(result).to be_success - expect(result.deployment_targets).to be_empty - end - end - - context 'with missing directory conventions' do - before do - allow(config).to receive(:environment_config).with('develop').and_return({ - 'environment' => 'develop' - }) - allow(config).to receive(:send).with(:directory_stacks).and_return([ - { 'name' => 'terragrunt', 'directory' => 'terragrunt/{environment}' }, - { 'name' => 'kubernetes', 'directory' => 'kubernetes/overlays/{environment}' } - ]) - allow(config).to receive(:services).and_return({ 'test-service' => {} }) - allow(config).to receive(:stack_conventions_for).with('test-service', 'terragrunt').and_return([]) - allow(config).to receive(:stack_conventions_for).with('test-service', 'kubernetes').and_return([]) - end - - it 'skips services without directory conventions' do - result = use_case.execute(deploy_labels: deploy_labels, target_environments: target_environments) - - expect(result).to be_success - expect(result.deployment_targets).to be_empty - end - end - - context 'with service-specific directory conventions' do - let(:env_config) do - { - 'environment' => 'develop', - 'stacks' => { - 'terragrunt' => { - 'aws_region' => 'ap-northeast-1', - 'iam_role_plan' => 'arn:aws:iam::123456789012:role/plan-role', - 'iam_role_apply' => 'arn:aws:iam::123456789012:role/apply-role' - } - } - } - end - - before do - allow(config).to receive(:environment_config).with('develop').and_return(env_config) - allow(config).to receive(:stack_attributes_for).with('develop', 'terragrunt').and_return(env_config['stacks']['terragrunt']) - allow(config).to receive(:send).with(:directory_stacks).and_return([ - { 'name' => 'terragrunt', 'directory' => 'terragrunt/{environment}' }, - { 'name' => 'kubernetes', 'directory' => 'kubernetes/overlays/{environment}' } - ]) - allow(config).to receive(:services).and_return({ 'test-service' => {} }) - allow(config).to receive(:excluded_services).and_return([]) - allow(config).to receive(:stack_convention_for).with('test-service', 'terragrunt').and_return('custom/{service}/terraform/environments/{environment}') - allow(config).to receive(:stack_convention_for).with('test-service', 'kubernetes').and_return(nil) # Only terragrunt - allow(config).to receive(:stack_conventions_for).with('test-service', 'terragrunt').and_return(['custom/{service}/terraform/environments/{environment}']) - allow(config).to receive(:stack_conventions_for).with('test-service', 'kubernetes').and_return([]) # Only terragrunt - - # Mock directory existence checks - allow(File).to receive(:directory?).and_return(true) - end - - it 'uses service-specific conventions and creates only matching targets' do - result = use_case.execute(deploy_labels: deploy_labels, target_environments: target_environments) - - expect(result).to be_success - expect(result.deployment_targets.length).to eq(1) - - target = result.deployment_targets.first - expect(target.stack).to eq('terragrunt') - expect(target.working_directory).to eq('custom/test-service/terraform/environments/develop') - expect(target.stack_convention_root).to eq('test-service') - end - end - - context 'when config loading fails' do - let(:error) { StandardError.new('Config file not found') } - - before do - allow(config_client).to receive(:load_workflow_config).and_raise(error) - end - - it 'handles error and returns failure result' do - result = use_case.execute(deploy_labels: deploy_labels, target_environments: target_environments) - - expect(result).to be_failure - expect(result.error_message).to include('Matrix generation failed') - expect(result.error_message).to include('Config file not found') - end - end - - context 'with mixed valid and invalid labels' do - let(:deploy_labels) do - [ - build(:deploy_label, :valid_service), - build(:deploy_label, :invalid) - ] - end - - before do - allow(config).to receive(:environment_config).with('develop').and_return({ - 'environment' => 'develop', - 'aws_region' => 'ap-northeast-1', - 'iam_role_plan' => 'arn:aws:iam::123456789012:role/plan-role', - 'iam_role_apply' => 'arn:aws:iam::123456789012:role/apply-role' - }) - allow(config).to receive(:stack_attributes_for).with('develop', 'terragrunt').and_return({ - 'aws_region' => 'ap-northeast-1', - 'iam_role_plan' => 'arn:aws:iam::123456789012:role/plan-role', - 'iam_role_apply' => 'arn:aws:iam::123456789012:role/apply-role' - }) - allow(config).to receive(:stack_attributes_for).with('develop', 'kubernetes').and_return({}) - allow(config).to receive(:stack_convention_root).and_return('{service}') - allow(config).to receive(:send).with(:directory_stacks).and_return([ - { 'name' => 'terragrunt', 'directory' => 'terragrunt/{environment}' }, - { 'name' => 'kubernetes', 'directory' => 'kubernetes/overlays/{environment}' } - ]) - allow(config).to receive(:services).and_return({ 'test-service' => {} }) - allow(config).to receive(:stack_convention_for).with('test-service', 'terragrunt').and_return('test-service/terragrunt/{environment}') - allow(config).to receive(:stack_convention_for).with('test-service', 'kubernetes').and_return('test-service/kubernetes/overlays/{environment}') - - # Mock directory existence checks - allow(File).to receive(:directory?).and_return(true) - end - - it 'processes only valid deploy labels' do - result = use_case.execute(deploy_labels: deploy_labels, target_environments: target_environments) - - expect(result).to be_success - expect(result.deployment_targets.length).to eq(2) # Only test-service targets - expect(result.deployment_targets.map(&:service).uniq).to eq(['test-service']) - end - end - - context 'with environment-agnostic stack (docker)' do - let(:target_environments) { ['develop', 'staging', 'production'] } - let(:env_config_develop) do - { - 'environment' => 'develop', - 'aws_region' => 'ap-northeast-1', - 'iam_role_plan' => 'arn:aws:iam::123456789012:role/plan-role', - 'iam_role_apply' => 'arn:aws:iam::123456789012:role/apply-role' - } - end - let(:env_config_staging) do - { - 'environment' => 'staging', - 'aws_region' => 'ap-northeast-1', - 'iam_role_plan' => 'arn:aws:iam::123456789012:role/staging-plan-role', - 'iam_role_apply' => 'arn:aws:iam::123456789012:role/staging-apply-role' - } - end - let(:env_config_production) do - { - 'environment' => 'production', - 'aws_region' => 'ap-northeast-1', - 'iam_role_plan' => 'arn:aws:iam::123456789012:role/prod-plan-role', - 'iam_role_apply' => 'arn:aws:iam::123456789012:role/prod-apply-role' - } - end - - before do - allow(config).to receive(:environment_config).with('develop').and_return(env_config_develop) - allow(config).to receive(:environment_config).with('staging').and_return(env_config_staging) - allow(config).to receive(:environment_config).with('production').and_return(env_config_production) - allow(config).to receive(:stack_attributes_for).with('develop', 'terragrunt').and_return({ - 'aws_region' => 'ap-northeast-1', - 'iam_role_plan' => 'arn:aws:iam::123456789012:role/plan-role', - 'iam_role_apply' => 'arn:aws:iam::123456789012:role/apply-role' - }) - allow(config).to receive(:stack_attributes_for).with('staging', 'terragrunt').and_return({ - 'aws_region' => 'ap-northeast-1', - 'iam_role_plan' => 'arn:aws:iam::123456789012:role/staging-plan-role', - 'iam_role_apply' => 'arn:aws:iam::123456789012:role/staging-apply-role' - }) - allow(config).to receive(:stack_attributes_for).with('production', 'terragrunt').and_return({ - 'aws_region' => 'ap-northeast-1', - 'iam_role_plan' => 'arn:aws:iam::123456789012:role/prod-plan-role', - 'iam_role_apply' => 'arn:aws:iam::123456789012:role/prod-apply-role' - }) - allow(config).to receive(:stack_attributes_for).with('develop', 'kubernetes').and_return({}) - allow(config).to receive(:stack_attributes_for).with('staging', 'kubernetes').and_return({}) - allow(config).to receive(:stack_attributes_for).with('production', 'kubernetes').and_return({}) - allow(config).to receive(:stack_attributes_for).with(anything, 'docker').and_return({}) - - # Mock stack_conventions_config for find_matching_conventions - allow(config).to receive(:stack_conventions_config).and_return([ - { - 'root' => '{service}', - 'stacks' => [ - { 'name' => 'docker', 'directory' => 'workspace' }, - { 'name' => 'terragrunt', 'directory' => 'terragrunt/envs/{environment}' }, - { 'name' => 'kubernetes', 'directory' => 'kubernetes/overlays/{environment}' } - ] - } - ]) - - allow(config).to receive(:services).and_return({ 'test-service' => {} }) - allow(config).to receive(:stack_conventions_for).with('test-service', 'docker').and_return(['{service}/workspace']) - allow(config).to receive(:stack_conventions_for).with('test-service', 'terragrunt').and_return(['{service}/terragrunt/envs/{environment}']) - allow(config).to receive(:stack_conventions_for).with('test-service', 'kubernetes').and_return(['{service}/kubernetes/overlays/{environment}']) - allow(config).to receive(:stack_convention_root).and_return('{service}') - - # Mock directory existence checks - allow(File).to receive(:directory?).and_return(true) - end - - it 'generates only one docker target with nil environment despite multiple target environments' do - result = use_case.execute(deploy_labels: deploy_labels, target_environments: target_environments) - - expect(result).to be_success - - docker_targets = result.deployment_targets.select { |t| t.stack == 'docker' } - terragrunt_targets = result.deployment_targets.select { |t| t.stack == 'terragrunt' } - kubernetes_targets = result.deployment_targets.select { |t| t.stack == 'kubernetes' } - - # Docker should have only 1 target with nil environment - expect(docker_targets.length).to eq(1) - expect(docker_targets.first.environment).to be_nil - expect(docker_targets.first.working_directory).to eq('test-service/workspace') - - # Terragrunt and Kubernetes should have targets for all environments - expect(terragrunt_targets.length).to eq(3) - expect(terragrunt_targets.map(&:environment)).to contain_exactly('develop', 'staging', 'production') - - expect(kubernetes_targets.length).to eq(3) - expect(kubernetes_targets.map(&:environment)).to contain_exactly('develop', 'staging', 'production') - - # Total: 1 docker + 3 terragrunt + 3 kubernetes = 7 targets - expect(result.deployment_targets.length).to eq(7) - end - end - - context 'with service spanning multiple stack conventions' do - let(:target_environments) { ['production'] } - let(:env_config) do - { - 'environment' => 'production', - 'stacks' => { - 'terragrunt' => { - 'aws_region' => 'ap-northeast-1', - 'iam_role_plan' => 'arn:aws:iam::123456789012:role/plan-role', - 'iam_role_apply' => 'arn:aws:iam::123456789012:role/apply-role' - }, - 'kubernetes' => {} - } - } - end - - before do - allow(config).to receive(:environment_config).with('production').and_return(env_config) - allow(config).to receive(:stack_attributes_for).with('production', 'terragrunt').and_return(env_config['stacks']['terragrunt']) - allow(config).to receive(:stack_attributes_for).with('production', 'kubernetes').and_return({}) - allow(config).to receive(:services).and_return({ 'test-service' => {} }) - - # Two separate conventions, each contributing a different stack - allow(config).to receive(:stack_conventions_config).and_return([ - { - 'root' => 'aws/{service}', - 'stacks' => [ - { 'name' => 'terragrunt', 'directory' => 'envs/{environment}' } - ] - }, - { - 'root' => 'kubernetes/components/{service}', - 'stacks' => [ - { 'name' => 'kubernetes', 'directory' => '{environment}' } - ] - } - ]) - - allow(config).to receive(:stack_conventions_for).with('test-service', 'terragrunt').and_return(['aws/test-service/envs/{environment}']) - allow(config).to receive(:stack_conventions_for).with('test-service', 'kubernetes').and_return(['kubernetes/components/test-service/{environment}']) - - allow(File).to receive(:directory?).and_return(true) - end + def directory(path) + FileUtils.mkdir_p(File.join(@root, path)) + end - it 'generates targets from every matching convention, not just the first' do - result = use_case.execute(deploy_labels: deploy_labels, target_environments: target_environments) + def generate(labels: ['deploy:demo'], environments: []) + use_case.execute(deploy_labels: labels.map { |label| Entities::DeployLabel.new(label) }, target_environments: environments) + end - expect(result).to be_success - expect(result.deployment_targets.length).to eq(2) + def items(**options) + result = generate(**options) + expect(result).to be_success + result.deployment_targets.map(&:to_matrix_item) + end - terragrunt_target = result.deployment_targets.find { |t| t.stack == 'terragrunt' } - kubernetes_target = result.deployment_targets.find { |t| t.stack == 'kubernetes' } + it 'generates every shared product path with attributes from its own environment' do + %w[dystopia/demo/aws/develop dystopia/demo/aws/production system-components/demo/infrastructure/aws/develop system-components/demo/infrastructure/aws/production].each { |path| directory(path) } + rows = items + expect(rows.length).to eq(6) + aws = rows.select { |row| row[:stack_id] == 'aws' } + expect(aws.length).to eq(4) + expect(aws.map { |row| [row[:environment], row[:aws_region]] }.uniq).to contain_exactly(['develop', 'ap-northeast-1'], ['production', 'us-west-2']) + expect(rows.select { |row| row[:stack] == 'container' }).to all(include(environment: nil, repository: 'registry.example.com/app')) + expect(rows).to all(satisfy { |row| !row.key?(:stack_convention_root) }) + end - expect(terragrunt_target).not_to be_nil - expect(terragrunt_target.working_directory).to eq('aws/test-service/envs/production') - expect(terragrunt_target.stack_convention_root).to eq('aws/test-service') + it 'keeps independent identities and attributes distinct' do + config_hash['stacks'] = [ + { 'name' => 'terragrunt', 'id' => 'dystopia-aws', 'paths' => ['dystopia/{service}/aws/{environment}'], 'environments' => { 'develop' => { 'region' => 'a' } } }, + { 'name' => 'terragrunt', 'id' => 'components-aws', 'paths' => ['system-components/{service}/infrastructure/aws/{environment}'], 'environments' => { 'develop' => { 'region' => 'b' } } } + ] + directory('dystopia/demo/aws/develop'); directory('system-components/demo/infrastructure/aws/develop') + expect(items.map { |row| [row[:stack_id], row[:region]] }).to contain_exactly(['dystopia-aws', 'a'], ['components-aws', 'b']) + end - expect(kubernetes_target).not_to be_nil - expect(kubernetes_target.working_directory).to eq('kubernetes/components/test-service/production') - expect(kubernetes_target.stack_convention_root).to eq('kubernetes/components/test-service') - end - end + it 'limits each stack to its declared environments' do + config_hash['stacks'] = [ + { 'name' => 'terragrunt', 'paths' => ['dystopia/{service}/aws/{environment}'], 'environments' => { 'develop' => {} } }, + { 'name' => 'kubernetes', 'paths' => ['dystopia/{service}/k8s/{environment}'], 'environments' => { 'production' => {} } } + ] + %w[aws/develop aws/production k8s/develop k8s/production].each { |suffix| directory("dystopia/demo/#{suffix}") } + expect(items.map { |row| [row[:stack], row[:environment]] }).to contain_exactly(['terragrunt', 'develop'], ['kubernetes', 'production']) + end - context 'when stack_conventions root contains an arbitrary placeholder' do - let(:config_yaml_hash) do - { - 'environments' => [ - { - 'environment' => 'develop', - 'stacks' => { - 'terragrunt' => { - 'aws_region' => 'ap-northeast-1', - 'iam_role_plan' => 'arn:aws:iam::1:role/plan', - 'iam_role_apply' => 'arn:aws:iam::1:role/apply' - } - } - } - ], - 'stack_conventions' => [ - { - 'root' => '{team}/{service}', - 'stacks' => [ - { - 'name' => 'terragrunt', - 'directory' => 'terragrunt/{environment}', - 'required_attributes' => ['aws_region', 'iam_role_plan', 'iam_role_apply'] - } - ] - } - ], - 'services' => [] - } - end - let(:config) { Entities::WorkflowConfig.new(config_yaml_hash) } + it 'reuses an environment-less directory across its declared environments' do + config_hash['stacks'].first['paths'] = ['dystopia/{service}/aws'] + directory('dystopia/demo/aws') + rows = items.select { |row| row[:stack_id] == 'aws' } + expect(rows.map { |row| [row[:working_directory], row[:environment], row[:aws_region]] }).to contain_exactly(['dystopia/demo/aws', 'develop', 'ap-northeast-1'], ['dystopia/demo/aws', 'production', 'us-west-2']) + end - before do - allow(File).to receive(:directory?).and_return(true) - allow(Dir).to receive(:glob).and_call_original - allow(Dir).to receive(:glob).with(/\*\/api\/terragrunt\/develop/, any_args).and_return( - ['payments/api/terragrunt/develop'] - ) - end + it 'generates common targets once in a common-only configuration' do + config_hash['stacks'] = [config_hash['stacks'].last] + directory('dystopia/demo') + expect(items(environments: nil)).to eq([{ service: 'demo', environment: nil, stack: 'container', stack_id: 'container', working_directory: 'dystopia/demo', repository: 'registry.example.com/app' }]) + end - it 'flattens the captured value into matrix items' do - config_client = double('ConfigClient') - allow(config_client).to receive(:load_workflow_config).and_return(config) - labels = [Entities::DeployLabel.from_service(service: 'api')] - result = described_class.new(config_client: config_client).execute( - deploy_labels: labels, - target_environments: ['develop'] - ) - expect(result).to be_success - targets = result.deployment_targets - expect(targets).not_to be_empty - item = targets.first.to_matrix_item - expect(item).to include(service: 'api', team: 'payments') - end - end + it 'discovers unregistered services for all labels and deduplicates overlapping labels' do + directory('dystopia/demo/aws/develop'); directory('dystopia/api/aws/develop'); directory('dystopia/.hidden/aws/develop') + rows = items(labels: ['deploy:all', 'deploy:demo', 'deploy:all']) + expect(rows.length).to eq(4) + expect(rows.map { |row| row[:service] }.uniq).to contain_exactly('demo', 'api') + end - context 'with same stack name in multiple matching conventions' do - let(:target_environments) { ['production'] } - let(:env_config) do - { - 'environment' => 'production', - 'stacks' => { - 'terragrunt' => { - 'aws_region' => 'ap-northeast-1', - 'iam_role_plan' => 'arn:aws:iam::123456789012:role/plan-role', - 'iam_role_apply' => 'arn:aws:iam::123456789012:role/apply-role' - } - } - } - end + it 'resolves every arbitrary placeholder value and exposes captures' do + config_hash['stacks'] = [{ 'name' => 'container', 'paths' => ['teams/{team}/{service}'] }] + directory('teams/platform/demo'); directory('teams/sandbox/demo') + expect(items.map { |row| [row[:team], row[:working_directory]] }).to contain_exactly(['platform', 'teams/platform/demo'], ['sandbox', 'teams/sandbox/demo']) + end - before do - allow(config).to receive(:environment_config).with('production').and_return(env_config) - allow(config).to receive(:stack_attributes_for).with('production', 'terragrunt').and_return(env_config['stacks']['terragrunt']) - allow(config).to receive(:services).and_return({ 'test-service' => {} }) + it 'applies service-only exclusions' do + config_hash['stacks'].each { |stack| stack['exclude'] = [{ 'service' => 'demo' }] } + directory('dystopia/demo/aws/develop') + expect(items).to eq([]) + end - # Two conventions, both contributing terragrunt — first one wins - allow(config).to receive(:stack_conventions_config).and_return([ - { - 'root' => 'aws/{service}', - 'stacks' => [ - { 'name' => 'terragrunt', 'directory' => 'envs/{environment}' } - ] - }, - { - 'root' => 'github/{service}', - 'stacks' => [ - { 'name' => 'terragrunt', 'directory' => 'envs/{environment}' } - ] - } - ]) + it 'applies environment-only exclusions to environment-less paths' do + config_hash['stacks'] = [config_hash['stacks'].first] + config_hash['stacks'].first.merge!('paths' => ['dystopia/{service}/aws'], 'exclude' => [{ 'environment' => 'production' }]) + directory('dystopia/demo/aws') + expect(items.map { |row| row[:environment] }).to eq(['develop']) + end - allow(config).to receive(:stack_conventions_for).with('test-service', 'terragrunt').and_return([ - 'aws/test-service/envs/{environment}', - 'github/test-service/envs/{environment}' - ]) + it 'applies arbitrary conditions with AND within a rule and OR across rules' do + config_hash['stacks'] = [{ 'name' => 'terragrunt', 'paths' => ['teams/{team}/{service}/{environment}'], 'environments' => { 'develop' => {}, 'production' => {} }, 'exclude' => [{ 'team' => 'sandbox' }, { 'team' => 'platform', 'environment' => 'production' }] }] + %w[platform sandbox].each { |team| %w[develop production].each { |env| directory("teams/#{team}/demo/#{env}") } } + expect(items.map { |row| [row[:team], row[:environment]] }).to eq([['platform', 'develop']]) + end - allow(File).to receive(:directory?).and_return(true) - end + it 'does not match missing captures' do + config_hash['stacks'] = [{ 'name' => 'container', 'paths' => ['teams/{team}/{service}', 'dystopia/{service}'], 'exclude' => [{ 'team' => 'sandbox' }] }] + directory('teams/sandbox/demo'); directory('dystopia/demo') + expect(items.map { |row| row[:working_directory] }).to eq(['dystopia/demo']) + end - it 'dedupes by identity (id || name) and generates a single terragrunt target' do - result = use_case.execute(deploy_labels: deploy_labels, target_environments: target_environments) + it 'applies null environment exclusions to common targets' do + config_hash['stacks'] = [{ 'name' => 'container', 'paths' => ['dystopia/{service}'], 'exclude' => [{ 'environment' => nil }] }] + directory('dystopia/demo') + expect(items).to eq([]) + end - expect(result).to be_success - expect(result.deployment_targets.length).to eq(1) - expect(result.deployment_targets.first.stack).to eq('terragrunt') - end - end + it 'deduplicates identical paths and repeated labels' do + config_hash['stacks'] = [{ 'name' => 'container', 'paths' => ['dystopia/{service}', 'dystopia/{service}'] }] + directory('dystopia/demo') + expect(items(labels: ['deploy:demo', 'deploy:demo']).length).to eq(1) end - context 'when {environment} appears in a pattern but target_environment is nil' do - it 'raises UnresolvedPlaceholderError when expand_directory_pattern is called directly' do - config_client = double('ConfigClient') - use_case = described_class.new(config_client: config_client) - expect { - use_case.send(:expand_directory_pattern, 'foo/{environment}/main', 'svc', nil) - }.to raise_error(Entities::UnresolvedPlaceholderError) + [false, true].each do |reverse| + it "rejects conflicting captures before exclusions with reversed paths #{reverse}" do + paths = ['teams/{team}/{service}/aws', 'teams/{product}/{service}/aws'] + paths.reverse! if reverse + config_hash['stacks'] = [{ 'name' => 'terragrunt', 'paths' => paths, 'exclude' => [{ 'team' => 'platform' }] }] + directory('teams/platform/demo/aws') + result = generate + expect(result).to be_failure + expect(result.error_message).to include('Conflicting captures', 'teams/platform/demo/aws') + expect(result.data).not_to have_key(:deployment_targets) end end - describe 'integration with real configuration' do - let(:real_config_client) { Infrastructure::ConfigClient.new } - let(:use_case) { described_class.new(config_client: real_config_client) } - let(:temp_config) { create_test_config(default_test_config) } - let(:deploy_labels) { [build(:deploy_label, :valid_service)] } - - after { temp_config.unlink } - - it 'works with real configuration' do - result = use_case.execute(deploy_labels: deploy_labels, target_environments: ['develop']) - - expect(result).to be_success - expect(result.deployment_targets).not_to be_empty - end + it 'returns failures without partial targets for enumeration errors' do + directory('dystopia/demo/aws/develop') + allow(file_client).to receive(:resolve_directories).and_call_original + allow(file_client).to receive(:resolve_directories).with(pattern: 'system-components/{service}/infrastructure/aws/{environment}', values: { 'service' => 'demo', 'environment' => 'develop' }).and_raise(Errno::EACCES) + result = generate + expect(result).to be_failure + expect(result.error_message).to include('Permission denied') + expect(result.data).not_to have_key(:deployment_targets) end - context 'when a convention has two stacks sharing name but distinct id' do - let(:target_environments) { ['production'] } - let(:env_config) { {'environment'=>'production','stacks'=>{'terragrunt'=>{'aws_region'=>'ap-northeast-1'}}} } - before do - allow(config).to receive(:environment_config).with('production').and_return(env_config) - allow(config).to receive(:stack_attributes_for).and_return(env_config['stacks']['terragrunt']) - allow(config).to receive(:services).and_return({'monolith'=>{}}) - allow(config).to receive(:stack_conventions_config).and_return([{'root'=>'dystopia/{service}','stacks'=>[ - {'name'=>'terragrunt','id'=>'aws','directory'=>'infrastructure/aws/{environment}'}, - {'name'=>'terragrunt','id'=>'stripe','directory'=>'infrastructure/stripe/{environment}'}]}]) - allow(File).to receive(:directory?).and_return(true) - end - it 'generates one target per stack instance' do - labels = [Entities::DeployLabel.from_service(service: 'monolith')] - result = use_case.execute(deploy_labels: labels, target_environments: target_environments) - expect(result).to be_success - expect(result.deployment_targets.length).to eq(2) + it 'returns failures for unknown environments even when no directory exists' do + result = generate(environments: ['preview']) + expect(result).to be_failure + expect(result.error_message).to include('preview') + expect(result.data).not_to have_key(:deployment_targets) + end - dirs = result.deployment_targets.map(&:working_directory).sort - expect(dirs).to eq([ - 'dystopia/monolith/infrastructure/aws/production', - 'dystopia/monolith/infrastructure/stripe/production' - ]) + it 'deduplicates requested environments' do + directory('dystopia/demo/aws/production') + expect(items(environments: %w[production production]).count { |row| row[:stack_id] == 'aws' }).to eq(1) + end - ids = result.deployment_targets.map(&:stack_id).sort - expect(ids).to eq(%w[aws stripe]) + it 'returns successful empty targets when directories are absent' do + result = generate + expect(result.data).to eq(deployment_targets: [], has_deployments: false, total_targets: 0) + end - expect(result.deployment_targets.map(&:stack).uniq).to eq(['terragrunt']) - end + it 'returns no targets for empty or invalid labels' do + directory('dystopia/demo/aws/develop') + expect(items(labels: [])).to eq([]) + expect(items(labels: ['other', 'deploy:demo:invalid'])).to eq([]) end end diff --git a/action-scripts/spec/shared/entities/deployment_target_spec.rb b/action-scripts/spec/shared/entities/deployment_target_spec.rb index a7ec05e..5433cb8 100644 --- a/action-scripts/spec/shared/entities/deployment_target_spec.rb +++ b/action-scripts/spec/shared/entities/deployment_target_spec.rb @@ -1,239 +1,37 @@ -# spec/shared/entities/deployment_target_spec.rb - require 'spec_helper' RSpec.describe Entities::DeploymentTarget do - describe '#initialize' do - context 'with all required core fields' do - it 'creates a target' do - target = described_class.new( - service: 'foo', - stack: 'terragrunt', - working_directory: 'foo/terragrunt/develop', - environment: 'develop', - stack_convention_root: 'foo', - attributes: { 'aws_region' => 'ap-northeast-1' } - ) - expect(target.service).to eq('foo') - expect(target.stack).to eq('terragrunt') - expect(target.working_directory).to eq('foo/terragrunt/develop') - expect(target.environment).to eq('develop') - expect(target.stack_convention_root).to eq('foo') - expect(target.attributes).to eq('aws_region' => 'ap-northeast-1') - end - end - - context 'with environment-agnostic stack' do - it 'allows nil environment' do - target = described_class.new( - service: 'foo', - stack: 'docker', - working_directory: 'foo/workspace', - attributes: {} - ) - expect(target.environment).to be_nil - expect(target.attributes).to eq({}) - end - end - - context 'with empty attributes' do - it 'defaults attributes to empty hash' do - target = described_class.new( - service: 'foo', - stack: 'kubernetes', - working_directory: 'foo/kubernetes/overlays/develop', - environment: 'develop' - ) - expect(target.attributes).to eq({}) - end - end - - context 'when service is missing' do - it 'raises ArgumentError' do - expect { - described_class.new(service: nil, stack: 'terragrunt', working_directory: 'foo') - }.to raise_error(ArgumentError, /service/) - end - end - - context 'when stack is missing' do - it 'raises ArgumentError' do - expect { - described_class.new(service: 'foo', stack: nil, working_directory: 'foo/dir') - }.to raise_error(ArgumentError, /stack/) - end - end - - context 'when working_directory is missing' do - it 'raises ArgumentError' do - expect { - described_class.new(service: 'foo', stack: 'terragrunt', working_directory: nil) - }.to raise_error(ArgumentError, /working_directory/) - end - end - - context 'when service is empty string' do - it 'raises ArgumentError' do - expect { - described_class.new(service: '', stack: 'terragrunt', working_directory: 'foo/dir') - }.to raise_error(ArgumentError, /service/) - end - end - end - - describe '#to_matrix_item' do - it 'returns flat hash merging core attrs and attributes with symbol keys' do - target = described_class.new( - service: 'foo', - stack: 'terragrunt', - stack_id: 'terragrunt', - working_directory: 'foo/terragrunt/develop', - environment: 'develop', - stack_convention_root: 'foo', - attributes: { - 'aws_region' => 'ap-northeast-1', - 'iam_role_plan' => 'arn:aws:iam::123:role/plan', - 'iam_role_apply' => 'arn:aws:iam::123:role/apply' - } - ) - - expect(target.to_matrix_item).to eq( - service: 'foo', - environment: 'develop', - stack: 'terragrunt', - stack_id: 'terragrunt', - working_directory: 'foo/terragrunt/develop', - stack_convention_root: 'foo', - aws_region: 'ap-northeast-1', - iam_role_plan: 'arn:aws:iam::123:role/plan', - iam_role_apply: 'arn:aws:iam::123:role/apply' - ) - end - - it 'omits attribute keys when attributes is empty' do - target = described_class.new( - service: 'foo', - stack: 'kubernetes', - stack_id: 'kubernetes', - working_directory: 'foo/kubernetes/overlays/develop', - environment: 'develop', - stack_convention_root: 'foo' - ) - - expect(target.to_matrix_item).to eq( - service: 'foo', - environment: 'develop', - stack: 'kubernetes', - stack_id: 'kubernetes', - working_directory: 'foo/kubernetes/overlays/develop', - stack_convention_root: 'foo' - ) - end - end + let(:params) { { service: 'demo', stack: 'terragrunt', stack_id: 'aws', working_directory: 'dystopia/demo/aws' } } - describe '#==' do - let(:base_args) do - { - service: 'foo', - stack: 'terragrunt', - working_directory: 'foo/terragrunt/develop', - environment: 'develop' - } - end - - it 'is equal when service / environment / stack / working_directory match' do - a = described_class.new(**base_args, attributes: { 'k' => 'v1' }) - b = described_class.new(**base_args, attributes: { 'k' => 'v2' }) - expect(a).to eq(b) - expect(a.hash).to eq(b.hash) - end - - it 'is not equal when working_directory differs' do - a = described_class.new(**base_args) - b = described_class.new(**base_args.merge(working_directory: 'foo/other')) - expect(a).not_to eq(b) - end + it 'exports five fixed fields with typed attributes and arbitrary captures' do + target = described_class.new(**params, attributes: { 'token' => nil, 'enabled' => true, 'count' => 2 }, captures: { 'team' => 'platform' }) + expect(target.to_matrix_item).to eq(params.merge(environment: nil, token: nil, enabled: true, count: 2, team: 'platform')) end - describe 'captures' do - let(:base_args) do - { - service: 'api', - stack: 'terragrunt', - working_directory: 'payments/api/terragrunt/develop', - environment: 'develop', - stack_convention_root: 'payments/api', - attributes: { 'aws_region' => 'ap-northeast-1' } - } - end - - it 'defaults captures to an empty hash when omitted' do - target = described_class.new(**base_args) - expect(target.captures).to eq({}) - end - - it 'stores captures and exposes them via #captures' do - target = described_class.new(**base_args, captures: { 'team' => 'payments' }) - expect(target.captures).to eq('team' => 'payments') - end - - it 'flattens captures into to_matrix_item with symbol keys' do - target = described_class.new(**base_args, captures: { 'team' => 'payments' }) - expect(target.to_matrix_item).to include( - service: 'api', - environment: 'develop', - stack: 'terragrunt', - working_directory: 'payments/api/terragrunt/develop', - stack_convention_root: 'payments/api', - aws_region: 'ap-northeast-1', - team: 'payments' - ) - end - - it 'raises ArgumentError when a captures key collides with a fixed field' do - expect { - described_class.new(**base_args, captures: { 'stack' => 'oops' }) - }.to raise_error(ArgumentError, /reserved/i) - end - - it 'raises ArgumentError when a captures key collides with an attributes key' do - expect { - described_class.new(**base_args, captures: { 'aws_region' => 'us-east-1' }) - }.to raise_error(ArgumentError, /attributes/i) - end + it 'requires an explicit stack identity' do + expect { described_class.new(**params.reject { |key, _| key == :stack_id }) }.to raise_error(ArgumentError, /stack_id/) + expect { described_class.new(**params.merge(stack_id: '')) }.to raise_error(ArgumentError, /stack_id/) end -end -RSpec.describe Entities::DeploymentTarget do - describe '#stack_id' do - it 'defaults to stack when not provided' do - target = described_class.new(service: 'monolith', stack: 'terragrunt', working_directory: 'dystopia/monolith/infrastructure/aws/production') - expect(target.stack_id).to eq('terragrunt') - end - it 'accepts an explicit stack_id distinct from stack' do - target = described_class.new(service: 'monolith', stack: 'terragrunt', stack_id: 'aws', working_directory: 'dystopia/monolith/infrastructure/aws/production') - expect(target.stack_id).to eq('aws') + %w[service environment stack stack_id working_directory].each do |key| + it "rejects captures colliding with #{key}" do + expect { described_class.new(**params, captures: { key => 'value' }) }.to raise_error(ArgumentError, /collides/) end end - describe '#to_matrix_item' do - it 'exposes stack_id in the matrix output' do - target = described_class.new(service: 'monolith', stack: 'terragrunt', stack_id: 'stripe', working_directory: 'dystopia/monolith/infrastructure/stripe/production') - expect(target.to_matrix_item[:stack_id]).to eq('stripe') - end - it 'exposes stack_id equal to stack when not explicitly set' do - target = described_class.new(service: 'monolith', stack: 'kubernetes', working_directory: 'dystopia/monolith/kubernetes/overlays/production') - expect(target.to_matrix_item[:stack_id]).to eq('kubernetes') - end + it 'rejects captures colliding with attributes' do + expect { described_class.new(**params, attributes: { 'team' => 'a' }, captures: { 'team' => 'b' }) }.to raise_error(ArgumentError, /attributes/) end - describe 'FIXED_RESERVED_KEYS' do - it 'includes stack_id so captures cannot collide with it' do - expect(Entities::DeploymentTarget::FIXED_RESERVED_KEYS).to include('stack_id') - end - it 'raises when captures collide with stack_id' do - expect { described_class.new(service: 'foo', stack: 'terragrunt', working_directory: 'foo/bar', captures: { 'stack_id' => 'boom' }) } - .to raise_error(ArgumentError, /reserved DeploymentTarget field/) + it 'uses service identity environment and directory for equality and hashing' do + first = described_class.new(**params) + second = described_class.new(**params, attributes: { 'region' => 'other' }) + expect(first).to eq(second) + expect(first.hash).to eq(second.hash) + expect([first, second].uniq.length).to eq(1) + [{ stack_id: 'other' }, { service: 'api' }, { environment: 'develop' }, { working_directory: 'other/demo' }].each do |changes| + expect(first).not_to eq(described_class.new(**params.merge(changes))) end + expect(first).not_to eq(nil) end end diff --git a/docs/superpowers/plans/stack-configuration.md b/docs/superpowers/plans/stack-configuration.md index 1c56dc9..7f09cda 100644 --- a/docs/superpowers/plans/stack-configuration.md +++ b/docs/superpowers/plans/stack-configuration.md @@ -271,7 +271,7 @@ Run: `git commit -s -m 'feat: resolve stack paths from repository directories'` - Produces: `DeploymentTarget#to_matrix_item` → Symbol キーの固定5フィールドと、属性・任意の抽出値を展開したマップ。`#==`・`#eql?`・`#hash` の識別要素は service・stack_id・environment・working_directory。 - Produces: `LabelResolverCLI#parse_environments(env_string)` → 入力省略・空白だけなら `[]`、それ以外は comma 分割して strip した文字列配列。環境名の検証は use case に任せる。 -- [ ] **Step 1: Write environment, target, and matrix behavior tests** +- [x] **Step 1: Write environment, target, and matrix behavior tests** 環境選択は入力 nil・空配列・重複・未知名・環境共通だけの設定をテストする。`['production', 'production']` の結果が `['production']`、未知名 `preview` が failure、共通だけの構成の既定値が `[]` になることを確認する。 @@ -293,13 +293,13 @@ matrix の spec は一時リポジトリと実モデルを使う。生成対象 `DeploymentTarget` の spec は固定5キー、属性値の型、capture の衝突、stack_id が異なる対象の不一致、同一対象の `hash` 一致を assertion にする。CLI の spec は bin を require してもコマンドが起動しないこと、nil・空白・`' develop,production '` の解析をテストする。controller の spec は検証済み環境配列を生成と表示へ渡すことを確認する。 -- [ ] **Step 2: Confirm the resolver tests fail** +- [x] **Step 2: Confirm the resolver tests fail** Run: `bundle exec rspec spec/shared/entities/deployment_target_spec.rb spec/label-resolver` Expected: 新しい constructor、環境選択、複数パス、除外、固定5キーの assertion が FAIL。 -- [ ] **Step 3: Implement target generation from stack definitions** +- [x] **Step 3: Implement target generation from stack definitions** `application.rb` から `FileSystemClient` を注入する。`DetermineTargetEnvironment` は `environment_names` を使い、`GenerateMatrix` も直接呼び出された場合に環境名を検証する。matrix の入力 nil・空配列も全環境として扱い、既定値・重複除去・未知名の判定は両 use case で同じ値になるようテストする。 @@ -309,11 +309,11 @@ matrix は stack ごとに選択環境と定義環境の共通部分、または 旧 convention の探索、属性の名前への fallback、最初のパスへの収束、サービス登録、root の推測、旧ディレクトリ解決の補助メソッドを削除する。`DeploymentTarget` と factory の `stack_convention_root` を削除し、同一性を stack_id に揃える。bin の実行末尾を `$PROGRAM_NAME == __FILE__` で guard し、環境一覧の既定値を補う `all_environments` を削除する。 -- [ ] **Step 4: Confirm the resolver tests pass** +- [x] **Step 4: Confirm the resolver tests pass** Step 2 と同じコマンドを実行する。Expected: exit 0、`0 failures`。行数・属性・capture を実在ディレクトリで確認し、旧メソッドの mock が残っていない時点で完了とする。 -- [ ] **Step 5: Commit environment selection and matrix generation** +- [x] **Step 5: Commit environment selection and matrix generation** Files の変更を stage して `git diff --cached --check` を実行する。 From 59dc03d725a1af45b398ac3c071b99cbb255824b Mon Sep 17 00:00:00 2001 From: panicboat Date: Fri, 9 Oct 2026 09:50:36 +0900 Subject: [PATCH 08/13] feat: apply stack exclusions to changed service matches Signed-off-by: panicboat --- .../label_dispatcher_controller.rb | 6 +- .../use_cases/detect_changed_services.rb | 126 +--- .../presenters/console_presenter.rb | 6 +- .../presenters/github_actions_presenter.rb | 7 +- .../label_dispatcher_controller_spec.rb | 11 +- .../use_cases/detect_changed_services_spec.rb | 552 +++--------------- .../presenters/console_presenter_spec.rb | 13 + .../github_actions_presenter_spec.rb | 35 ++ docs/superpowers/plans/stack-configuration.md | 10 +- 9 files changed, 172 insertions(+), 594 deletions(-) create mode 100644 action-scripts/spec/shared/interfaces/presenters/console_presenter_spec.rb create mode 100644 action-scripts/spec/shared/interfaces/presenters/github_actions_presenter_spec.rb diff --git a/action-scripts/label-dispatcher/controllers/label_dispatcher_controller.rb b/action-scripts/label-dispatcher/controllers/label_dispatcher_controller.rb index c928140..f1301ed 100644 --- a/action-scripts/label-dispatcher/controllers/label_dispatcher_controller.rb +++ b/action-scripts/label-dispatcher/controllers/label_dispatcher_controller.rb @@ -50,8 +50,7 @@ def dispatch_labels(pr_number:) deploy_labels: detection_result.deploy_labels, labels_added: labels_added, labels_removed: labels_removed, - changed_files: detection_result.changed_files, - excluded_services: detection_result.excluded_services || [] + changed_files: detection_result.changed_files ) end @@ -66,8 +65,7 @@ def test_detection(base_ref: nil, head_ref: nil) deploy_labels: detection_result.deploy_labels, labels_added: [], labels_removed: [], - changed_files: detection_result.changed_files, - excluded_services: detection_result.excluded_services || [] + changed_files: detection_result.changed_files ) end diff --git a/action-scripts/label-dispatcher/use_cases/detect_changed_services.rb b/action-scripts/label-dispatcher/use_cases/detect_changed_services.rb index b3a06d7..5a6e1bf 100644 --- a/action-scripts/label-dispatcher/use_cases/detect_changed_services.rb +++ b/action-scripts/label-dispatcher/use_cases/detect_changed_services.rb @@ -1,6 +1,3 @@ -# Use case for detecting changed services from file modifications -# Analyzes git diff output to determine which services need deployment - module UseCases module LabelManagement class DetectChangedServices @@ -9,108 +6,41 @@ def initialize(file_client:, config_client:) @config_client = config_client end - # Execute service detection based on changed files def execute(base_ref: nil, head_ref: nil) config = @config_client.load_workflow_config changed_files = @file_client.get_changed_files(base_ref: base_ref, head_ref: head_ref) + candidates = {} + config.stacks.each do |stack| + stack['paths'].each do |pattern| + changed_files.each do |file| + captures = Entities::PatternMatcher.extract_prefix(pattern, file) + next unless captures + service = captures.fetch('service') + next if service.start_with?('.') + environments = stack.key?('environments') ? stack['environments'].keys : [nil] + environments &= [captures['environment']] if captures.key?('environment') + directory = Entities::PatternMatcher.expand(pattern, captures) + custom = captures.reject { |key, _| %w[service environment].include?(key) } + environments.each do |environment| + identity = [service, stack['id'], environment, directory] + if candidates.key?(identity) && candidates[identity][:captures] != custom + raise "Conflicting captures for stack '#{stack['id']}' at '#{directory}'" + end + candidates[identity] = { stack: stack, service: service, environment: environment, captures: custom } + end + end + end + end - # Discover all services from file changes using pattern matching - all_discovered_services = discover_services_from_patterns(changed_files, config) - - # Filter out excluded services - filtered_services = filter_excluded_services(all_discovered_services, config, changed_files) - excluded_services = all_discovered_services - filtered_services - - # Generate deploy labels for non-excluded services - deploy_labels = filtered_services.map { |service| Entities::DeployLabel.from_service(service: service) } - - # Log excluded services if any - log_excluded_services(excluded_services, config) if excluded_services.any? - - Entities::Result.success( - deploy_labels: deploy_labels, - changed_files: changed_files, - services_detected: filtered_services, - excluded_services: excluded_services, - total_services_discovered: all_discovered_services.length - ) + services = candidates.values.filter_map do |candidate| + values = candidate.fetch(:captures).merge('service' => candidate.fetch(:service), 'environment' => candidate.fetch(:environment)) + candidate.fetch(:service) unless config.excluded?(candidate.fetch(:stack), values) + end.uniq + labels = services.map { |service| Entities::DeployLabel.from_service(service: service) } + Entities::Result.success(deploy_labels: labels, changed_files: changed_files, services_detected: services) rescue => error Entities::Result.failure(error_message: error.message) end - - private - - # Filter out services that are excluded from automation - def filter_excluded_services(discovered_services, config, changed_files) - discovered_services.reject do |service| - excluded_from_automation?(service, config, changed_files) - end - end - - # Check if a service is excluded from automation - def excluded_from_automation?(service, config, changed_files) - # Check if service is in excluded_services list - return true if config.excluded_services.include?(service) - - # Check if service config has exclude_from_automation flag - service_config = config.services[service] - return false unless service_config - - return true if service_config['exclude_from_automation'] == true - - false - end - - # Log excluded services for visibility - def log_excluded_services(excluded_services, config) - return if excluded_services.empty? - - puts "⚠️ Services excluded from automation (#{excluded_services.length}):" - excluded_services.each do |service| - service_config = config.services[service] - exclusion_config = service_config&.[]('exclusion_config') || {} - reason = exclusion_config['reason'] || 'No reason specified' - type = exclusion_config['type'] || 'unspecified' - - puts " - #{service} (#{type}): #{reason}" - end - end - - # Discover services from changed files and configuration - def discover_services_from_patterns(changed_files, config) - services = Set.new - - # Get all possible directory patterns from configuration - patterns = config.all_directory_patterns - - patterns.each do |pattern| - pattern_services = discover_services_from_pattern(changed_files, pattern) - services.merge(pattern_services) - end - - services.to_a.reject { |service| service.start_with?('.') } - end - - private - - # Discover services by matching changed files against a directory - # pattern. Uses PatternMatcher.extract_prefix so arbitrary placeholders - # ({team}, {region}, etc.) match correctly even though only {service} - # is propagated downstream from this layer. - def discover_services_from_pattern(changed_files, pattern) - services = Set.new - changed_files.each do |file| - captures = Entities::PatternMatcher.extract_prefix(pattern, file) - next unless captures - - service_name = captures['service'] - next if service_name.nil? || service_name.start_with?('.') - - services << service_name - end - services - end - end end end diff --git a/action-scripts/shared/interfaces/presenters/console_presenter.rb b/action-scripts/shared/interfaces/presenters/console_presenter.rb index 1a8cd4e..ff2542f 100644 --- a/action-scripts/shared/interfaces/presenters/console_presenter.rb +++ b/action-scripts/shared/interfaces/presenters/console_presenter.rb @@ -5,16 +5,12 @@ module Interfaces module Presenters class ConsolePresenter # Present label dispatch results - def present_label_dispatch_result(deploy_labels:, labels_added:, labels_removed:, changed_files:, excluded_services: []) + def present_label_dispatch_result(deploy_labels:, labels_added:, labels_removed:, changed_files:) puts "🏷️ Label Dispatch Results".colorize(:blue) puts "Deploy Labels: #{deploy_labels.map(&:to_s).join(', ')}" puts "Labels Added: #{labels_added.join(', ')}" if labels_added.any? puts "Labels Removed: #{labels_removed.join(', ')}" if labels_removed.any? - if excluded_services.any? - puts "Excluded Services: #{excluded_services.join(', ')}".colorize(:yellow) - end - puts "Changed Files: #{changed_files.length} files" if changed_files.length <= 10 diff --git a/action-scripts/shared/interfaces/presenters/github_actions_presenter.rb b/action-scripts/shared/interfaces/presenters/github_actions_presenter.rb index 0dcc277..c09bc3f 100644 --- a/action-scripts/shared/interfaces/presenters/github_actions_presenter.rb +++ b/action-scripts/shared/interfaces/presenters/github_actions_presenter.rb @@ -5,7 +5,7 @@ module Interfaces module Presenters class GitHubActionsPresenter # Present label dispatch results for GitHub Actions - def present_label_dispatch_result(deploy_labels:, labels_added:, labels_removed:, changed_files:, excluded_services: []) + def present_label_dispatch_result(deploy_labels:, labels_added:, labels_removed:, changed_files:) set_environment_variables( 'DEPLOY_LABELS' => deploy_labels.map(&:to_s).to_json, 'LABELS_ADDED' => labels_added.to_json, @@ -13,8 +13,6 @@ def present_label_dispatch_result(deploy_labels:, labels_added:, labels_removed: 'HAS_CHANGES' => deploy_labels.any?.to_s, 'CHANGED_FILES' => changed_files.to_json, 'SERVICES_DETECTED' => deploy_labels.map(&:service).uniq.to_json, - 'EXCLUDED_SERVICES' => excluded_services.to_json, - 'HAS_EXCLUDED_SERVICES' => excluded_services.any?.to_s ) set_action_outputs( @@ -23,15 +21,12 @@ def present_label_dispatch_result(deploy_labels:, labels_added:, labels_removed: 'labels-removed' => labels_removed.to_json, 'services-detected' => deploy_labels.map(&:service).uniq.to_json, 'has-changes' => deploy_labels.any?.to_s, - 'excluded-services' => excluded_services.to_json, - 'has-excluded-services' => excluded_services.any?.to_s ) puts "🏷️ Label Dispatch Completed" puts "Deploy Labels: #{deploy_labels.map(&:to_s).join(', ')}" puts "Labels Added: #{labels_added.join(', ')}" if labels_added.any? puts "Labels Removed: #{labels_removed.join(', ')}" if labels_removed.any? - puts "Excluded Services: #{excluded_services.join(', ')}" if excluded_services.any? end # Present deployment matrix for GitHub Actions diff --git a/action-scripts/spec/label-dispatcher/controllers/label_dispatcher_controller_spec.rb b/action-scripts/spec/label-dispatcher/controllers/label_dispatcher_controller_spec.rb index 456f8ec..7d6a6d9 100644 --- a/action-scripts/spec/label-dispatcher/controllers/label_dispatcher_controller_spec.rb +++ b/action-scripts/spec/label-dispatcher/controllers/label_dispatcher_controller_spec.rb @@ -19,7 +19,6 @@ let(:pr_number) { 123 } let(:deploy_labels) { [build(:deploy_label, :valid_service)] } let(:changed_files) { ['services/test-service/main.tf'] } - let(:excluded_services) { ['excluded-service'] } context 'with successful detection and no GitHub Actions' do let(:detection_result) do @@ -29,7 +28,6 @@ failure?: false, deploy_labels: deploy_labels, changed_files: changed_files, - excluded_services: excluded_services ) end @@ -52,7 +50,6 @@ labels_added: [], labels_removed: [], changed_files: changed_files, - excluded_services: excluded_services ) end end @@ -65,7 +62,6 @@ failure?: false, deploy_labels: deploy_labels, changed_files: changed_files, - excluded_services: excluded_services ) end let(:manage_result) do @@ -100,7 +96,6 @@ labels_added: ['deploy:test-service'], labels_removed: ['deploy:old-service'], changed_files: changed_files, - excluded_services: excluded_services ) end end @@ -132,7 +127,6 @@ failure?: false, deploy_labels: deploy_labels, changed_files: changed_files, - excluded_services: excluded_services ) end let(:manage_result) { double('Result', success?: false, failure?: true) } @@ -160,7 +154,6 @@ failure?: false, deploy_labels: deploy_labels, changed_files: changed_files, - excluded_services: [] ) end @@ -197,7 +190,6 @@ failure?: false, deploy_labels: [build(:deploy_label, :valid_service)], changed_files: ['test-file.tf'], - excluded_services: [] ) end @@ -218,7 +210,6 @@ labels_added: [], labels_removed: [], changed_files: detection_result.changed_files, - excluded_services: detection_result.excluded_services ) end @@ -393,4 +384,4 @@ end end -end \ No newline at end of file +end diff --git a/action-scripts/spec/label-dispatcher/use_cases/detect_changed_services_spec.rb b/action-scripts/spec/label-dispatcher/use_cases/detect_changed_services_spec.rb index b9fc575..4555ff0 100644 --- a/action-scripts/spec/label-dispatcher/use_cases/detect_changed_services_spec.rb +++ b/action-scripts/spec/label-dispatcher/use_cases/detect_changed_services_spec.rb @@ -1,490 +1,110 @@ -# spec/label-dispatcher/use_cases/detect_changed_services_spec.rb - require 'spec_helper' RSpec.describe UseCases::LabelManagement::DetectChangedServices do - let(:file_client) { double('FileSystemClient') } - let(:config_client) { double('ConfigClient') } - let(:config) { build(:workflow_config) } - - subject(:use_case) do - described_class.new( - file_client: file_client, - config_client: config_client - ) + let(:stack) { { 'name' => 'terragrunt', 'paths' => ['teams/{team}/{service}/aws/{environment}'], 'environments' => { 'develop' => {}, 'production' => {} }, 'exclude' => [{ 'team' => 'platform', 'service' => 'demo', 'environment' => 'production' }] } } + let(:config_hash) { { 'stacks' => [stack] } } + let(:config) { Entities::WorkflowConfig.new(config_hash) } + let(:config_client) { instance_double(Infrastructure::ConfigClient, load_workflow_config: config) } + let(:changed_files) { ['teams/platform/demo/aws/production/main.tf'] } + let(:file_client) { instance_double(Infrastructure::FileSystemClient, get_changed_files: changed_files) } + subject(:use_case) { described_class.new(file_client: file_client, config_client: config_client) } + + def services + result = use_case.execute + expect(result).to be_success + result.services_detected end - before do - allow(config_client).to receive(:load_workflow_config).and_return(config) + it 'omits services whose only match satisfies every exclusion condition' do + expect(services).to eq([]) end - describe '#execute' do - let(:base_ref) { 'main' } - let(:head_ref) { 'feature/test' } - let(:changed_files) { ['services/auth/terragrunt/main.tf', 'services/payment/kubernetes/deployment.yaml'] } - - context 'with valid changed files' do - before do - allow(file_client).to receive(:get_changed_files).with(base_ref: base_ref, head_ref: head_ref).and_return(changed_files) - allow(config).to receive(:all_directory_patterns).and_return([ - 'services/{service}', - 'services/{service}/terragrunt/{environment}', - 'services/{service}/kubernetes/{environment}' - ]) - allow(config).to receive(:services).and_return({ - 'auth' => { 'name' => 'auth' }, - 'payment' => { 'name' => 'payment' } - }) - allow(config).to receive(:excluded_services).and_return([]) - end - - it 'detects changed services from file paths' do - result = use_case.execute(base_ref: base_ref, head_ref: head_ref) - - expect(result).to be_success - expect(result.deploy_labels.map(&:to_s)).to contain_exactly('deploy:auth', 'deploy:payment') - expect(result.changed_files).to eq(changed_files) - expect(result.excluded_services).to be_empty - end - end - - context 'with excluded services' do - let(:changed_files) { ['services/auth/terragrunt/main.tf', 'services/excluded-service/terragrunt/main.tf'] } - - before do - allow(file_client).to receive(:get_changed_files).and_return(changed_files) - allow(config).to receive(:all_directory_patterns).and_return([ - 'services/{service}', - 'services/{service}/terragrunt/{environment}' - ]) - allow(config).to receive(:services).and_return({ - 'auth' => { 'name' => 'auth' }, - 'excluded-service' => { 'name' => 'excluded-service' } - }) - allow(config).to receive(:excluded_services).and_return(['excluded-service']) - end - - it 'excludes services from automation but includes in results' do - result = use_case.execute(base_ref: base_ref, head_ref: head_ref) - - expect(result).to be_success - expect(result.deploy_labels.map(&:to_s)).to contain_exactly('deploy:auth') - expect(result.excluded_services).to contain_exactly('excluded-service') - end - end - - context 'with no matching files' do - let(:changed_files) { ['README.md', 'non-service/guide.md'] } - - before do - allow(file_client).to receive(:get_changed_files).and_return(changed_files) - allow(config).to receive(:all_directory_patterns).and_return([ - 'services/{service}', - 'services/{service}/terragrunt/{environment}' - ]) - allow(config).to receive(:services).and_return({}) - allow(config).to receive(:excluded_services).and_return([]) - end - - it 'returns empty deploy labels' do - result = use_case.execute(base_ref: base_ref, head_ref: head_ref) - - expect(result).to be_success - expect(result.deploy_labels).to be_empty - expect(result.changed_files).to eq(changed_files) - expect(result.excluded_services).to be_empty - end - end - - context 'with complex directory patterns' do - let(:changed_files) do - [ - 'infrastructures/auth/terragrunt/envs/develop/main.tf', - 'apps/frontend/kubernetes/overlays/develop/deployment.yaml', - 'services/legacy/old-structure/main.tf' - ] - end - - before do - allow(file_client).to receive(:get_changed_files).and_return(changed_files) - - # Mock custom directory conventions - allow(config).to receive(:stack_convention_for).with('auth', 'terragrunt').and_return('infrastructures/auth/terragrunt') - allow(config).to receive(:stack_convention_for).with('frontend', 'kubernetes').and_return('apps/frontend/kubernetes') - allow(config).to receive(:stack_convention_for).with('legacy', anything).and_return(nil) - - allow(config).to receive(:services).and_return({ - 'auth' => { 'name' => 'auth' }, - 'frontend' => { 'name' => 'frontend' }, - 'legacy' => { 'name' => 'legacy' } - }) - allow(config).to receive(:send).with(:directory_stacks).and_return([ - { 'name' => 'terragrunt', 'directory' => 'infrastructures/{service}/terragrunt/{environment}' }, - { 'name' => 'kubernetes', 'directory' => 'apps/{service}/kubernetes/overlays/{environment}' } - ]) - allow(config).to receive(:stack_conventions).and_return({ - 'root' => '', # Empty root for custom patterns - 'stacks' => [ - { 'name' => 'terragrunt', 'directory' => 'infrastructures/{service}/terragrunt/{environment}' }, - { 'name' => 'kubernetes', 'directory' => 'apps/{service}/kubernetes/overlays/{environment}' } - ] - }) - allow(config).to receive(:stack_conventions_config).and_return([ - { - 'root' => '', - 'stacks' => [ - { 'name' => 'terragrunt', 'directory' => 'infrastructures/{service}/terragrunt/{environment}' }, - { 'name' => 'kubernetes', 'directory' => 'apps/{service}/kubernetes/overlays/{environment}' } - ] - } - ]) - allow(config).to receive(:excluded_services).and_return([]) - end - - it 'matches services with custom directory patterns' do - result = use_case.execute(base_ref: base_ref, head_ref: head_ref) - - expect(result).to be_success - expect(result.deploy_labels.map(&:to_s)).to contain_exactly('deploy:auth', 'deploy:frontend') - end - end - - context 'when file client fails' do - let(:error) { StandardError.new('Git command failed') } - - before do - allow(file_client).to receive(:get_changed_files).and_raise(error) - end - - it 'returns failure result' do - result = use_case.execute(base_ref: base_ref, head_ref: head_ref) - - expect(result).to be_failure - expect(result.error_message).to include('Git command failed') - end - end - - context 'when config loading fails' do - let(:error) { StandardError.new('Config file not found') } - - before do - allow(config_client).to receive(:load_workflow_config).and_raise(error) - end - - it 'returns failure result' do - result = use_case.execute(base_ref: base_ref, head_ref: head_ref) - - expect(result).to be_failure - expect(result.error_message).to include('Config file not found') - end - end - - context 'with nil refs (working directory comparison)' do - before do - allow(file_client).to receive(:get_changed_files).with(base_ref: nil, head_ref: nil).and_return(changed_files) - allow(config).to receive(:stack_convention_for).and_return('services/auth/terragrunt') - allow(config).to receive(:services).and_return({'auth' => { 'name' => 'auth' }}) - allow(config).to receive(:send).with(:directory_stacks).and_return([ - { 'name' => 'terragrunt', 'directory' => 'terragrunt/{environment}' } - ]) - allow(config).to receive(:excluded_services).and_return([]) - end - - it 'handles nil refs for working directory comparison' do - result = use_case.execute(base_ref: nil, head_ref: nil) - - expect(result).to be_success - expect(file_client).to have_received(:get_changed_files).with(base_ref: nil, head_ref: nil) - end - end - - context 'with service name extraction edge cases' do - let(:changed_files) do - [ - 'services/my-service-name/main.tf', - 'services/service_with_underscores/main.tf', - 'services/123-numeric-service/main.tf', - 'not-a-service-path/main.tf' - ] - end - - before do - allow(file_client).to receive(:get_changed_files).and_return(changed_files) - allow(config).to receive(:stack_convention_for).with('my-service-name', 'terragrunt').and_return('services/{service}') - allow(config).to receive(:stack_convention_for).with('service_with_underscores', 'terragrunt').and_return('services/{service}') - allow(config).to receive(:stack_convention_for).with('123-numeric-service', 'terragrunt').and_return('services/{service}') - allow(config).to receive(:services).and_return({ - 'my-service-name' => { 'name' => 'my-service-name' }, - 'service_with_underscores' => { 'name' => 'service_with_underscores' }, - '123-numeric-service' => { 'name' => '123-numeric-service' } - }) - allow(config).to receive(:send).with(:directory_stacks).and_return([ - { 'name' => 'terragrunt', 'directory' => '' } - ]) - allow(config).to receive(:stack_conventions).and_return({ - 'root' => 'services/{service}', - 'stacks' => [ - { 'name' => 'terragrunt', 'directory' => '' } - ] - }) - allow(config).to receive(:stack_conventions_config).and_return([ - { - 'root' => 'services/{service}', - 'stacks' => [ - { 'name' => 'terragrunt', 'directory' => '' } - ] - } - ]) - allow(config).to receive(:excluded_services).and_return([]) - end - - it 'handles various service naming patterns' do - result = use_case.execute(base_ref: base_ref, head_ref: head_ref) - - expect(result).to be_success - service_names = result.deploy_labels.map { |label| label.to_s.sub('deploy:', '') } - expect(service_names).to include('my-service-name', 'service_with_underscores', '123-numeric-service') - end - end - - context 'with multiple stacks for same service' do - let(:changed_files) do - [ - 'services/auth/terragrunt/main.tf', - 'services/auth/kubernetes/overlays/develop/deployment.yaml' - ] - end - - before do - allow(file_client).to receive(:get_changed_files).and_return(changed_files) - allow(config).to receive(:stack_convention_for).with('auth', 'terragrunt').and_return('services/{service}/terragrunt') - allow(config).to receive(:stack_convention_for).with('auth', 'kubernetes').and_return('services/{service}/kubernetes') - allow(config).to receive(:services).and_return({ - 'auth' => { 'name' => 'auth' } - }) - allow(config).to receive(:send).with(:directory_stacks).and_return([ - { 'name' => 'terragrunt', 'directory' => 'terragrunt/{environment}' }, - { 'name' => 'kubernetes', 'directory' => 'kubernetes/overlays/{environment}' } - ]) - allow(config).to receive(:stack_conventions).and_return({ - 'root' => 'services/{service}', - 'stacks' => [ - { 'name' => 'terragrunt', 'directory' => 'terragrunt/{environment}' }, - { 'name' => 'kubernetes', 'directory' => 'kubernetes/overlays/{environment}' } - ] - }) - allow(config).to receive(:stack_conventions_config).and_return([ - { - 'root' => 'services/{service}', - 'stacks' => [ - { 'name' => 'terragrunt', 'directory' => 'terragrunt/{environment}' }, - { 'name' => 'kubernetes', 'directory' => 'kubernetes/overlays/{environment}' } - ] - } - ]) - allow(config).to receive(:excluded_services).and_return([]) - end - - it 'creates single deploy label for service with multiple stack changes' do - result = use_case.execute(base_ref: base_ref, head_ref: head_ref) - - expect(result).to be_success - expect(result.deploy_labels.map(&:to_s)).to eq(['deploy:auth']) - end - end - - context 'with non-existent service in configuration' do - let(:changed_files) { ['unknown-path/unknown-service/main.tf'] } - - before do - allow(file_client).to receive(:get_changed_files).and_return(changed_files) - allow(config).to receive(:all_directory_patterns).and_return([ - 'services/{service}', - 'services/{service}/terragrunt/{environment}' - ]) - allow(config).to receive(:services).and_return({}) - allow(config).to receive(:excluded_services).and_return([]) - end - - it 'skips services not in configuration' do - result = use_case.execute(base_ref: base_ref, head_ref: head_ref) - - expect(result).to be_success - expect(result.deploy_labels).to be_empty - end - end - - context 'with root directory file changes' do - let(:changed_files) do - [ - 'auth/README.md', - 'auth/src/main.py', - 'payment/package.json', - 'payment/terragrunt/develop/main.tf' - ] - end - - before do - allow(file_client).to receive(:get_changed_files).and_return(changed_files) - allow(config).to receive(:services).and_return({ - 'auth' => { 'name' => 'auth' }, - 'payment' => { 'name' => 'payment' } - }) - allow(config).to receive(:excluded_services).and_return([]) - end - - it 'detects services from root directory file changes' do - result = use_case.execute(base_ref: base_ref, head_ref: head_ref) - - expect(result).to be_success - expect(result.deploy_labels.map(&:to_s)).to contain_exactly('deploy:auth', 'deploy:payment') - expect(result.changed_files).to eq(changed_files) - end - end - - context 'with mixed root and stack file changes' do - let(:changed_files) do - [ - 'auth/README.md', # Root directory change - 'payment/terragrunt/develop/main.tf' # Stack directory change - ] - end - - before do - allow(file_client).to receive(:get_changed_files).and_return(changed_files) - allow(config).to receive(:services).and_return({ - 'auth' => { 'name' => 'auth' }, - 'payment' => { 'name' => 'payment' } - }) - allow(config).to receive(:excluded_services).and_return([]) - end - - it 'detects services from both root and stack changes' do - result = use_case.execute(base_ref: base_ref, head_ref: head_ref) - - expect(result).to be_success - expect(result.deploy_labels.map(&:to_s)).to contain_exactly('deploy:auth', 'deploy:payment') - end - end - - context 'with a directory pattern containing an arbitrary placeholder beyond {service} and {environment}' do - let(:changed_files) { ['payments/api/terragrunt/develop/main.tf'] } - - before do - allow(file_client).to receive(:get_changed_files).with(base_ref: base_ref, head_ref: head_ref).and_return(changed_files) - allow(config).to receive(:all_directory_patterns).and_return([ - '{team}/{service}/terragrunt/{environment}' - ]) - allow(config).to receive(:services).and_return({}) - allow(config).to receive(:excluded_services).and_return([]) - end - - it 'still detects the service from the changed file path' do - result = use_case.execute(base_ref: base_ref, head_ref: head_ref) - expect(result).to be_success - expect(result.services_detected).to include('api') - end - end + it 'keeps services with a non-excluded environment match' do + changed_files << 'teams/platform/demo/aws/develop/main.tf' + result = use_case.execute(base_ref: 'base', head_ref: 'head') + expect(result.data.keys).to contain_exactly(:deploy_labels, :changed_files, :services_detected) + expect(result.deploy_labels.map(&:to_s)).to eq(['deploy:demo']) + expect(result.changed_files).to eq(changed_files) + expect(file_client).to have_received(:get_changed_files).with(base_ref: 'base', head_ref: 'head') end - describe 'service detection logic' do - let(:base_ref) { 'main' } - let(:head_ref) { 'feature/test' } + it 'detects only declared stack paths without an implicit service root' do + changed_files.replace(['teams/platform/demo/README.md']) + expect(services).to eq([]) + end - before do - allow(config_client).to receive(:load_workflow_config).and_return(config) - allow(config).to receive(:excluded_services).and_return([]) - end + it 'ignores undeclared environments' do + changed_files.replace(['teams/platform/demo/aws/preview/main.tf']) + expect(services).to eq([]) + end - context 'with default directory conventions' do - let(:changed_files) { ['auth/terragrunt/envs/develop/main.tf'] } + it 'evaluates every environment for paths without environment' do + stack['paths'] = ['teams/{team}/{service}/aws'] + expect(services).to eq(['demo']) + stack['exclude'] = [{ 'environment' => 'develop' }, { 'environment' => 'production' }] + allow(config_client).to receive(:load_workflow_config).and_return(Entities::WorkflowConfig.new(config_hash)) + expect(services).to eq([]) + end - before do - allow(file_client).to receive(:get_changed_files).and_return(changed_files) - allow(config).to receive(:stack_convention_for).with('auth', 'terragrunt').and_return('{service}/terragrunt/envs/{environment}') - allow(config).to receive(:services).and_return({ - 'auth' => { 'name' => 'auth' } - }) - allow(config).to receive(:send).with(:directory_stacks).and_return([ - { 'name' => 'terragrunt', 'directory' => 'terragrunt/{environment}' } - ]) - end + it 'keeps services with a non-excluded stack match' do + config_hash['stacks'] << { 'name' => 'container', 'paths' => ['teams/{team}/{service}'] } + expect(services).to eq(['demo']) + end - it 'detects service using default pattern' do - result = use_case.execute(base_ref: base_ref, head_ref: head_ref) + it 'does not exclude paths missing custom captures' do + stack['paths'] << 'dystopia/{service}/aws/{environment}' + changed_files << 'dystopia/demo/aws/production/main.tf' + expect(services).to eq(['demo']) + end - expect(result).to be_success - expect(result.deploy_labels.map(&:to_s)).to include('deploy:auth') - end - end + it 'detects common stacks with null environment' do + config_hash['stacks'] = [{ 'name' => 'container', 'paths' => ['dystopia/{service}'], 'exclude' => [{ 'service' => 'demo', 'environment' => nil }] }] + changed_files.replace(['dystopia/demo/main.rb', 'dystopia/api/main.rb']) + expect(services).to eq(['api']) + end - context 'with service-specific directory conventions' do - let(:changed_files) { ['infrastructures/special-service/terraform/main.tf'] } + it 'uses AND within conditions and OR across conditions' do + stack['exclude'] = [{ 'team' => 'platform', 'service' => 'demo' }, { 'team' => 'sandbox' }] + changed_files.replace(['teams/platform/api/aws/develop/main.tf', 'teams/sandbox/api/aws/develop/main.tf', 'teams/platform/demo/aws/develop/main.tf']) + expect(services).to eq(['api']) + end - before do - allow(file_client).to receive(:get_changed_files).and_return(changed_files) - allow(config).to receive(:stack_convention_for).with('special-service', 'terragrunt').and_return('infrastructures/{service}/terraform') - allow(config).to receive(:services).and_return({ - 'special-service' => { - 'name' => 'special-service', - 'stack_conventions' => { - 'terragrunt' => 'infrastructures/{service}/terraform' - } - } - }) - allow(config).to receive(:excluded_services).and_return([]) - allow(config).to receive(:send).with(:directory_stacks).and_return([ - { 'name' => 'terragrunt', 'directory' => 'terraform' } - ]) - allow(config).to receive(:stack_conventions).and_return({ - 'root' => 'infrastructures/{service}', - 'stacks' => [ - { 'name' => 'terragrunt', 'directory' => 'terraform' } - ] - }) - allow(config).to receive(:stack_conventions_config).and_return([ - { - 'root' => 'infrastructures/{service}', - 'stacks' => [ - { 'name' => 'terragrunt', 'directory' => 'terraform' } - ] - } - ]) - end + it 'detects deleted file paths without checking filesystem existence' do + changed_files.replace(['teams/absent/unregistered/aws/develop/deleted.tf']) + expect(file_client).not_to receive(:resolve_directories) + expect(services).to eq(['unregistered']) + end - it 'detects service using custom pattern' do - result = use_case.execute(base_ref: base_ref, head_ref: head_ref) + it 'deduplicates matches for files paths and stacks' do + stack['paths'] << stack['paths'].first + changed_files.replace(['teams/platform/api/aws/develop/main.tf', 'teams/platform/api/aws/develop/vars.tf']) + expect(services).to eq(['api']) + end - expect(result).to be_success - expect(result.deploy_labels.map(&:to_s)).to include('deploy:special-service') - end - end + it 'ignores hidden services' do + changed_files.replace(['teams/platform/.hidden/aws/develop/main.tf']) + expect(services).to eq([]) end - describe 'integration with real components' do - let(:real_file_client) { Infrastructure::FileSystemClient.new } - let(:real_config_client) { Infrastructure::ConfigClient.new } - let(:use_case) do - described_class.new( - file_client: real_file_client, - config_client: real_config_client - ) + [false, true].each do |reverse| + it "rejects conflicting captures before exclusions with reversed paths #{reverse}" do + paths = ['teams/{team}/{service}/aws', 'teams/{product}/{service}/aws'] + paths.reverse! if reverse + config_hash['stacks'] = [{ 'name' => 'terragrunt', 'paths' => paths, 'exclude' => [{ 'team' => 'platform' }] }] + result = use_case.execute + expect(result).to be_failure + expect(result.error_message).to include('Conflicting captures', 'teams/platform/demo/aws') + expect(result.data).not_to have_key(:deploy_labels) end - let(:temp_config) { create_test_config(default_test_config) } - - after { temp_config.unlink } - - context 'with mocked git operations' do - before do - allow(real_file_client).to receive(:get_changed_files).and_return(['test-service/terragrunt/develop/main.tf']) - end + end - it 'works with real configuration and mocked file operations' do - result = use_case.execute(base_ref: 'main', head_ref: 'feature/test') + it 'returns successful empty results for unrelated changes' do + changed_files.replace(['README.md']) + expect(services).to eq([]) + end - expect(result).to be_success - expect(result.deploy_labels.map(&:to_s)).to include('deploy:test-service') - end - end + it 'returns failures for git errors' do + allow(file_client).to receive(:get_changed_files).and_raise('git error') + expect(use_case.execute.error_message).to include('git error') end -end \ No newline at end of file +end diff --git a/action-scripts/spec/shared/interfaces/presenters/console_presenter_spec.rb b/action-scripts/spec/shared/interfaces/presenters/console_presenter_spec.rb new file mode 100644 index 0000000..cf72008 --- /dev/null +++ b/action-scripts/spec/shared/interfaces/presenters/console_presenter_spec.rb @@ -0,0 +1,13 @@ +require 'spec_helper' + +RSpec.describe Interfaces::Presenters::ConsolePresenter do + it 'displays labels and changed files without service exclusion metadata' do + expect do + described_class.new.present_label_dispatch_result(deploy_labels: [Entities::DeployLabel.new('deploy:demo')], labels_added: ['deploy:demo'], labels_removed: [], changed_files: ['dystopia/demo/main.rb']) + end.to output(/Deploy Labels: deploy:demo.*Changed Files: 1 files/m).to_stdout + end + + it 'accepts only the dispatch result fields' do + expect(described_class.instance_method(:present_label_dispatch_result).parameters.map(&:last)).to eq(%i[deploy_labels labels_added labels_removed changed_files]) + end +end diff --git a/action-scripts/spec/shared/interfaces/presenters/github_actions_presenter_spec.rb b/action-scripts/spec/shared/interfaces/presenters/github_actions_presenter_spec.rb new file mode 100644 index 0000000..4412ef6 --- /dev/null +++ b/action-scripts/spec/shared/interfaces/presenters/github_actions_presenter_spec.rb @@ -0,0 +1,35 @@ +require 'spec_helper' +require 'tmpdir' + +RSpec.describe Interfaces::Presenters::GitHubActionsPresenter do + around do |example| + original = ENV.to_h.slice('GITHUB_ENV', 'GITHUB_OUTPUT') + Dir.mktmpdir do |root| + %w[GITHUB_ENV GITHUB_OUTPUT].each do |key| + ENV[key] = File.join(root, key) + File.write(ENV[key], '') + end + example.run + end + ensure + %w[GITHUB_ENV GITHUB_OUTPUT].each { |key| ENV[key] = original[key] } + end + + it 'writes public dispatch outputs without global service exclusion fields' do + described_class.new.present_label_dispatch_result(deploy_labels: [Entities::DeployLabel.new('deploy:demo')], labels_added: ['deploy:demo'], labels_removed: ['deploy:old'], changed_files: ['dystopia/demo/main.rb']) + output = File.read(ENV['GITHUB_OUTPUT']) + expect(output.lines.map(&:strip)).to contain_exactly('deploy-labels=["deploy:demo"]', 'labels-added=["deploy:demo"]', 'labels-removed=["deploy:old"]', 'services-detected=["demo"]', 'has-changes=true') + env = File.read(ENV['GITHUB_ENV']) + expect(env).to include('SERVICES_DETECTED=["demo"]', 'DEPLOY_LABELS=["deploy:demo"]', 'HAS_CHANGES=true') + expect(env).not_to include('EXCLUDED_SERVICES', 'HAS_EXCLUDED_SERVICES') + end + + it 'writes false and empty outputs when no labels are detected' do + described_class.new.present_label_dispatch_result(deploy_labels: [], labels_added: [], labels_removed: [], changed_files: []) + expect(File.read(ENV['GITHUB_OUTPUT'])).to include('deploy-labels=[]', 'services-detected=[]', 'has-changes=false') + end + + it 'accepts only the dispatch result fields' do + expect(described_class.instance_method(:present_label_dispatch_result).parameters.map(&:last)).to eq(%i[deploy_labels labels_added labels_removed changed_files]) + end +end diff --git a/docs/superpowers/plans/stack-configuration.md b/docs/superpowers/plans/stack-configuration.md index 7f09cda..a50c45e 100644 --- a/docs/superpowers/plans/stack-configuration.md +++ b/docs/superpowers/plans/stack-configuration.md @@ -338,7 +338,7 @@ Run: `git commit -s -m 'feat: generate deployment targets from stack definitions - Produces: `DetectChangedServices#execute(base_ref: nil, head_ref: nil)` → 成功時 `deploy_labels`・`changed_files`・`services_detected`、失敗時 `error_message` を持つ `Result`。 - Produces: 両 presenter の `#present_label_dispatch_result(deploy_labels:, labels_added:, labels_removed:, changed_files:)`。controller はこの signature で呼び出す。 -- [ ] **Step 1: Write per-match exclusion and output tests** +- [x] **Step 1: Write per-match exclusion and output tests** 変更ファイル `teams/platform/demo/aws/production/main.tf` に対し、team=platform・service=demo・environment=production の条件でラベルを生成しないことをテストする。同じ設定で develop の変更を加えると `deploy:demo` を生成することをテストする。 @@ -346,23 +346,23 @@ Run: `git commit -s -m 'feat: generate deployment targets from stack definitions controller の spec は presenter 呼び出しから `excluded_services` が消えることを確認する。GitHub presenter の spec は一時ファイルを `GITHUB_ENV`・`GITHUB_OUTPUT` とし、現行公開 outputs のラベル・サービス・変更有無が出力され、`EXCLUDED_SERVICES`・`HAS_EXCLUDED_SERVICES`・`excluded-services`・`has-excluded-services` が出力されないことを確認する。Console presenter の spec は、ラベルとサービス結果を表示することを確認する。 -- [ ] **Step 2: Confirm the dispatcher tests fail** +- [x] **Step 2: Confirm the dispatcher tests fail** Run: `bundle exec rspec spec/label-dispatcher spec/shared/interfaces/presenters` Expected: stack 別・照合別の判定と旧除外出力の assertion が FAIL。 -- [ ] **Step 3: Implement service detection from valid matches** +- [x] **Step 3: Implement service detection from valid matches** stack と paths を走査し、変更ファイルから service・環境・任意の placeholder を抽出する。サービス名が `.` で始まる照合は対象にしない。環境名がパスにある場合は当該 stack の定義内であることを確認し、ない場合は定義された各環境、環境共通なら nil を使う。`PatternMatcher.expand(pattern, captures)` で照合したディレクトリを確定し、service・stack_id・environment・working_directory が同じ照合の抽出値が矛盾する場合は failure にする。その後に各照合を `excluded?` で判定し、除外されない照合が一つでもあるサービスを一度だけラベル対象にする。 全体のサービス除外、理由・種別のログ、旧結果フィールド、controller と presenter の旧引数・内部環境変数・内部出力を削除する。対象を列挙する際に filesystem の存在は要求せず、削除されたファイルの変更パスにも同じ照合を適用する。 -- [ ] **Step 4: Confirm the dispatcher tests pass** +- [x] **Step 4: Confirm the dispatcher tests pass** Step 2 と同じコマンドを実行する。Expected: exit 0、`0 failures`。除外された照合だけのサービスと、除外されない照合を持つサービスの双方を確認して完了とする。 -- [ ] **Step 5: Commit changed service detection** +- [x] **Step 5: Commit changed service detection** Files の変更を stage して `git diff --cached --check` を実行する。 From db7489253e325fc16c32ade2ea6c318fb03ed1b5 Mon Sep 17 00:00:00 2001 From: panicboat Date: Fri, 9 Oct 2026 10:07:11 +0900 Subject: [PATCH 09/13] feat: expose stack configuration through config commands Signed-off-by: panicboat --- README-ja.md | 133 ++-------- README.md | 158 +++++------ action-scripts/config-manager/README-ja.md | 196 +------------- action-scripts/config-manager/README.md | 224 ++-------------- action-scripts/config-manager/application.rb | 2 + .../config-manager/bin/config-manager | 123 +-------- .../controllers/config_manager_controller.rb | 153 ++++------- action-scripts/label-dispatcher/README-ja.md | 114 ++------ action-scripts/label-dispatcher/README.md | 82 +----- action-scripts/label-resolver/README-ja.md | 102 ++----- action-scripts/label-resolver/README.md | 66 +---- .../presenters/console_presenter.rb | 30 +-- .../presenters/github_actions_presenter.rb | 30 +-- .../spec/config-manager/cli_spec.rb | 32 +++ .../config_manager_controller_spec.rb | 251 +++++++----------- .../presenters/console_presenter_spec.rb | 15 ++ .../github_actions_presenter_spec.rb | 15 ++ action-scripts/workflow-config.yaml | 73 +++-- docs/superpowers/plans/stack-configuration.md | 18 +- 19 files changed, 467 insertions(+), 1350 deletions(-) create mode 100644 action-scripts/spec/config-manager/cli_spec.rb diff --git a/README-ja.md b/README-ja.md index 91369c5..ae42762 100644 --- a/README-ja.md +++ b/README-ja.md @@ -4,21 +4,21 @@ 複数サービスを抱えるリポジトリで、PR ラベルベースのデプロイメント・オーケストレーションを駆動する GitHub Actions ツールキット。 -## 概要 +## Overview ファイル変更からデプロイ対象ラベルを生成し、ラベルからデプロイメント・ターゲットを解決する層を提供します。実際の `plan`/`apply` 実行は、利用側で任意の Composite Action(Terragrunt / Helm / kustomize など)に委ねる構成です。 -## コンポーネント +## Components ### 1. Config Manager (`action-scripts/config-manager/`) -`workflow-config.yaml` を検証・管理。環境・サービス・ディレクトリ規約を定義します。 +`workflow-config.yaml` の stack 定義を検証し、実在するサービスの設定を診断します。 **特徴:** - 詳細なエラーレポート付きの設定検証 -- 環境とサービスの管理 -- ディレクトリ規約の検証 +- 定義環境の一覧とサービス診断 +- stack のパスと除外条件の検証 - テンプレート生成 ### 2. Label Dispatcher (`label-dispatcher/`) @@ -34,12 +34,12 @@ PR の変更ファイルを検出し、変更があったサービスに対し ### 3. Label Resolver (`label-resolver/`) -`deploy:` ラベルとブランチ情報を、後続 Action が利用するデプロイメント・ターゲットの matrix に変換します。 +`deploy:` ラベルと指定環境を、後続 Action が利用する matrix に変換します。 **特徴:** - ラベルからターゲットへの解決 -- ブランチからの環境検出 +- 定義された環境からの対象選択 - デプロイメント matrix 生成 - 安全性検証 @@ -66,64 +66,13 @@ PR の変更ファイルを検出し、変更があったサービスに対し environments: develop # 任意。カンマ区切り (例: develop,staging) ``` -## 設定 +## Configuration -`workflow-config.yaml` を読み込みます。 +設定仕様、共有・独立する stack、placeholder、除外条件は [Configuration](README.md#configuration) を参照してください。 -```yaml -environments: - - environment: develop - stacks: - aws: - aws_region: ap-northeast-1 - iam_role_plan: arn:aws:iam::ACCOUNT:role/plan-role - iam_role_apply: arn:aws:iam::ACCOUNT:role/apply-role - -stack_conventions: - - root: "{service}" # {service}/{environment} 以外の任意 placeholder - # も使用可能。抽出された値は matrix item の - # トップレベル(例: {team})に展開される。 - stacks: - - name: aws - directory: "aws/{environment}" - required_attributes: [aws_region, iam_role_plan, iam_role_apply] - - name: kubernetes - directory: "kubernetes/overlays/{environment}" - -services: - - name: excluded-service - exclude_from_automation: true - exclusion_config: - reason: "手動デプロイが必要" - type: "permanent" -``` - -実行可能なサンプルは `action-scripts/workflow-config.yaml` を参照してください。 - -### 同じ stack name の複数インスタンス - -1つのサービスで同じ stack を2つ使う場合(AWS リソース用と Stripe 用の -Terragrunt stack など)は、各エントリに異なる `id` を指定します。 - -```yaml -# `id` はオプションです。省略時は `name` にフォールバックします。 -# 1つの convention 内では、`id || name` が一意でなければなりません。 -stack_conventions: - - root: "dystopia/{service}" - stacks: - - name: terragrunt - id: aws - directory: infrastructure/aws/{environment} - required_attributes: [aws_region, iam_role_plan, iam_role_apply] - - name: terragrunt - id: stripe - directory: infrastructure/stripe/{environment} - required_attributes: [aws_region, iam_role_plan, iam_role_apply] -``` - -## ワークフロー統合 +## Workflow Integration -### 1. 変更検出 +### 1. Change Detection ```yaml name: Detect Changes and Create Labels @@ -142,7 +91,7 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} ``` -### 2. デプロイターゲット解決 +### 2. Target Resolution ```yaml name: Deploy @@ -169,65 +118,17 @@ jobs: ## Matrix Output -`label-resolver` は `outputs.targets`(および環境変数 `DEPLOYMENT_TARGETS`)として JSON 配列を出力します。各 matrix item はフラット構造です。 - -| Key | 由来 | 補足 | -|---|---|---| -| `service` | 固定 | `deploy:` ラベルの service 名 | -| `environment` | 固定 | environment-agnostic stack では `null` | -| `stack` | 固定 | 例: `aws`, `kubernetes` | -| `stack_id` | 固定 | インスタンス識別子(`id \|\| name`)。`id` 未指定時は `stack` と同じ | -| `working_directory` | 固定 | 実在する deploy 対象ディレクトリ | -| `stack_convention_root` | 固定 | マッチした root pattern の展開後の値 | -| (attributes のキー) | 動的 | `environments[].stacks[stack].*` で定義された値 | -| (captures のキー) | 動的 | マッチした pattern 中の任意 `{placeholder}` の抽出値(`service` / `environment` を除く) | - -例として、次の `workflow-config.yaml` を考えます。 - -```yaml -environments: - - environment: develop - stacks: - aws: - aws_region: ap-northeast-1 - iam_role_plan: arn:aws:iam::ACCOUNT:role/plan-role - iam_role_apply: arn:aws:iam::ACCOUNT:role/apply-role - -stack_conventions: - - root: "{team}/{service}" - stacks: - - name: aws - directory: "aws/{environment}" -``` - -このとき `payments/api/aws/develop` が deploy 対象として解決されると、matrix item は次のようになります。 - -```json -{ - "service": "api", - "environment": "develop", - "stack": "aws", - "stack_id": "aws", - "working_directory": "payments/api/aws/develop", - "stack_convention_root": "payments/api", - "aws_region": "ap-northeast-1", - "iam_role_plan": "arn:aws:iam::ACCOUNT:role/plan-role", - "iam_role_apply": "arn:aws:iam::ACCOUNT:role/apply-role", - "team": "payments" -} -``` - -`aws_region` / `iam_role_plan` / `iam_role_apply` は `environments[0].stacks.aws` の attributes が、`team` は `root` の `{team}` プレースホルダ抽出値がそれぞれ展開されたものです。下流の Composite Action では `${{ matrix.team }}` のように任意のキーを直接参照できます。固定キーや attributes キーと衝突する placeholder 名は `config-manager validate` の段階で拒否されます。 +固定キー、属性・抽出値の展開、対象の同一性は [Matrix Output](README.md#matrix-output) を参照してください。 -## 開発 +## Development -### 前提条件 +### Prerequisites - Ruby 4.0.3 - Bundler - Git -### セットアップ +### Setup ```bash git clone https://github.com/panicboat/deploy-actions.git @@ -236,7 +137,7 @@ bundle install bundle exec rspec ``` -### コンポーネント別テスト +### Component Testing ```bash bundle exec ruby config-manager/bin/config-manager validate diff --git a/README.md b/README.md index e1b4915..084be9c 100644 --- a/README.md +++ b/README.md @@ -12,14 +12,7 @@ Deploy Actions converts file changes into deployment labels and converts those l ### 1. Config Manager (`action-scripts/config-manager/`) -Validates and manages the `workflow-config.yaml` that defines environments, services, and directory conventions. - -**Highlights:** - -- Configuration validation with detailed error reporting -- Environment and service management -- Directory-convention validation -- Template generation +`workflow-config.yaml` の stack 定義を検証し、設定表示・環境一覧・実在ディレクトリのサービス診断・テンプレート生成を提供します。 ### 2. Label Dispatcher (`label-dispatcher/`) @@ -34,12 +27,12 @@ Detects file changes from a PR and creates `deploy:` labels for affecte ### 3. Label Resolver (`label-resolver/`) -Translates `deploy:` labels and branch context into a deployment-target matrix that downstream actions consume. +`deploy:` ラベルと指定環境から、後続 Action が使う matrix を生成します。 **Highlights:** - Label-to-target resolution -- Environment detection from branch +- 定義された環境からの対象選択 - Deployment-matrix generation - Safety validation @@ -68,58 +61,65 @@ Translates `deploy:` labels and branch context into a deployment-target ## Configuration -The toolkit reads `workflow-config.yaml`: +`workflow-config.yaml` のトップレベルには、空でない `stacks` 配列を定義します。同じ stack のパス、環境属性、除外条件を一つの定義で管理します。 ```yaml -environments: - - environment: develop - stacks: - aws: +stacks: + - name: terragrunt + id: aws + paths: + - "dystopia/{service}/aws/{environment}" + - "system-components/{service}/infrastructure/aws/{environment}" + - "teams/{team}/{service}/aws/{environment}" + environments: + develop: aws_region: ap-northeast-1 - iam_role_plan: arn:aws:iam::ACCOUNT:role/plan-role - iam_role_apply: arn:aws:iam::ACCOUNT:role/apply-role - -stack_conventions: - - root: "{service}" # placeholders other than {service}/{environment} - # are also allowed; their values are emitted as - # top-level keys in matrix output (e.g. {team}). - stacks: - - name: aws - directory: "aws/{environment}" - required_attributes: [aws_region, iam_role_plan, iam_role_apply] - - name: kubernetes - directory: "kubernetes/overlays/{environment}" - -services: - - name: excluded-service - exclude_from_automation: true - exclusion_config: - reason: "Manual deployment required" - type: "permanent" + production: + aws_region: us-west-2 + exclude: + - service: demo + environment: production + - team: sandbox + - name: container + paths: + - "dystopia/{service}" + - "system-components/{service}" + attributes: + repository: registry.example.com/app ``` -See `action-scripts/workflow-config.yaml` for a runnable sample. +### Stack Definitions -### Multiple instances of the same stack name +| Field | Requirement | Behavior | +|---|---|---| +| `name` | 必須 | stack の種類。属性名やプロバイダーを制限しない | +| `id` | 任意 | インスタンス識別子。省略時は `name`。設定全体で一意 | +| `paths` | 必須 | 空でない相対パス配列。全パスに `{service}` が必要 | +| `environments` | 任意 | 環境名をキー、環境属性を値とする空でないマップ | +| `attributes` | 任意 | 環境共通の属性。`environments` と併用できない | +| `exclude` | 任意 | 除外条件の配列。省略時は空配列 | -When a service needs two instances of the same stack (e.g. one Terragrunt -stack for AWS resources and one for Stripe), give each entry a distinct `id`. +共有するプロダクトは一つの定義の `paths` に追加します。独立させる場合は、同じ `name` に異なる `id` を付けた別定義にし、それぞれにパスと属性を置きます。 -```yaml -# `id` is optional; when omitted it falls back to `name`. Within a single -# convention, `id || name` must be unique. -stack_conventions: - - root: "dystopia/{service}" - stacks: - - name: terragrunt - id: aws - directory: infrastructure/aws/{environment} - required_attributes: [aws_region, iam_role_plan, iam_role_apply] - - name: terragrunt - id: stripe - directory: infrastructure/stripe/{environment} - required_attributes: [aws_region, iam_role_plan, iam_role_apply] -``` +`environments` のキーがその stack の環境名です。環境一覧は全 stack の和集合になり、環境指定を省略すると全定義環境が対象になります。各 stack は自身の定義環境だけを生成し、未知の環境指定はエラーになります。属性の値と型はそのまま出力され、環境間の属性キーを揃える必要はありません。 + +パスに `{environment}` がなくても、`environments` があれば環境ごとの対象になります。同じディレクトリを異なる環境属性で使えます。`environments` がなければ環境共通で、`environment: null` の対象を各ディレクトリにつき一度だけ生成します。この場合の属性は `attributes` から取得し、パスに `{environment}` は指定できません。 + +### Path Matching + +パスはリポジトリルートからの完全な相対パターンです。絶対パスと `..` 要素は拒否し、先頭の `./`、余分な `/`、`.` 要素は正規化します。すべてのパターンに一致する実在ディレクトリを列挙し、存在しないパスは対象を生成しません。サービスの登録は不要です。ドットで始まるサービスは対象外です。 + +`{team}` などの任意 placeholder を使えます。名前は `[a-z_][a-z0-9_]*`、値はパスの一要素です。同じ名前を繰り返した場合は、すべて同じ値に一致する必要があります。glob の記号はリテラルとして扱います。任意 placeholder と matrix の固定キー、または同じ stack のいずれかの環境属性・共通属性キーが衝突する定義は拒否します。 + +### Exclusion Conditions + +`exclude` の各要素は空でない条件マップです。`service`、`environment`、その stack のいずれかのパスにある任意 placeholder を条件にできます。属性名は条件キーとして使えません。 + +一つのマップ内は AND、配列内は OR で完全一致を判定します。省略したキーは制約になりません。service のみ、environment のみ、任意 placeholder のみでも指定できます。照合したパスにない抽出値を要求する条件は一致しません。 + +条件値は空でない文字列で、`/`、`.`、`..` は使えません。service はドットで始められません。環境条件は自身の定義環境だけを指定できます。環境共通 stack では environment の省略または `null` を受け付け、environment 以外の条件値に `null` は使えません。 + +実行可能な設定例は [workflow-config.yaml](action-scripts/workflow-config.yaml) を参照してください。 ## Workflow integration @@ -169,55 +169,33 @@ The execution layer (`aws`, `kubernetes`, etc.) is intentionally not part of thi ## Matrix Output -`label-resolver` produces a JSON array on `outputs.targets` (and the `DEPLOYMENT_TARGETS` env var). Each matrix item is flat: - -| Key | Source | Notes | -|---|---|---| -| `service` | Fixed | `deploy:` label | -| `environment` | Fixed | `null` for environment-agnostic stacks | -| `stack` | Fixed | e.g. `aws`, `kubernetes` | -| `stack_id` | Fixed | Instance identity (`id \|\| name`). Equals `stack` when `id` is not set. | -| `working_directory` | Fixed | Resolved deploy directory | -| `stack_convention_root` | Fixed | `root` portion of the matched pattern, expanded | -| (attributes keys) | Dynamic | Everything under `environments[].stacks[stack].*` | -| (captures keys) | Dynamic | Values of arbitrary `{placeholder}` segments in the matched pattern, excluding `service` / `environment` | - -Example. Given this `workflow-config.yaml`: +`label-resolver` は `outputs.targets` と環境変数 `DEPLOYMENT_TARGETS` に JSON 配列を出力します。固定キーは次の5個で、属性と任意 placeholder の抽出値を同じ階層に展開します。 -```yaml -environments: - - environment: develop - stacks: - aws: - aws_region: ap-northeast-1 - iam_role_plan: arn:aws:iam::ACCOUNT:role/plan-role - iam_role_apply: arn:aws:iam::ACCOUNT:role/apply-role - -stack_conventions: - - root: "{team}/{service}" - stacks: - - name: aws - directory: "aws/{environment}" -``` +| Key | Source | +|---|---| +| `service` | ラベルまたは `deploy:all` で探索したサービス名 | +| `environment` | 定義した環境名。環境共通なら `null` | +| `stack` | stack の `name` | +| `stack_id` | 確定した `id` | +| `working_directory` | 実在する対象ディレクトリの相対パス | +| 属性のキー | 当該環境属性、または共通 `attributes` | +| 任意 placeholder のキー | 一致したパスの抽出値 | -a working directory at `payments/api/aws/develop` resolves to: +Configuration の例で `teams/payments/api/aws/develop` が存在する場合、次の行を生成します。 ```json { "service": "api", "environment": "develop", - "stack": "aws", + "stack": "terragrunt", "stack_id": "aws", - "working_directory": "payments/api/aws/develop", - "stack_convention_root": "payments/api", + "working_directory": "teams/payments/api/aws/develop", "aws_region": "ap-northeast-1", - "iam_role_plan": "arn:aws:iam::ACCOUNT:role/plan-role", - "iam_role_apply": "arn:aws:iam::ACCOUNT:role/apply-role", "team": "payments" } ``` -`aws_region` / `iam_role_plan` / `iam_role_apply` come from `environments[0].stacks.aws` (attributes); `team` comes from the `{team}` placeholder in `root` (captures). Downstream composite actions can reference any key directly, e.g. `${{ matrix.team }}`. Placeholder names that would collide with a fixed key or with any attribute key are rejected at `config-manager validate` time. +対象の同一性は service・stack_id・environment・working_directory で決まります。同じ対象の重複は一行にまとめます。同じ対象を異なる抽出値マップで解釈するパスは、除外条件や記載順にかかわらずエラーになります。下流では `${{ matrix.team }}` のように任意のキーを参照できます。 ## Development diff --git a/action-scripts/config-manager/README-ja.md b/action-scripts/config-manager/README-ja.md index 1fd08eb..9c8e719 100644 --- a/action-scripts/config-manager/README-ja.md +++ b/action-scripts/config-manager/README-ja.md @@ -4,198 +4,26 @@ GitHub Actions デプロイメント自動化のための Ruby ベース設定検証・管理ツールです。 -## 概要 +## Overview -Config Manager は、デプロイメント環境、サービス、自動化ルールを定義するワークフロー設定ファイルを検証・管理します。トランクベース開発に最適化されたデプロイメント自動化セットアップのための包括的な設定検証、診断ツール、テンプレートを提供します。 +設定表示、検証、環境一覧、サービス診断を担当します。[Commands](README.md#commands) に実行方法と結果、[Service Diagnosis](README.md#service-diagnosis) に診断の説明をまとめています。 -## 機能 +設定仕様は [Configuration](../../README.md#configuration)、出力形式は [Matrix Output](../../README.md#matrix-output) を参照してください。 -- **設定検証**: 詳細なエラーレポートを伴う `workflow-config.yaml` ファイルの検証 -- **環境管理**: AWS IAM ロールを使用した設定済み環境の一覧表示とテスト -- **サービス設定**: サービス固有のデプロイメント設定と除外の管理 -- **ディレクトリ規約**: 階層ディレクトリ構造設定の検証 -- **テンプレート生成**: 最適化された設定テンプレートの生成 -- **診断ツール**: デプロイメントセットアップの包括的ヘルスチェック +## Architecture -## 使用方法 - -Config Manager は `bin/config-manager` を通じて CLI インターフェースを提供します: - -### 基本コマンド - -```bash -# 設定ファイルの検証 -bundle exec ruby config-manager/bin/config-manager validate - -# パース済み設定の表示 -bundle exec ruby config-manager/bin/config-manager show - -# 全環境の一覧 -bundle exec ruby config-manager/bin/config-manager environments - -# 全サービスの一覧 -bundle exec ruby config-manager/bin/config-manager services - -# 自動化から除外されたサービスの一覧 -bundle exec ruby config-manager/bin/config-manager excluded_services - -# 特定のサービス設定のテスト -bundle exec ruby config-manager/bin/config-manager test サービス名 環境名 - -# 診断チェックの実行 -bundle exec ruby config-manager/bin/config-manager diagnostics - -# 設定テンプレートの生成 -bundle exec ruby config-manager/bin/config-manager template -``` - -### 使用例 - -```bash -# 現在の設定の検証 -./bin/config-manager validate - -# develop 環境での auth サービスのテスト -./bin/config-manager test auth develop - -# 全設定詳細の表示 -./bin/config-manager show - -# 新しい設定テンプレートの生成 -./bin/config-manager template > new-workflow-config.yaml -``` - -## 設定構造 - -Config Manager は以下の構造の `workflow-config.yaml` ファイルを管理します: - -### 環境 - -ブランチ依存なしのデプロイメント環境を定義: - -```yaml -environments: - - environment: develop - aws_region: ap-northeast-1 - iam_role_plan: arn:aws:iam::ACCOUNT:role/plan-role - iam_role_apply: arn:aws:iam::ACCOUNT:role/apply-role - - - environment: staging - aws_region: ap-northeast-1 - iam_role_plan: arn:aws:iam::ACCOUNT:role/staging-plan-role - iam_role_apply: arn:aws:iam::ACCOUNT:role/staging-apply-role - - - environment: production - aws_region: ap-northeast-1 - iam_role_plan: arn:aws:iam::ACCOUNT:role/production-plan-role - iam_role_apply: arn:aws:iam::ACCOUNT:role/production-apply-role -``` - -### ディレクトリ規約 - -サービス発見のための階層ディレクトリ構造: - -```yaml -stack_conventions: - - root: "{service}" - stacks: - - name: aws - directory: "aws/{environment}" - targets: ["develop", "staging", "production"] - - name: kubernetes - directory: "kubernetes/overlays/{environment}" - targets: ["develop", "staging", "production"] -``` - -各 stack エントリには、インスタンスを識別するオプションの `id` を指定できます。 -識別子は `id || name` で決まり、1つの convention 内で一意でなければなりません。 -識別子が重複している場合、設定の読み込み時に拒否されます。 - -### サービス - -サービス固有の設定と除外: - -```yaml -services: - - name: excluded-service - exclude_from_automation: true - exclusion_config: - reason: "特別な要件により手動デプロイメントが必要" - type: "permanent" - - - name: special-service - stack_conventions: - aws: "custom/{service}/infra/{environment}" -``` - -## 検証ルール - -Config Manager は包括的な検証を実施します: - -### 環境検証 -- 全環境に `environment`、`aws_region`、`iam_role_plan`、`iam_role_apply` が必要 -- AWS リージョンは標準形式に従う必要(`us-west-2`、`ap-northeast-1` など) -- IAM ロール ARN は有効な AWS ARN 形式である必要 -- 必須環境:`develop`、`staging`、`production` - -### ディレクトリ規約検証 -- ルートパターンには `{service}` プレースホルダーが必要(空の場合を除く) -- スタックディレクトリには `{environment}` プレースホルダーが必要 -- 必須スタック:`aws`(最低限) -- ディレクトリ規約は配列である必要 - -### サービス検証 -- サービス名はドット(`.`)で始まることはできない -- サービス固有のディレクトリ規約には `{service}` プレースホルダーが必要 -- 除外されたサービスには理由付きの `exclusion_config` が必要 - -## テンプレート生成 - -最適化された設定テンプレートを生成: - -```bash -./bin/config-manager template -``` - -生成されるテンプレートには以下が含まれます: -- ブランチフィールドのない環境設定 -- モダンなディレクトリ規約 -- サービス除外の例 -- 包括的なドキュメント - -## 診断ツール - -デプロイメント自動化の包括的ヘルスチェック: - -```bash -./bin/config-manager diagnostics -``` - -チェック項目: -- 設定ファイル検証 -- 環境変数の可用性 -- Git リポジトリ状態 -- 設定ファイルの場所 -- ディレクトリ構造の整合性 - -## アーキテクチャ - -### コンポーネント +### Components - **ConfigManagerController**: メインオーケストレーションと CLI インターフェース - **ValidateConfig**: 包括的設定検証 - **ConfigClient**: 設定読み込みとパース - **ConsolePresenter**: 人間が読める出力フォーマット -### 検証フロー +### Validation Flow -1. **構造検証**: YAML 構造と必須セクション -2. **環境検証**: AWS 認証情報とリージョン検証 -3. **サービス検証**: サービス設定と除外 -4. **ディレクトリ検証**: ディレクトリ規約とプレースホルダー -5. **サマリー生成**: 検証結果と統計 +検証の流れは [Validation Flow](README.md#validation-flow) を参照してください。 -## エラーハンドリング +## Error Handling 以下を含む詳細なエラーレポート: - **具体的なエラーメッセージ**: 設定問題の特定 @@ -203,23 +31,23 @@ Config Manager は包括的な検証を実施します: - **提案**: 一般的な設定問題の修正ガイダンス - **サマリー統計**: 設定ヘルスの概要 -## 統合 +## Integration Config Manager は以下と統合されます: - **Label Resolver**: デプロイメント指定のための設定提供 - **Label Dispatcher**: サービスとディレクトリ設定の検証 - **GitHub Actions**: CI/CD ワークフローの環境検証 -## 開発 +## Development -### テスト実行 +### Running Tests ```bash cd action-scripts bundle exec rspec spec/config-manager/ ``` -### ローカルテスト +### Local Testing ```bash # カスタム設定でのテスト diff --git a/action-scripts/config-manager/README.md b/action-scripts/config-manager/README.md index 27566e0..16ae56e 100644 --- a/action-scripts/config-manager/README.md +++ b/action-scripts/config-manager/README.md @@ -6,205 +6,38 @@ A Ruby-based configuration validation and management tool for GitHub Actions dep ## Overview -The Config Manager validates and manages workflow configuration files that define deployment environments, services, and automation rules. It provides comprehensive configuration validation, diagnostic tools, and templates for deployment automation setups optimized for trunk-based development. - -## Features - -- **Configuration Validation**: Validate `workflow-config.yaml` files with detailed error reporting -- **Environment Management**: List and test configured environments with AWS IAM roles -- **Service Configuration**: Manage service-specific deployment settings and exclusions -- **Directory Conventions**: Validate hierarchical directory structure configuration -- **Template Generation**: Generate configuration templates optimized -- **Diagnostic Tools**: Comprehensive health checks for deployment setup +stack 定義の読み込みと検証、設定表示、サービスの実在ディレクトリ診断を提供します。設定仕様は [Configuration](../../README.md#configuration)、出力形式は [Matrix Output](../../README.md#matrix-output) を参照してください。 ## Usage The Config Manager provides a CLI interface through `bin/config-manager`: -### Basic Commands - -```bash -# Validate configuration file -bundle exec ruby config-manager/bin/config-manager validate - -# Show parsed configuration -bundle exec ruby config-manager/bin/config-manager show - -# List all environments -bundle exec ruby config-manager/bin/config-manager environments - -# List all services -bundle exec ruby config-manager/bin/config-manager services - -# List services excluded from automation -bundle exec ruby config-manager/bin/config-manager excluded_services - -# Test specific service configuration -bundle exec ruby config-manager/bin/config-manager test SERVICE_NAME ENVIRONMENT - -# Run diagnostic checks -bundle exec ruby config-manager/bin/config-manager diagnostics - -# Generate configuration template -bundle exec ruby config-manager/bin/config-manager template -``` - -### Examples - -```bash -# Validate current configuration -./bin/config-manager validate - -# Test auth service in develop environment -./bin/config-manager test auth develop - -# Show all configuration details -./bin/config-manager show - -# Generate new configuration template -./bin/config-manager template > new-workflow-config.yaml -``` - -## Configuration Structure - -The Config Manager manages `workflow-config.yaml` files with the following structure: - -### Environments - -Defines deployment environments without branch dependencies: - -```yaml -environments: - - environment: develop - aws_region: ap-northeast-1 - iam_role_plan: arn:aws:iam::ACCOUNT:role/plan-role - iam_role_apply: arn:aws:iam::ACCOUNT:role/apply-role - - - environment: staging - aws_region: ap-northeast-1 - iam_role_plan: arn:aws:iam::ACCOUNT:role/staging-plan-role - iam_role_apply: arn:aws:iam::ACCOUNT:role/staging-apply-role - - - environment: production - aws_region: ap-northeast-1 - iam_role_plan: arn:aws:iam::ACCOUNT:role/production-plan-role - iam_role_apply: arn:aws:iam::ACCOUNT:role/production-apply-role -``` - -### Directory Conventions - -Hierarchical directory structure for service discovery: - -```yaml -stack_conventions: - - root: "{service}" - stacks: - - name: aws - directory: "aws/{environment}" - targets: ["develop", "staging", "production"] - - name: kubernetes - directory: "kubernetes/overlays/{environment}" - targets: ["develop", "staging", "production"] -``` - -Each stack entry may define an optional `id` to identify an instance. The -identity is `id || name`, and identities must be unique within a convention; -duplicate identities are rejected when the configuration is loaded. - -### Services - -Service-specific configurations and exclusions: - -```yaml -services: - - name: excluded-service - exclude_from_automation: true - exclusion_config: - reason: "Manual deployment required due to special requirements" - type: "permanent" - - - name: special-service - stack_conventions: - aws: "custom/{service}/infra/{environment}" -``` - -## Validation Rules - -The Config Manager enforces comprehensive validation: - -### Environment Validation -- All environments must have `environment`, `aws_region`, `iam_role_plan`, and `iam_role_apply` -- AWS regions must follow standard format (`us-west-2`, `ap-northeast-1`, etc.) -- IAM role ARNs must be valid AWS ARN format -- Required environments: `develop`, `staging`, `production` - -### Directory Convention Validation -- Root patterns must include `{service}` placeholder (unless empty) -- Stack directories must include `{environment}` placeholder -- Required stacks: `aws` (minimum) -- Directory conventions must be an array - -### Service Validation -- Service names cannot start with dot (`.`) -- Service-specific directory conventions must include `{service}` placeholder -- Excluded services must have `exclusion_config` with reason - -## Placeholder Rules - -Patterns in `stack_conventions[].root` and `stack_conventions[].stacks[].directory` accept `{name}` placeholders. The grammar: - -- `name` must match `[a-z_][a-z0-9_]*` (lowercase letter or underscore followed by lowercase letters, digits, or underscores). -- Strings like `{Team}`, `{my-var}`, or `{a b}` are not recognized as placeholders; the entire literal is rejected by `validate`. -- The same placeholder name may appear multiple times in one pattern. Expansion uses the same value at every occurrence; extraction requires the same value at every occurrence. - -### Built-in placeholders +### Commands -- `{service}` is required in `root` (unless `root` is the empty string). -- `{environment}` is optional in `stacks[].directory` — omitting it makes the stack environment-agnostic. +`action-scripts` を作業ディレクトリとして実行します。 -### Custom placeholders +| Command | Result | +|---|---| +| `bundle exec ruby config-manager/bin/config-manager validate` | 設定の検証結果と stack・環境数 | +| `bundle exec ruby config-manager/bin/config-manager show` | 各 stack ID の全パス、環境属性または共通属性、除外条件 | +| `bundle exec ruby config-manager/bin/config-manager environments` | 定義した環境名の和集合 | +| `bundle exec ruby config-manager/bin/config-manager test SERVICE_NAME [ENVIRONMENT]` | 実在する全一致対象と除外状態 | +| `bundle exec ruby config-manager/bin/config-manager diagnostics` | 設定、環境変数、Git 状態、設定ファイルの診断 | +| `bundle exec ruby config-manager/bin/config-manager template` | 新しい設定を作るための YAML の表示 | +| `bundle exec ruby config-manager/bin/config-manager check_file` | 既定ファイルの存在、読み取り、YAML 構文の確認 | -Any additional placeholder name acts as a capture. After resolving a deploy target, its value is emitted as a top-level key on the matrix item (see repository-root `README.md` `## Matrix Output`). Custom placeholder names are rejected at validate time if they would collide with: +### Service Diagnosis -- A reserved DeploymentTarget field: `stack`, `working_directory`, `stack_convention_root`. -- Any attribute key used under `environments[].stacks[].*` (e.g. `aws_region`). +環境を省略すると、そのサービスの全定義環境と共通対象を調べます。環境を指定すると、指定環境と共通対象を調べます。サービスの登録は不要です。パスに一致しないサービスは空の結果になります。 -### Structural equivalence - -If two conventions share a stack name and have placeholders at the same positions but with different names (e.g. `{team}/{service}` and `{team99}/{service}` both for stack `aws`), `validate` rejects the configuration: the captured key name would otherwise depend on YAML order. - -### Implementation - -Placeholder parsing, expansion, and extraction are centralized in `Entities::PatternMatcher` (`shared/entities/pattern_matcher.rb`). All three components (`config-manager`, `label-dispatcher`, `label-resolver`) call into it, so the grammar above is authoritative. - -## Template Generation - -Generate configuration templates optimized: +一致する全ディレクトリを stack ID ごとに表示し、属性と任意 placeholder の抽出値を保持します。除外された対象も `excluded: true` として表示するため、実行対象から外れる条件を確認できます。 ```bash -./bin/config-manager template +bundle exec ruby config-manager/bin/config-manager test demo +bundle exec ruby config-manager/bin/config-manager test demo production ``` -Generated templates include: -- Environment configurations without branch fields -- Modern directory conventions -- Service exclusion examples -- Comprehensive documentation - -## Diagnostic Tools - -Comprehensive health checks for deployment automation: - -```bash -./bin/config-manager diagnostics -``` - -Checks include: -- Configuration file validation -- Environment variable availability -- Git repository status -- Configuration file location -- Directory structure integrity +未知の環境、設定の不正、ディレクトリ列挙の失敗、同じ対象の抽出値の矛盾はエラーとして表示します。 ## Architecture @@ -217,11 +50,7 @@ Checks include: ### Validation Flow -1. **Structure Validation**: YAML structure and required sections -2. **Environment Validation**: AWS credentials and region validation -3. **Service Validation**: Service configurations and exclusions -4. **Directory Validation**: Directory conventions and placeholders -5. **Summary Generation**: Validation results and statistics +YAML を読み込み、設定モデルで構造と整合性を検証し、検証結果と stack・環境数を表示します。検証規則の詳細は [Configuration](../../README.md#configuration) を参照してください。 ## Error Handling @@ -257,18 +86,3 @@ cp workflow-config.yaml test-config.yaml # Test service configuration ./bin/config-manager test myservice develop ``` - -## Migration Notes - -**Trunk-based Migration**: This version removes branch-based configuration dependencies: - -- **Branch fields removed**: No longer needed in environment configurations -- **Direct environment targeting**: Use explicit environment parameters in workflows -- **Backward compatibility**: Old configurations will show warnings but continue to work -- **Template updates**: New templates exclude branch configurations - -This migration provides: -- Simplified configuration management -- Better support for trunk-based development -- More flexible deployment strategies -- Cleaner separation of concerns between branch management and deployment configuration diff --git a/action-scripts/config-manager/application.rb b/action-scripts/config-manager/application.rb index e690ff5..4c61162 100644 --- a/action-scripts/config-manager/application.rb +++ b/action-scripts/config-manager/application.rb @@ -31,6 +31,7 @@ def self.build_container # Infrastructure clients container[:config_client] = Infrastructure::ConfigClient.new + container[:file_client] = Infrastructure::FileSystemClient.new # Use cases container[:validate_config] = UseCases::ConfigManagement::ValidateConfig.new( @@ -46,6 +47,7 @@ def self.build_container container[:config_manager_controller] = Interfaces::Controllers::ConfigManagerController.new( validate_config_use_case: container[:validate_config], config_client: container[:config_client], + file_client: container[:file_client], presenter: presenter ) diff --git a/action-scripts/config-manager/bin/config-manager b/action-scripts/config-manager/bin/config-manager index c462859..32dbec2 100644 --- a/action-scripts/config-manager/bin/config-manager +++ b/action-scripts/config-manager/bin/config-manager @@ -21,9 +21,9 @@ class ConfigManagerCLI < Thor controller.show_configuration end - desc "test SERVICE_NAME ENVIRONMENT", "Test service configuration for specific service and environment" + desc "test SERVICE_NAME [ENVIRONMENT]", "Test service configuration for specific service and environment" - def test(service_name, environment) + def test(service_name, environment = nil) controller = ConfigManagerContainer.resolve(:config_manager_controller) controller.test_service_configuration( service_name: service_name, @@ -75,129 +75,16 @@ class ConfigManagerCLI < Thor def environments begin - controller = ConfigManagerContainer.resolve(:config_manager_controller) config_client = ConfigManagerContainer.resolve(:config_client) config = config_client.load_workflow_config - - puts "🌍 Configured Environments:" - config.environments.each do |env_name, env_config| - puts " #{env_name}:" - puts " AWS Region: #{env_config['aws_region']}" - puts " IAM Plan Role: #{env_config['iam_role_plan']}" - puts " IAM Apply Role: #{env_config['iam_role_apply']}" - puts "" - end + puts "Configured Environments:" + config.environment_names.each { |name| puts " #{name}" } rescue => error puts "❌ Failed to load environments: #{error.message}" exit 1 end end - desc "services", "List all configured services" - - def services - begin - config_client = ConfigManagerContainer.resolve(:config_client) - config = config_client.load_workflow_config - - puts "🔧 Configured Services:" - if config.services.empty? - puts " No services explicitly configured" - puts " Services will be auto-discovered from directory structure" - else - automated_services = [] - excluded_services = [] - - config.services.each do |service_name, service_config| - if service_config['exclude_from_automation'] == true - excluded_services << { name: service_name, config: service_config } - else - automated_services << { name: service_name, config: service_config } - end - end - - if automated_services.any? - puts " ✅ Automated Services (#{automated_services.length}):" - automated_services.each do |service| - puts " #{service[:name]}:" - if service[:config]['stack_conventions'] - service[:config]['stack_conventions'].each do |stack, pattern| - puts " #{stack}: #{pattern}" - end - else - puts " Using default directory conventions" - end - end - puts "" - end - - if excluded_services.any? - puts " ⚠️ Excluded Services (#{excluded_services.length}):" - excluded_services.each do |service| - exclusion_config = service[:config]['exclusion_config'] || {} - reason = exclusion_config['reason'] || 'No reason specified' - type = exclusion_config['type'] || 'unspecified' - - puts " #{service[:name]} (#{type}):" - puts " Reason: #{reason}" - if service[:config]['stack_conventions'] - puts " Directory conventions: configured (for future migration)" - else - puts " Directory conventions: not configured" - end - end - end - end - rescue => error - puts "❌ Failed to load services: #{error.message}" - exit 1 - end - end - - desc "excluded_services", "List services excluded from automation" - - def excluded_services - begin - config_client = ConfigManagerContainer.resolve(:config_client) - config = config_client.load_workflow_config - - excluded_services = config.services.select { |_, service_config| - service_config['exclude_from_automation'] == true - } - - if excluded_services.empty? - puts "✅ No services are excluded from automation" - return - end - - puts "⚠️ Excluded Services (#{excluded_services.length}):" - - # Group by exclusion type - by_type = excluded_services.group_by { |_, service_config| - service_config.dig('exclusion_config', 'type') || 'unspecified' - } - - by_type.each do |type, services| - puts " #{type.capitalize} (#{services.length}):" - services.each do |service_name, service_config| - exclusion_config = service_config['exclusion_config'] || {} - reason = exclusion_config['reason'] || 'No reason specified' - - puts " - #{service_name}: #{reason}" - end - puts "" - end - - puts "📋 Total excluded: #{excluded_services.length} services" - puts "📋 Total configured: #{config.services.length} services" - puts "📋 Automation coverage: #{((config.services.length - excluded_services.length).to_f / config.services.length * 100).round(1)}%" - - rescue => error - puts "❌ Failed to load excluded services: #{error.message}" - exit 1 - end - end - end -ConfigManagerCLI.start(ARGV) +ConfigManagerCLI.start(ARGV) if $PROGRAM_NAME == __FILE__ diff --git a/action-scripts/config-manager/controllers/config_manager_controller.rb b/action-scripts/config-manager/controllers/config_manager_controller.rb index e05b96a..e323ca8 100644 --- a/action-scripts/config-manager/controllers/config_manager_controller.rb +++ b/action-scripts/config-manager/controllers/config_manager_controller.rb @@ -7,10 +7,12 @@ class ConfigManagerController def initialize( validate_config_use_case:, config_client:, + file_client:, presenter: ) @validate_config = validate_config_use_case @config_client = config_client + @file_client = file_client @presenter = presenter end @@ -44,53 +46,43 @@ def show_configuration end end - # Test service configuration for specific service and environment - def test_service_configuration(service_name:, environment:) - begin - config = @config_client.load_workflow_config - - # Validate service exists - unless config.services.key?(service_name) - return @presenter.present_error( - Entities::Result.failure(error_message: "Service '#{service_name}' not found in configuration") - ) - end - - # Validate environment exists - unless config.environments.key?(environment) - return @presenter.present_error( - Entities::Result.failure(error_message: "Environment '#{environment}' not found in configuration") - ) - end - - # Get service configurations - service_config = config.services[service_name] - - # Collect directories and attributes per stack declared in stack_conventions - stack_directories = {} - stack_attributes = {} - config.stack_conventions_config.each do |convention| - (convention['stacks'] || []).each do |stack_def| - stack_key = stack_def['id'] || stack_def['name'] - stack_directories[stack_key] = config.stack_convention_for(service_name, stack_key) - &.gsub('{service}', service_name) - &.gsub('{environment}', environment) - stack_attributes[stack_key] = config.stack_attributes_for(environment, stack_key) + def test_service_configuration(service_name:, environment: nil) + config = @config_client.load_workflow_config + if environment && !config.environment_names.include?(environment) + return @presenter.present_error(Entities::Result.failure(error_message: "Environment '#{environment}' not found in configuration")) + end + candidates = {} + config.stacks.each do |stack| + environments = stack.key?('environments') ? stack['environments'].keys : [nil] + environments &= [environment] if environment && stack.key?('environments') + environments.each do |selected| + values = { 'service' => service_name, 'environment' => selected } + stack['paths'].each do |pattern| + @file_client.resolve_directories(pattern: pattern, values: values).each do |match| + captures = match.fetch(:captures) + next if captures.fetch('service').start_with?('.') + directory = match.fetch(:working_directory) + custom = captures.reject { |key, _| %w[service environment].include?(key) } + identity = [service_name, stack['id'], selected, directory] + if candidates.key?(identity) && candidates[identity][:captures] != custom + raise "Conflicting captures for stack '#{stack['id']}' at '#{directory}'" + end + candidates[identity] = { stack: stack, environment: selected, working_directory: directory, captures: custom } + end end end - - @presenter.present_service_test_result( - service_name: service_name, - environment: environment, - stack_attributes: stack_attributes, - service_config: service_config, - stack_directories: stack_directories - ) - rescue => error - @presenter.present_error( - Entities::Result.failure(error_message: "Failed to test service configuration: #{error.message}") - ) end + matches = candidates.values.map do |candidate| + stack = candidate.fetch(:stack) + selected = candidate.fetch(:environment) + captures = candidate.fetch(:captures) + attributes = stack.key?('environments') ? stack['environments'].fetch(selected) : stack['attributes'] + target = Entities::DeploymentTarget.new(service: service_name, environment: selected, stack: stack['name'], stack_id: stack['id'], working_directory: candidate.fetch(:working_directory), attributes: attributes, captures: captures) + { target: target, excluded: config.excluded?(stack, captures.merge('service' => service_name, 'environment' => selected)) } + end + @presenter.present_service_test_result(service_name: service_name, matches: matches) + rescue => error + @presenter.present_error(Entities::Result.failure(error_message: "Failed to test service configuration: #{error.message}")) end # Diagnostic check for configuration and environment @@ -160,63 +152,28 @@ def generate_config_template private - # Build a configuration template with examples def build_config_template <<~YAML - # Workflow Automation Configuration Template - # Generated by config-manager - - environments: - - environment: develop - stacks: - aws: + stacks: + - name: terragrunt + id: aws + paths: + - "dystopia/{service}/aws/{environment}" + - "system-components/{service}/infrastructure/aws/{environment}" + environments: + develop: aws_region: ap-northeast-1 - iam_role_plan: arn:aws:iam::ACCOUNT_ID:role/github-oidc-auth-develop-plan-role - iam_role_apply: arn:aws:iam::ACCOUNT_ID:role/github-oidc-auth-develop-apply-role - docker: - repository: AWS_ACCOUNT_ID.dkr.ecr.AWS_REGION.amazonaws.com/REPOSITORY_NAME - kubernetes: - namespace: default - - - environment: staging - stacks: - aws: - aws_region: ap-northeast-1 - iam_role_plan: arn:aws:iam::ACCOUNT_ID:role/github-oidc-auth-staging-plan-role - iam_role_apply: arn:aws:iam::ACCOUNT_ID:role/github-oidc-auth-staging-apply-role - docker: - repository: AWS_ACCOUNT_ID.dkr.ecr.AWS_REGION.amazonaws.com/REPOSITORY_NAME - kubernetes: - namespace: default - - - environment: production - stacks: - aws: - aws_region: ap-northeast-1 - iam_role_plan: arn:aws:iam::ACCOUNT_ID:role/github-oidc-auth-production-plan-role - iam_role_apply: arn:aws:iam::ACCOUNT_ID:role/github-oidc-auth-production-apply-role - docker: - repository: AWS_ACCOUNT_ID.dkr.ecr.AWS_REGION.amazonaws.com/REPOSITORY_NAME - kubernetes: - namespace: default - - stack_conventions: - - root: "{service}" - stacks: - - name: aws - directory: "aws/{environment}" - required_attributes: [aws_region, iam_role_plan, iam_role_apply] - - name: docker - directory: "" - - name: kubernetes - directory: "kubernetes/overlays/{environment}" - - services: - - name: excluded-service - exclude_from_automation: true - exclusion_config: - reason: "Manual deployment required due to special requirements" - type: "permanent" + production: + aws_region: us-west-2 + exclude: + - service: demo + environment: production + - name: container + paths: + - "dystopia/{service}" + - "system-components/{service}" + attributes: + repository: registry.example.com/app YAML end end diff --git a/action-scripts/label-dispatcher/README-ja.md b/action-scripts/label-dispatcher/README-ja.md index b941ee1..7fff54c 100644 --- a/action-scripts/label-dispatcher/README-ja.md +++ b/action-scripts/label-dispatcher/README-ja.md @@ -4,25 +4,25 @@ GitHub Actions デプロイメント自動化のための Ruby ベースのサービス変更検出・ラベル管理ツール -## 概要 +## Overview Label Dispatcherはプルリクエストのファイル変更を分析し、影響を受けるサービスを検出し、デプロイラベルを自動的に管理します。コード変更に基づいてデプロイが必要なサービスを識別することで、デプロイ自動化のエントリーポイントとして機能します。 -## 機能 +## Features - **変更検出**: Gitの差分を分析して変更されたファイルを識別 - **サービスマッピング**: ファイル変更をサービスデプロイにマッピング - **ラベル管理**: PRのデプロイラベルを自動的に追加/削除 -- **除外サポート**: 自動化から除外されたサービスの処理 +- **Exclusion Support**: stack ごとの照合条件で除外 - **GitHub統合**: シームレスなPRラベル管理 - **ディレクトリ規則**: 柔軟なサービスディレクトリ検出 - **デプロイメント戦略非依存**: 任意のブランチ戦略や開発ワークフローに対応 -## 使用方法 +## Usage Label Dispatcherは`bin/dispatcher`を通じてCLIインターフェースを提供します: -### 基本コマンド +### Commands ```bash # PRのラベル配信(自動モード) @@ -44,7 +44,7 @@ bundle exec ruby label-dispatcher/bin/dispatcher validate_env bundle exec ruby label-dispatcher/bin/dispatcher help_usage ``` -### ワークフロー統合 +### Workflow Integration ディスパッチャーは通常GitHub Actionsワークフローから呼び出されます: @@ -57,7 +57,7 @@ bundle exec ruby label-dispatcher/bin/dispatcher help_usage github-token: ${{ secrets.GITHUB_TOKEN }} ``` -### 環境変数 +### Environment Variables ディスパッチャーは GitHub Actions 用に以下の環境変数を設定します: @@ -66,69 +66,15 @@ bundle exec ruby label-dispatcher/bin/dispatcher help_usage - `LABELS_REMOVED`: 削除されたラベルの JSON 配列 - `HAS_CHANGES`: 変更が検出されたかを示すブール値 -## 核心ロジック +## Change Matching -### 1. 変更検出 +変更パスの照合とラベル生成の条件は [Change Matching](README.md#change-matching) を参照してください。 -Git差分を分析して変更されたファイルを識別: -- ベースコミットとヘッドコミットを比較 -- 追加、変更、削除されたファイルを識別 -- デプロイに関連しない変更を除外 -- APIベースとGitベースの両方の検出をサポート +## Configuration -### 2. サービスマッピング +設定仕様はルートの [Configuration](../../README.md#configuration) を参照してください。 -ファイル変更をサービスデプロイにマッピング: -- ディレクトリ規則を使用してサービスを識別 -- デフォルトとカスタムディレクトリパターンの両方をサポート -- 複数のデプロイスタック(Terragrunt、Kubernetes)を処理 -- サービス固有の設定オーバーライドを適用 - -### 3. ラベル管理 - -PRラベルを自動的に管理: -- 変更されたサービスに`deploy:service-name`ラベルを追加 -- 変更されなくなったサービスのラベルを削除 -- PR更新全体でラベルの一貫性を維持 -- バッチラベル操作をサポート - -### 4. 除外処理 - -自動化から除外されたサービスを管理: -- 設定から除外されたサービスを識別 -- 除外理由とタイプ情報を提供 -- 一時的と永続的な除外をサポート - -## 設定 - -ディスパッチャーは設定に`workflow-config.yaml`を使用します: - -```yaml -# サービス検出用のディレクトリ規則(階層構造) -stack_conventions: - - root: "{service}" - stacks: - - name: aws - directory: "aws/{environment}" - - name: kubernetes - directory: "kubernetes/overlays/{environment}" - -# サービス固有の設定 -services: - - name: excluded-service - exclude_from_automation: true - exclusion_config: - reason: "特別な要件により手動デプロイが必要" - type: "permanent" - - - name: legacy-service - exclude_from_automation: true - exclusion_config: - reason: "移行進行中" - type: "temporary" -``` - -## アーキテクチャ +## Architecture Label Dispatcherはクリーンアーキテクチャパターンに従います: @@ -144,32 +90,18 @@ Label Dispatcherはクリーンアーキテクチャパターンに従います - `FileSystemClient`: Git操作とファイル分析 - `ConfigClient`: 設定管理 -## 必要な環境変数 +## Required Environment Variables - `GITHUB_TOKEN`: GitHub API アクセスに必要 - `GITHUB_REPOSITORY`: リポジトリ名(owner/repo 形式) - `GITHUB_ACTIONS`: GitHub Actions 出力フォーマットを有効化 - `WORKFLOW_CONFIG_PATH`: 設定ファイルのパス(オプション、デフォルトは workflow-config.yaml) -## サービス検出ロジック - -ディスパッチャーは以下のロジックを使用してサービスを検出します: - -1. **ファイル分析**: PRで変更されたファイルを調査 -2. **パターンマッチング**: ファイルパスをディレクトリ規則と照合 -3. **サービス抽出**: マッチしたパターンからサービス名を抽出 -4. **設定検索**: サービス固有の設定を適用 -5. **除外フィルタリング**: 除外されたサービスを結果から削除 - -### 検出例 +## Detection Results -`services/auth/aws/develop/main.tf`でのファイル変更の場合: -- パターンマッチ: `services/{service}/aws/{environment}` -- サービス抽出: `auth` -- `auth`サービスの設定を適用 -- ラベル追加: `deploy:auth` +検出結果と GitHub Actions の出力は [Detection Results](README.md#detection-results) を参照してください。 -## エラーハンドリング +## Error Handling ディスパッチャーは包括的なエラーハンドリングを提供します: @@ -178,9 +110,9 @@ Label Dispatcherはクリーンアーキテクチャパターンに従います - **設定問題**: 詳細なエラーメッセージを提供 - **権限エラー**: トークン権限の明確なガイダンス -## 開発 +## Development -### 依存関係 +### Dependencies - Ruby 3.4+ - Bundler @@ -188,7 +120,7 @@ Label Dispatcherはクリーンアーキテクチャパターンに従います - Octokit (GitHub API) - Git (システム依存関係) -### テスト +### Testing ```bash # 現在の作業ディレクトリでテスト @@ -201,7 +133,7 @@ bundle exec ruby label-dispatcher/bin/dispatcher test --base-ref=main --head-ref bundle exec ruby label-dispatcher/bin/dispatcher validate_env ``` -## 統合ポイント +## Integration Points Label Dispatcherは以下と統合します: @@ -210,7 +142,7 @@ Label Dispatcherは以下と統合します: 3. **GitHub Actions**: PRイベントでトリガーされ更新 4. **Gitリポジトリ**: ファイル変更と履歴を分析 -## ラベル規則 +## Label Conventions ディスパッチャーは標準化されたラベル形式を使用します: @@ -218,10 +150,10 @@ Label Dispatcherは以下と統合します: - `deploy:all` - 全サービスのデプロイ(特殊ケース) - ラベルは自動的に管理・同期される -## 安全性機能 +## Safety Features - **変更検証**: 関連する変更のみがデプロイをトリガーすることを確保 - **設定検証**: 処理前の設定検証 - **権限チェック**: GitHubトークンの権限を確認 -- **除外の尊重**: サービス除外設定を遵守 +- **Exclusion Respect**: 各照合の除外条件を適用 - **監査証跡**: トラブルシューティング用のすべてのラベル操作をログ記録 diff --git a/action-scripts/label-dispatcher/README.md b/action-scripts/label-dispatcher/README.md index 7e9288a..8204072 100644 --- a/action-scripts/label-dispatcher/README.md +++ b/action-scripts/label-dispatcher/README.md @@ -13,7 +13,7 @@ The Label Dispatcher analyzes file changes in pull requests, detects affected se - **Change Detection**: Analyze Git diffs to identify modified files - **Service Mapping**: Map file changes to service deployments - **Label Management**: Automatically add/remove deployment labels on PRs -- **Exclusion Support**: Handle services excluded from automation +- **Exclusion Support**: stack ごとの照合条件で除外 - **GitHub Integration**: Seamless PR label management - **Directory Conventions**: Flexible service directory detection - **Deployment Strategy Agnostic**: Works with any branching strategy or development workflow @@ -66,67 +66,15 @@ The dispatcher sets the following environment variables for GitHub Actions: - `LABELS_REMOVED`: JSON array of labels that were removed - `HAS_CHANGES`: Boolean indicating if changes were detected -## Core Logic +## Change Matching -### 1. Change Detection +変更ファイルを定義された stack のパスに照合し、サービス・環境・任意 placeholder を抽出します。パスに環境名がない場合は、その stack の定義環境ごとに評価します。環境共通の stack は environment が null の照合として扱います。 -Analyzes Git differences to identify modified files: -- Compares base and head commits -- Identifies added, modified, and deleted files -- Filters out non-deployment relevant changes -- Supports both API-based and Git-based detection - -### 2. Service Mapping - -Maps file changes to service deployments: -- Uses directory conventions to identify services -- Supports both default and custom directory patterns -- Handles multiple deployment stacks (Terragrunt, Kubernetes) -- Applies service-specific configuration overrides - -### 3. Label Management - -Automatically manages PR labels: -- Adds `deploy:service-name` labels for changed services -- Removes labels for services no longer changed -- Maintains label consistency across PR updates -- Supports batch label operations - -### 4. Exclusion Handling - -Manages services excluded from automation: -- Identifies excluded services from configuration -- Provides exclusion reason and type information -- Supports temporary and permanent exclusions +各照合に除外条件を適用し、除外されない照合が一つでもあるサービスを一度だけラベル対象にします。サービス全体のディレクトリを検出対象にする場合は、そのパスも stack に定義します。削除されたファイルも照合するため、ディレクトリの存在は要求しません。 ## Configuration -The dispatcher uses `workflow-config.yaml` for configuration: - -```yaml -# Directory conventions for service detection (hierarchical structure) -stack_conventions: - - root: "{service}" - stacks: - - name: aws - directory: "aws/{environment}" - - name: kubernetes - directory: "kubernetes/overlays/{environment}" - -# Service-specific configurations -services: - - name: excluded-service - exclude_from_automation: true - exclusion_config: - reason: "Manual deployment required due to special requirements" - type: "permanent" - - - name: legacy-service - exclude_from_automation: true - exclusion_config: - reason: "Migration in progress" - type: "temporary" -``` +設定仕様はルートの [Configuration](../../README.md#configuration) を参照してください。 ## Architecture @@ -151,23 +99,9 @@ The Label Dispatcher follows a clean architecture pattern: - `GITHUB_ACTIONS`: Enables GitHub Actions output format - `WORKFLOW_CONFIG_PATH`: Path to configuration file (optional, defaults to workflow-config.yaml) -## Service Detection Logic - -The dispatcher uses the following logic to detect services: - -1. **File Analysis**: Examine changed files in the PR -2. **Pattern Matching**: Match file paths against directory conventions -3. **Service Extraction**: Extract service names from matched patterns -4. **Configuration Lookup**: Apply service-specific configurations -5. **Exclusion Filtering**: Remove excluded services from results - -### Example Detection +## Detection Results -For a file change in `services/auth/aws/develop/main.tf`: -- Matches pattern: `services/{service}/aws/{environment}` -- Extracts service: `auth` -- Applies configuration for `auth` service -- Adds label: `deploy:auth` +検出結果にはラベル、変更ファイル、対象サービスを含みます。GitHub Actions では `deploy-labels`、`labels-added`、`labels-removed`、`services-detected`、`has-changes` を出力します。除外状態の確認には [Service Diagnosis](../config-manager/README.md#service-diagnosis) を使います。 ## Error Handling @@ -223,5 +157,5 @@ The dispatcher uses standardized label formats: - **Change Validation**: Ensures only relevant changes trigger deployments - **Configuration Validation**: Validates configuration before processing - **Permission Checks**: Verifies GitHub token permissions -- **Exclusion Respect**: Honors service exclusion configurations +- **Exclusion Respect**: 各照合の除外条件を適用 - **Audit Trail**: Logs all label operations for troubleshooting diff --git a/action-scripts/label-resolver/README-ja.md b/action-scripts/label-resolver/README-ja.md index 2a83150..d9ede4e 100644 --- a/action-scripts/label-resolver/README-ja.md +++ b/action-scripts/label-resolver/README-ja.md @@ -4,11 +4,11 @@ PR ラベルを明示的な環境指定を使って GitHub Actions 自動化のためのデプロイメントターゲットに変換する Ruby ベースのデプロイメント解決ツールです。 -## 概要 +## Overview Label Resolver は PR ラベルを分析し、指定された環境に対するデプロイメントターゲットを生成します。デプロイメントの安全性を検証し、マルチサービスデプロイメント用のデプロイメントマトリクスを作成し、デプロイメント自動化の意思決定の中心的なオーケストレーターとして機能します。 -## 機能 +## Features - **ラベル解決**: PR 情報からデプロイメントラベルを抽出 - **明示的環境指定**: ブランチ依存なしの直接的な環境指定 @@ -16,11 +16,11 @@ Label Resolver は PR ラベルを分析し、指定された環境に対する - **マトリクス生成**: 並列実行用のデプロイメントマトリクス作成 - **GitHub Actions 統合**: GitHub Actions ワークフローとのシームレスな統合 -## 使用方法 +## Usage Label Resolver は `bin/resolver` を通じて CLI インターフェースを提供します: -### 基本コマンド +### Commands ```bash # 特定の環境に対する PR ラベルからのデプロイメント解決 @@ -44,7 +44,7 @@ bundle exec ruby label-resolver/bin/resolver debug PR番号 [環境一覧] - 複数環境: `develop,staging` (カンマ区切り) - 全環境: 環境一覧パラメータを省略 -### 使用例 +### Examples ```bash # develop 環境のデプロイメント解決 @@ -60,7 +60,7 @@ bundle exec ruby label-resolver/bin/resolver debug PR番号 [環境一覧] ./bin/resolver resolve 123 ``` -### ワークフロー統合 +### Workflow Integration リゾルバーは通常 GitHub Actions ワークフローから呼び出されます: @@ -69,18 +69,18 @@ bundle exec ruby label-resolver/bin/resolver debug PR番号 [環境一覧] - name: デプロイメントターゲットの解決 uses: panicboat/deploy-actions/label-resolver@v1 with: - pr_number: ${{ github.event.pull_request.number }} - target_environments: ${{ inputs.target_environment }} + pr-number: ${{ github.event.pull_request.number }} + environments: ${{ inputs.target_environment }} # 複数環境デプロイメント - name: デプロイメントターゲットの解決 uses: panicboat/deploy-actions/label-resolver@v1 with: - pr_number: ${{ github.event.pull_request.number }} - target_environments: "develop,staging" + pr-number: ${{ github.event.pull_request.number }} + environments: "develop,staging" ``` -### 環境変数 +### Environment Variables リゾルバーは GitHub Actions 用に以下の環境変数を設定します: @@ -90,7 +90,7 @@ bundle exec ruby label-resolver/bin/resolver debug PR番号 [環境一覧] - `SAFETY_STATUS`: 安全性検証の結果 - `MERGED_PR_NUMBER`: デプロイメント追跡用の PR 番号 -### Action 出力 +### Action Outputs リゾルバーは以下の GitHub Actions 出力を提供します: @@ -98,9 +98,9 @@ bundle exec ruby label-resolver/bin/resolver debug PR番号 [環境一覧] - `has-targets`: ターゲットが存在するかを示すブール値 (`true`/`false`) - `safety-status`: 安全性検証の結果 (`passed`/`failed`) -## アーキテクチャ +## Architecture -### コンポーネント +### Components - **LabelResolverController**: メインオーケストレーションロジック - **DetermineTargetEnvironment**: 複数環境検証 @@ -108,7 +108,7 @@ bundle exec ruby label-resolver/bin/resolver debug PR番号 [環境一覧] - **ValidateDeploymentSafety**: 安全性チェック(現在簡素化) - **GenerateMatrix**: 複数環境用デプロイメントマトリクス生成 -### フロー +### Flow 1. **ラベル抽出**: PR からデプロイラベルを取得 2. **環境検証**: 全ての対象環境が存在することを検証 @@ -116,56 +116,20 @@ bundle exec ruby label-resolver/bin/resolver debug PR番号 [環境一覧] 4. **マトリクス生成**: ディレクトリ構造に基づいて全環境のデプロイメントターゲットを作成 5. **出力生成**: 簡素化された出力で GitHub Actions 用に結果をフォーマット -## 設定 +## Configuration -リゾルバーは設定に `workflow-config.yaml` を使用します: +設定仕様と matrix の形式はルートの [Configuration](../../README.md#configuration) と [Matrix Output](../../README.md#matrix-output) を参照してください。 -```yaml -environments: - - environment: develop - aws_region: ap-northeast-1 - iam_role_plan: arn:aws:iam::ACCOUNT:role/plan-role - iam_role_apply: arn:aws:iam::ACCOUNT:role/apply-role - - - environment: staging - aws_region: ap-northeast-1 - iam_role_plan: arn:aws:iam::ACCOUNT:role/staging-plan-role - iam_role_apply: arn:aws:iam::ACCOUNT:role/staging-apply-role - - - environment: production - aws_region: ap-northeast-1 - iam_role_plan: arn:aws:iam::ACCOUNT:role/production-plan-role - iam_role_apply: arn:aws:iam::ACCOUNT:role/production-apply-role - -stack_conventions: - - root: "{service}" - stacks: - - name: aws - id: primary - directory: "aws/{environment}" - targets: ["develop", "staging", "production"] - - name: kubernetes - directory: "kubernetes/overlays/{environment}" - targets: ["develop", "staging", "production"] - -services: - - name: excluded-service - exclude_from_automation: true - exclusion_config: - reason: "手動デプロイメントが必要" - type: "permanent" -``` - -## デプロイラベル +## Deploy Labels システムは `deploy:service` 形式のラベルを認識します: - `deploy:auth` - auth サービスをデプロイ - `deploy:api` - api サービスをデプロイ - `deploy:frontend` - frontend サービスをデプロイ -- `deploy:all` - 除外されていない全サービスをデプロイ +- `deploy:all` - 実在する全サービスの対象を解決 -## 環境指定 +## Environment Targeting **トランクベース開発**: リゾルバーはブランチベースマッピングではなく明示的な環境指定を使用: @@ -174,41 +138,29 @@ services: - 設定で定義された任意のデプロイメント環境をサポート - 複数環境への同時デプロイメントが可能 -## ディレクトリ構造検出 - -リゾルバーはディレクトリの存在確認により利用可能なスタックを自動検出します: - -``` -{service}/ -├── aws/{environment}/ # IaC スタック -└── kubernetes/overlays/{environment}/ # Kubernetes スタック -``` - -実際に存在するディレクトリのみがデプロイメントマトリクスに含まれます。 +## Target Resolution -オプションの `id` は stack インスタンスを識別します。識別子は `id || name` で決まり、 -1つの convention 内で一意でなければなりません。同じ `name` でも異なる `id` を持つ -2つのエントリは、別々のターゲットとして生成されます。 +環境選択、実在ディレクトリの列挙、除外、失敗条件は [Target Resolution](README.md#target-resolution) を参照してください。 -## エラーハンドリング +## Error Handling リゾルバーは包括的なエラーハンドリングを提供します: - **無効な環境**: 対象環境が存在しない場合の明確なエラー - **ラベル不足**: デプロイラベルのない PR の適切な処理 - **設定エラー**: ワークフロー設定の詳細な検証 -- **ディレクトリ検出**: デプロイメントディレクトリ不足の警告 +- **Directory Detection**: 存在しないパスは正常な空の結果 -## 開発 +## Development -### テスト実行 +### Running Tests ```bash cd action-scripts bundle exec rspec spec/label-resolver/ ``` -### ローカルテスト +### Local Testing ```bash # 環境設定 diff --git a/action-scripts/label-resolver/README.md b/action-scripts/label-resolver/README.md index 2da8a40..ac30db1 100644 --- a/action-scripts/label-resolver/README.md +++ b/action-scripts/label-resolver/README.md @@ -69,15 +69,15 @@ The resolver is typically called from GitHub Actions workflows: - name: Resolve deployment targets uses: panicboat/deploy-actions/label-resolver@v1 with: - pr_number: ${{ github.event.pull_request.number }} - target_environments: ${{ inputs.target_environment }} + pr-number: ${{ github.event.pull_request.number }} + environments: ${{ inputs.target_environment }} # Multiple environment deployment - name: Resolve deployment targets uses: panicboat/deploy-actions/label-resolver@v1 with: - pr_number: ${{ github.event.pull_request.number }} - target_environments: "develop,staging" + pr-number: ${{ github.event.pull_request.number }} + environments: "develop,staging" ``` ### Environment Variables @@ -118,43 +118,7 @@ The resolver provides the following GitHub Actions outputs: ## Configuration -The resolver uses `workflow-config.yaml` for configuration: - -```yaml -environments: - - environment: develop - aws_region: ap-northeast-1 - iam_role_plan: arn:aws:iam::ACCOUNT:role/plan-role - iam_role_apply: arn:aws:iam::ACCOUNT:role/apply-role - - - environment: staging - aws_region: ap-northeast-1 - iam_role_plan: arn:aws:iam::ACCOUNT:role/staging-plan-role - iam_role_apply: arn:aws:iam::ACCOUNT:role/staging-apply-role - - - environment: production - aws_region: ap-northeast-1 - iam_role_plan: arn:aws:iam::ACCOUNT:role/production-plan-role - iam_role_apply: arn:aws:iam::ACCOUNT:role/production-apply-role - -stack_conventions: - - root: "{service}" - stacks: - - name: aws - id: primary - directory: "aws/{environment}" - targets: ["develop", "staging", "production"] - - name: kubernetes - directory: "kubernetes/overlays/{environment}" - targets: ["develop", "staging", "production"] - -services: - - name: excluded-service - exclude_from_automation: true - exclusion_config: - reason: "Manual deployment required" - type: "permanent" -``` +設定仕様と matrix の形式はルートの [Configuration](../../README.md#configuration) と [Matrix Output](../../README.md#matrix-output) を参照してください。 ## Deploy Labels @@ -163,7 +127,7 @@ The system recognizes labels in the format `deploy:service`: - `deploy:auth` - Deploy auth service - `deploy:api` - Deploy api service - `deploy:frontend` - Deploy frontend service -- `deploy:all` - Deploy all non-excluded services +- `deploy:all` - 実在する全サービスの対象を解決 ## Environment Targeting @@ -173,21 +137,11 @@ The system recognizes labels in the format `deploy:service`: - No dependency on branch names for environment determination - Supports any deployment environment defined in configuration -## Directory Structure Detection - -The resolver automatically detects available stacks by checking directory existence: - -``` -{service}/ -├── aws/{environment}/ # IaC stack -└── kubernetes/overlays/{environment}/ # Kubernetes stack -``` +## Target Resolution -Only directories that actually exist will be included in the deployment matrix. +指定環境を各 stack の定義環境に絞り、全パスの実在ディレクトリを列挙します。環境指定の省略または空白入力は全定義環境を選択します。環境共通の対象は各ディレクトリにつき一度だけ生成します。 -The optional `id` identifies a stack instance; identity is `id || name` and -must be unique within a convention. Two entries with the same `name` but -different `id` values produce two separate targets. +`deploy:all` は設定したパスから全サービスを探索します。除外条件に一致する対象は matrix に含めません。存在しないパスは正常な空の結果になり、未知環境・列挙エラー・抽出値の矛盾は失敗になります。 ## Error Handling @@ -196,7 +150,7 @@ The resolver provides comprehensive error handling: - **Invalid Environment**: Clear error when target environment doesn't exist - **Missing Labels**: Graceful handling of PRs without deploy labels - **Configuration Errors**: Detailed validation of workflow configuration -- **Directory Detection**: Warnings for missing deployment directories +- **Directory Detection**: 存在しないパスは正常な空の結果 ## Development diff --git a/action-scripts/shared/interfaces/presenters/console_presenter.rb b/action-scripts/shared/interfaces/presenters/console_presenter.rb index ff2542f..8fb5f0a 100644 --- a/action-scripts/shared/interfaces/presenters/console_presenter.rb +++ b/action-scripts/shared/interfaces/presenters/console_presenter.rb @@ -83,30 +83,22 @@ def present_config_validation_result(valid:, errors: [], config: nil, summary: n end end - # Present configuration details def present_config_details(config:) - puts "📋 Workflow Configuration".colorize(:blue) - puts "Environments: #{config.environments.keys.join(', ')}" - puts "Services: #{config.services.keys.join(', ')}" - - puts "\nDirectory Conventions:" - config.stack_conventions.each do |convention| - root = convention['root'] - (convention['stacks'] || []).each do |stack| - puts " #{stack['name']}: #{root}/#{stack['directory']}" - end + puts "Workflow Configuration" + puts "Environments: #{config.environment_names.join(', ')}" + config.stacks.each do |stack| + puts "Stack '#{stack['id']}' (#{stack['name']}):" + puts stack.to_yaml end end - # Present service test results - def present_service_test_result(service_name:, environment:, stack_attributes:, service_config:, stack_directories:) - puts "🔧 Service Configuration Test".colorize(:blue) + def present_service_test_result(service_name:, matches:) + puts "Service Configuration Test" puts "Service: #{service_name}" - puts "Environment: #{environment}" - stack_directories.each do |stack_name, directory| - puts "Stack '#{stack_name}':" - puts " directory: #{directory}" - (stack_attributes[stack_name] || {}).each { |key, value| puts " #{key}: #{value}" } + matches.each do |match| + puts "Target:" + match.fetch(:target).to_matrix_item.each { |key, value| puts " #{key}: #{value.inspect}" } + puts " excluded: #{match.fetch(:excluded)}" end end diff --git a/action-scripts/shared/interfaces/presenters/github_actions_presenter.rb b/action-scripts/shared/interfaces/presenters/github_actions_presenter.rb index c09bc3f..2223d20 100644 --- a/action-scripts/shared/interfaces/presenters/github_actions_presenter.rb +++ b/action-scripts/shared/interfaces/presenters/github_actions_presenter.rb @@ -105,30 +105,22 @@ def present_config_validation_result(valid:, errors: [], config: nil, summary: n end end - # Present configuration details def present_config_details(config:) - puts "📋 Workflow Configuration" - puts "Environments: #{config.environments.keys.join(', ')}" - puts "Services: #{config.services.keys.join(', ')}" - - puts "\nDirectory Conventions:" - config.stack_conventions.each do |convention| - root = convention['root'] - (convention['stacks'] || []).each do |stack| - puts " #{stack['name']}: #{root}/#{stack['directory']}" - end + puts "Workflow Configuration" + puts "Environments: #{config.environment_names.join(', ')}" + config.stacks.each do |stack| + puts "Stack '#{stack['id']}' (#{stack['name']}):" + puts stack.to_yaml end end - # Present service test results - def present_service_test_result(service_name:, environment:, stack_attributes:, service_config:, stack_directories:) - puts "🔧 Service Configuration Test" + def present_service_test_result(service_name:, matches:) + puts "Service Configuration Test" puts "Service: #{service_name}" - puts "Environment: #{environment}" - stack_directories.each do |stack_name, directory| - puts "Stack '#{stack_name}':" - puts " directory: #{directory}" - (stack_attributes[stack_name] || {}).each { |key, value| puts " #{key}: #{value}" } + matches.each do |match| + puts "Target:" + match.fetch(:target).to_matrix_item.each { |key, value| puts " #{key}: #{value.inspect}" } + puts " excluded: #{match.fetch(:excluded)}" end end diff --git a/action-scripts/spec/config-manager/cli_spec.rb b/action-scripts/spec/config-manager/cli_spec.rb new file mode 100644 index 0000000..6ad63b6 --- /dev/null +++ b/action-scripts/spec/config-manager/cli_spec.rb @@ -0,0 +1,32 @@ +require 'spec_helper' +require 'thor' + +RSpec.describe 'ConfigManagerCLI' do + before(:all) { load File.expand_path('../../config-manager/bin/config-manager', __dir__) } + + it 'does not start commands when loaded as a library' do + expect(ConfigManagerCLI).not_to receive(:start) + load File.expand_path('../../config-manager/bin/config-manager', __dir__) + end + + it 'does not register service list or global exclusion commands' do + expect(ConfigManagerCLI.all_commands.keys).not_to include('services', 'excluded_services') + end + + it 'lists the union of configured environment names' do + config = Entities::WorkflowConfig.new('stacks' => [ + { 'name' => 'aws', 'paths' => ['{service}/aws'], 'environments' => { 'production' => {}, 'preview' => {} } }, + { 'name' => 'kubernetes', 'paths' => ['{service}/k8s'], 'environments' => { 'preview' => {}, 'sandbox' => {} } } + ]) + client = instance_double(Infrastructure::ConfigClient, load_workflow_config: config) + allow(ConfigManagerContainer).to receive(:resolve).with(:config_client).and_return(client) + expect { ConfigManagerCLI.new.environments }.to output(/production.*preview.*sandbox/m).to_stdout + end + + it 'passes omitted environments to service diagnosis' do + controller = instance_double(Interfaces::Controllers::ConfigManagerController) + allow(ConfigManagerContainer).to receive(:resolve).with(:config_manager_controller).and_return(controller) + expect(controller).to receive(:test_service_configuration).with(service_name: 'demo', environment: nil) + ConfigManagerCLI.new.test('demo') + end +end diff --git a/action-scripts/spec/config-manager/controllers/config_manager_controller_spec.rb b/action-scripts/spec/config-manager/controllers/config_manager_controller_spec.rb index 06dd30d..cbc153a 100644 --- a/action-scripts/spec/config-manager/controllers/config_manager_controller_spec.rb +++ b/action-scripts/spec/config-manager/controllers/config_manager_controller_spec.rb @@ -1,16 +1,22 @@ # spec/config-manager/controllers/config_manager_controller_spec.rb require 'spec_helper' +require 'tmpdir' +require 'fileutils' RSpec.describe Interfaces::Controllers::ConfigManagerController do let(:validate_config_use_case) { double('ValidateConfig') } - let(:config_client) { double('ConfigClient') } + let(:config_hash) { attributes_for(:workflow_config).fetch(:config_hash) } + let(:config) { Entities::WorkflowConfig.new(config_hash) } + let(:config_client) { instance_double(Infrastructure::ConfigClient, load_workflow_config: config) } + let(:file_client) { Infrastructure::FileSystemClient.new } let(:presenter) { double('Presenter') } subject(:controller) do described_class.new( validate_config_use_case: validate_config_use_case, config_client: config_client, + file_client: file_client, presenter: presenter ) end @@ -45,7 +51,7 @@ double( 'Result', success?: false, - validation_errors: ['Missing required field: environments'], + validation_errors: ['Missing required field: stacks'], error_message: 'Validation failed' ) end @@ -58,7 +64,7 @@ expect(presenter).to have_received(:present_config_validation_result).with( valid: false, - errors: ['Missing required field: environments'] + errors: ['Missing required field: stacks'] ) end end @@ -119,127 +125,100 @@ end describe '#test_service_configuration' do - let(:config) { build(:workflow_config) } - let(:service_name) { 'test-service' } - let(:environment) { 'develop' } - - context 'with valid service and environment' do - before do - allow(config_client).to receive(:load_workflow_config).and_return(config) - allow(config).to receive_message_chain(:services, :key?).with(service_name).and_return(true) - allow(config).to receive_message_chain(:environments, :key?).with(environment).and_return(true) - allow(config).to receive_message_chain(:services, :[]).with(service_name).and_return({ - 'name' => service_name - }) - allow(config).to receive(:stack_convention_for).with(service_name, 'terragrunt').and_return('services/{service}/terragrunt/envs/{environment}') - allow(config).to receive(:stack_convention_for).with(service_name, 'kubernetes').and_return('services/{service}/kubernetes/overlays/{environment}') - allow(config).to receive(:stack_conventions_config).and_return([ - { - 'root' => '{service}', - 'stacks' => [ - { 'name' => 'terragrunt', 'directory' => 'terragrunt/{environment}' }, - { 'name' => 'kubernetes', 'directory' => 'kubernetes/overlays/{environment}' } - ] - } - ]) - allow(config).to receive(:stack_attributes_for).with(environment, 'terragrunt').and_return({ - 'aws_region' => 'ap-northeast-1', - 'iam_role_plan' => 'arn:aws:iam::123:role/plan', - 'iam_role_apply' => 'arn:aws:iam::123:role/apply' - }) - allow(config).to receive(:stack_attributes_for).with(environment, 'kubernetes').and_return({}) - allow(presenter).to receive(:present_service_test_result) + around do |example| + original = ENV['SOURCE_REPO_PATH'] + Dir.mktmpdir do |root| + @root = root + FileUtils.mkdir_p(File.join(root, '.git')) + ENV['SOURCE_REPO_PATH'] = root + example.run end + ensure + ENV['SOURCE_REPO_PATH'] = original + end - it 'presents service test result' do - controller.test_service_configuration(service_name: service_name, environment: environment) - - expect(presenter).to have_received(:present_service_test_result).with( - service_name: service_name, - environment: environment, - stack_attributes: hash_including('terragrunt' => hash_including('aws_region' => 'ap-northeast-1')), - service_config: hash_including('name' => service_name), - stack_directories: { - 'terragrunt' => 'services/test-service/terragrunt/envs/develop', - 'kubernetes' => 'services/test-service/kubernetes/overlays/develop' - } - ) - end + def directory(path) + FileUtils.mkdir_p(File.join(@root, path)) end - context 'with stack names other than terragrunt/kubernetes' do - before do - allow(config_client).to receive(:load_workflow_config).and_return(config) - allow(config).to receive_message_chain(:services, :key?).with(service_name).and_return(true) - allow(config).to receive_message_chain(:environments, :key?).with(environment).and_return(true) - allow(config).to receive_message_chain(:services, :[]).with(service_name).and_return({ - 'name' => service_name - }) - allow(config).to receive(:stack_convention_for).with(service_name, 'aws').and_return('services/{service}/terragrunt/envs/{environment}') - allow(config).to receive(:stack_convention_for).with(service_name, 'docker').and_return('services/{service}/workspace') - allow(config).to receive(:stack_conventions_config).and_return([ - { - 'root' => 'services/{service}', - 'stacks' => [ - { 'name' => 'aws', 'directory' => 'terragrunt/envs/{environment}' }, - { 'name' => 'docker', 'directory' => 'workspace' } - ] - } - ]) - allow(config).to receive(:stack_attributes_for).with(environment, 'aws').and_return({ - 'aws_region' => 'ap-northeast-1' - }) - allow(config).to receive(:stack_attributes_for).with(environment, 'docker').and_return({}) - allow(presenter).to receive(:present_service_test_result) - end + def matches(environment: nil, service: 'demo') + allow(presenter).to receive(:present_service_test_result) + controller.test_service_configuration(service_name: service, environment: environment) + expect(presenter).to have_received(:present_service_test_result) { |args| return args.fetch(:matches) } + end - it 'presents directories keyed by stack name from stack_conventions' do - controller.test_service_configuration(service_name: service_name, environment: environment) + it 'diagnoses unregistered services across every shared product path and common stack' do + %w[dystopia/demo/aws/develop dystopia/demo/aws/production system-components/demo/infrastructure/aws/develop system-components/demo/infrastructure/aws/production].each { |path| directory(path) } + rows = matches + expect(rows.length).to eq(6) + expect(rows.map { |row| row[:target].working_directory }.uniq.length).to eq(6) + expect(rows.count { |row| row[:target].environment.nil? }).to eq(2) + expect(rows.map { |row| row[:excluded] }.uniq).to eq([false]) + end - expect(presenter).to have_received(:present_service_test_result).with( - hash_including( - stack_directories: { - 'aws' => 'services/test-service/terragrunt/envs/develop', - 'docker' => 'services/test-service/workspace' - } - ) - ) - end + it 'retains environment attributes and includes excluded targets for diagnosis' do + config_hash['stacks'].first['exclude'] = [{ 'service' => 'demo', 'environment' => 'production' }] + directory('dystopia/demo/aws/production') + rows = matches(environment: 'production') + aws = rows.find { |row| row[:target].stack_id == 'aws' } + expect(aws[:excluded]).to be(true) + expect(aws[:target].attributes).to eq('aws_region' => 'us-west-2') + expect(rows.find { |row| row[:target].stack_id == 'container' }[:excluded]).to be(false) end - context 'with non-existing service' do - before do - allow(config_client).to receive(:load_workflow_config).and_return(config) - allow(config).to receive_message_chain(:services, :key?).with(service_name).and_return(false) - allow(presenter).to receive(:present_error) - end + it 'retains distinct identities and captures at the same directory' do + config_hash['stacks'] = [ + { 'name' => 'terragrunt', 'id' => 'aws', 'paths' => ['teams/{team}/{service}'], 'attributes' => { 'token' => nil } }, + { 'name' => 'terragrunt', 'id' => 'stripe', 'paths' => ['teams/{team}/{service}'], 'attributes' => { 'repository' => 'registry.example.com' } } + ] + directory('teams/platform/demo') + rows = matches + expect(rows.map { |row| row[:target].stack_id }).to eq(%w[aws stripe]) + expect(rows.map { |row| row[:target].captures }).to eq([{ 'team' => 'platform' }] * 2) + expect(rows.first[:target].attributes).to eq('token' => nil) + end - it 'presents service not found error' do - controller.test_service_configuration(service_name: service_name, environment: environment) + it 'diagnoses dot-prefixed environment and custom placeholder values' do + config_hash['stacks'] = [{ 'name' => 'terragrunt', 'paths' => ['teams/{team}/{service}/{environment}'], 'environments' => { '.preview' => {} }, 'exclude' => [{ 'team' => '.platform', 'environment' => '.preview' }] }] + directory('teams/.platform/demo/.preview') + rows = matches(environment: '.preview') + expect(rows.length).to eq(1) + expect(rows.first[:target].to_matrix_item).to include(team: '.platform', environment: '.preview') + expect(rows.first[:excluded]).to be(true) + end - expect(presenter).to have_received(:present_error) do |result| - expect(result.failure?).to be true - expect(result.error_message).to include("Service '#{service_name}' not found") - end - end + it 'returns an empty list when no directory matches' do + expect(matches(service: 'unregistered')).to eq([]) end - context 'with non-existing environment' do - before do - allow(config_client).to receive(:load_workflow_config).and_return(config) - allow(config).to receive_message_chain(:services, :key?).with(service_name).and_return(true) - allow(config).to receive_message_chain(:environments, :key?).with(environment).and_return(false) - allow(presenter).to receive(:present_error) - end + it 'deduplicates identical matches' do + config_hash['stacks'] = [{ 'name' => 'container', 'paths' => ['dystopia/{service}', 'dystopia/{service}'] }] + directory('dystopia/demo') + expect(matches.length).to eq(1) + end - it 'presents environment not found error' do - controller.test_service_configuration(service_name: service_name, environment: environment) + it 'reports unknown environments without presenting partial matches' do + allow(presenter).to receive(:present_error) + expect(presenter).not_to receive(:present_service_test_result) + controller.test_service_configuration(service_name: 'demo', environment: 'preview') + expect(presenter).to have_received(:present_error) { |result| expect(result.error_message).to include('preview') } + end - expect(presenter).to have_received(:present_error) do |result| - expect(result.failure?).to be true - expect(result.error_message).to include("Environment '#{environment}' not found") - end - end + it 'reports conflicting captures before exclusions' do + config_hash['stacks'] = [{ 'name' => 'container', 'paths' => ['teams/{team}/{service}', 'teams/{product}/{service}'], 'exclude' => [{ 'team' => 'platform' }] }] + directory('teams/platform/demo') + allow(presenter).to receive(:present_error) + expect(presenter).not_to receive(:present_service_test_result) + controller.test_service_configuration(service_name: 'demo') + expect(presenter).to have_received(:present_error) { |result| expect(result.error_message).to include('Conflicting captures') } + end + + it 'reports enumeration errors without presenting partial matches' do + allow(file_client).to receive(:resolve_directories).and_raise(Errno::EACCES) + allow(presenter).to receive(:present_error) + expect(presenter).not_to receive(:present_service_test_result) + controller.test_service_configuration(service_name: 'demo') + expect(presenter).to have_received(:present_error) { |result| expect(result.error_message).to include('Permission denied') } end end @@ -301,57 +280,19 @@ end describe '#generate_config_template' do - it 'presents generated configuration template' do + it 'generates a template accepted by the configuration model' do allow(presenter).to receive(:present_config_template) - controller.generate_config_template - expect(presenter).to have_received(:present_config_template) do |args| - template = args[:template] - expect(template).to be_a(String) - expect(template).to include('environments:') - expect(template).to include('stack_conventions:') - expect(template).to include('services:') + config = Entities::WorkflowConfig.new(YAML.safe_load(args.fetch(:template))) + expect(config.stacks.map { |stack| stack['id'] }).to include('aws', 'container') + expect(config.environment_names).to include('develop', 'production') end end - it 'uses aws as the stack name in the template' do - allow(presenter).to receive(:present_config_template) - - controller.generate_config_template - - expect(presenter).to have_received(:present_config_template) do |args| - template = args[:template] - expect(template).to include('aws:') - expect(template).to include('name: aws') - expect(template).not_to match(/^\s+terragrunt:/) - expect(template).not_to include('name: terragrunt') - end - end - end - context 'with two stack entries sharing a name but distinct ids' do - let(:config) { double('WorkflowConfig') } - let(:service_name) { 'monolith' } - let(:environment) { 'production' } - before do - allow(config_client).to receive(:load_workflow_config).and_return(config) - allow(config).to receive_message_chain(:services, :key?).with(service_name).and_return(true) - allow(config).to receive_message_chain(:environments, :key?).with(environment).and_return(true) - allow(config).to receive_message_chain(:services, :[]).with(service_name).and_return({ 'name' => service_name }) - allow(config).to receive(:stack_convention_for).with(service_name, 'aws').and_return('dystopia/{service}/infrastructure/aws/{environment}') - allow(config).to receive(:stack_convention_for).with(service_name, 'stripe').and_return('dystopia/{service}/infrastructure/stripe/{environment}') - allow(config).to receive(:stack_conventions_config).and_return([{ 'root'=>'dystopia/{service}', 'stacks'=>[ - { 'name'=>'terragrunt', 'id'=>'aws', 'directory'=>'infrastructure/aws/{environment}' }, - { 'name'=>'terragrunt', 'id'=>'stripe', 'directory'=>'infrastructure/stripe/{environment}' }] }]) - allow(config).to receive(:stack_attributes_for).with(environment, 'aws').and_return({ 'aws_region'=>'ap-northeast-1' }) - allow(config).to receive(:stack_attributes_for).with(environment, 'stripe').and_return({ 'stripe_secret_ref'=>'/panicboat/stripe/api-key' }) - allow(presenter).to receive(:present_service_test_result) - end - it 'keeps both entries distinct instead of the second overwriting the first' do - controller.test_service_configuration(service_name: service_name, environment: environment) - expect(presenter).to have_received(:present_service_test_result).with(hash_including( - stack_directories: { 'aws'=>"dystopia/#{service_name}/infrastructure/aws/#{environment}", 'stripe'=>"dystopia/#{service_name}/infrastructure/stripe/#{environment}" }, - stack_attributes: { 'aws'=>{ 'aws_region'=>'ap-northeast-1' }, 'stripe'=>{ 'stripe_secret_ref'=>'/panicboat/stripe/api-key' } })) + it 'accepts the repository sample with the same model' do + config = Entities::WorkflowConfig.new(YAML.safe_load(File.read(File.expand_path('../../../workflow-config.yaml', __dir__)))) + expect(config.stacks.map { |stack| stack['id'] }).to include('aws', 'container') end end end diff --git a/action-scripts/spec/shared/interfaces/presenters/console_presenter_spec.rb b/action-scripts/spec/shared/interfaces/presenters/console_presenter_spec.rb index cf72008..fbe2a27 100644 --- a/action-scripts/spec/shared/interfaces/presenters/console_presenter_spec.rb +++ b/action-scripts/spec/shared/interfaces/presenters/console_presenter_spec.rb @@ -10,4 +10,19 @@ it 'accepts only the dispatch result fields' do expect(described_class.instance_method(:present_label_dispatch_result).parameters.map(&:last)).to eq(%i[deploy_labels labels_added labels_removed changed_files]) end + it 'shows each stack identity with all paths generic attributes and exclusion conditions' do + config = Entities::WorkflowConfig.new('stacks' => [ + { 'name' => 'terragrunt', 'id' => 'aws', 'paths' => ['dystopia/{service}/aws', 'system-components/{service}/aws'], 'environments' => { 'develop' => { 'token' => nil } }, 'exclude' => [{ 'service' => 'demo' }] }, + { 'name' => 'container', 'paths' => ['dystopia/{service}'], 'attributes' => { 'repository' => 'registry.example.com' } } + ]) + expect { described_class.new.present_config_details(config: config) }.to output(/Stack 'aws'.*dystopia.*system-components.*develop.*token.*exclude.*demo.*Stack 'container'.*repository.*registry.example.com/m).to_stdout + end + + it 'shows complete diagnostic targets and their exclusion status' do + target = Entities::DeploymentTarget.new(service: 'demo', stack: 'terragrunt', stack_id: 'aws', working_directory: 'teams/platform/demo/aws', captures: { 'team' => 'platform' }, attributes: { 'token' => nil }) + expect do + described_class.new.present_service_test_result(service_name: 'demo', matches: [{ target: target, excluded: true }]) + end.to output(/stack_id: "aws".*working_directory: "teams\/platform\/demo\/aws".*token: nil.*team: "platform".*excluded: true/m).to_stdout + end + end diff --git a/action-scripts/spec/shared/interfaces/presenters/github_actions_presenter_spec.rb b/action-scripts/spec/shared/interfaces/presenters/github_actions_presenter_spec.rb index 4412ef6..63852e3 100644 --- a/action-scripts/spec/shared/interfaces/presenters/github_actions_presenter_spec.rb +++ b/action-scripts/spec/shared/interfaces/presenters/github_actions_presenter_spec.rb @@ -32,4 +32,19 @@ it 'accepts only the dispatch result fields' do expect(described_class.instance_method(:present_label_dispatch_result).parameters.map(&:last)).to eq(%i[deploy_labels labels_added labels_removed changed_files]) end + it 'shows each stack identity with all paths generic attributes and exclusion conditions' do + config = Entities::WorkflowConfig.new('stacks' => [ + { 'name' => 'terragrunt', 'id' => 'aws', 'paths' => ['dystopia/{service}/aws', 'system-components/{service}/aws'], 'environments' => { 'develop' => { 'token' => nil } }, 'exclude' => [{ 'service' => 'demo' }] }, + { 'name' => 'container', 'paths' => ['dystopia/{service}'], 'attributes' => { 'repository' => 'registry.example.com' } } + ]) + expect { described_class.new.present_config_details(config: config) }.to output(/Stack 'aws'.*dystopia.*system-components.*develop.*token.*exclude.*demo.*Stack 'container'.*repository.*registry.example.com/m).to_stdout + end + + it 'shows complete diagnostic targets and their exclusion status' do + target = Entities::DeploymentTarget.new(service: 'demo', stack: 'terragrunt', stack_id: 'aws', working_directory: 'teams/platform/demo/aws', captures: { 'team' => 'platform' }, attributes: { 'token' => nil }) + expect do + described_class.new.present_service_test_result(service_name: 'demo', matches: [{ target: target, excluded: true }]) + end.to output(/stack_id: "aws".*working_directory: "teams\/platform\/demo\/aws".*token: nil.*team: "platform".*excluded: true/m).to_stdout + end + end diff --git a/action-scripts/workflow-config.yaml b/action-scripts/workflow-config.yaml index b68b4e8..7147c55 100644 --- a/action-scripts/workflow-config.yaml +++ b/action-scripts/workflow-config.yaml @@ -1,51 +1,42 @@ -environments: - - environment: develop - stacks: - aws: +stacks: + - name: terragrunt + id: aws + paths: + - "dystopia/{service}/aws/{environment}" + - "system-components/{service}/infrastructure/aws/{environment}" + environments: + develop: aws_region: ap-northeast-1 iam_role_plan: arn:aws:iam::123456789012:role/aws-plan-develop-role iam_role_apply: arn:aws:iam::123456789012:role/aws-apply-develop-role - docker: - repository: 123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/REPOSITORY_NAME - kubernetes: - namespace: default - - - environment: staging - stacks: - aws: + staging: aws_region: ap-northeast-1 iam_role_plan: arn:aws:iam::123456789012:role/aws-plan-staging-role iam_role_apply: arn:aws:iam::123456789012:role/aws-apply-staging-role - docker: - repository: 123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/REPOSITORY_NAME - kubernetes: - namespace: default - - - environment: production - stacks: - aws: + production: aws_region: ap-northeast-1 iam_role_plan: arn:aws:iam::123456789012:role/aws-plan-production-role iam_role_apply: arn:aws:iam::123456789012:role/aws-apply-production-role - docker: - repository: 123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/REPOSITORY_NAME - kubernetes: + exclude: + - service: demo + - name: container + paths: + - "dystopia/{service}" + - "system-components/{service}" + attributes: + repository: registry.example.com/app + exclude: + - service: demo + - name: kubernetes + paths: + - "dystopia/{service}/kubernetes/overlays/{environment}" + - "system-components/{service}/kubernetes/overlays/{environment}" + environments: + develop: + namespace: default + staging: + namespace: default + production: namespace: default - -stack_conventions: - - root: "{service}" - stacks: - - name: aws - directory: "aws/{environment}" - required_attributes: [aws_region, iam_role_plan, iam_role_apply] - - name: docker - directory: "" - - name: kubernetes - directory: "kubernetes/overlays/{environment}" - -services: - - name: demo - exclude_from_automation: true - exclusion_config: - reason: "The demo service is for learning purposes, so the directory structure is completely different." - type: "permanent" + exclude: + - service: demo diff --git a/docs/superpowers/plans/stack-configuration.md b/docs/superpowers/plans/stack-configuration.md index a50c45e..e980901 100644 --- a/docs/superpowers/plans/stack-configuration.md +++ b/docs/superpowers/plans/stack-configuration.md @@ -396,7 +396,7 @@ Run: `git commit -s -m 'feat: apply stack exclusions to changed service matches' - Preserves: `#validate_configuration`・`#show_configuration`・`#run_diagnostics`・`#generate_config_template`、presenter の `#present_config_details(config:)` と `#present_config_template(template:)`。 - Produces: `ConfigManagerCLI#test(service_name, environment = nil)` と `#environments`。`services` と `excluded_services` のコマンドは削除する。 -- [ ] **Step 1: Write configuration display and diagnostic tests** +- [x] **Step 1: Write configuration display and diagnostic tests** `show_configuration` がそのまま検証済みモデルを presenter へ渡すこと、両 presenter が stack_id ごとに全パス・各環境属性または共通属性・除外条件を表示することをテストする。非 AWS 属性 `repository` と `token` が表示されることを assertion にする。 @@ -414,13 +414,13 @@ end CLI の spec は require 時の自動起動がないこと、environments が和集合を表示すること、削除した2コマンドが Thor の登録コマンドにないことを確認する。テンプレートとリポジトリの sample YAML が同じモデルで検証できるテストを追加する。 -- [ ] **Step 2: Confirm the configuration command tests fail** +- [x] **Step 2: Confirm the configuration command tests fail** Run: `bundle exec rspec spec/config-manager spec/shared/interfaces/presenters` Expected: 新しい診断引数、汎用属性の表示、旧コマンドの削除、新形式のテンプレートで FAIL。 -- [ ] **Step 3: Implement configuration commands and update their documentation** +- [x] **Step 3: Implement configuration commands and update their documentation** Config manager にファイルクライアントを注入する。診断は stack ごとに環境を選択し、各パスを Task 2 のメソッドで解決する。属性・capture を持つ `DeploymentTarget` と Task 1 の除外判定を組にして presenter へ渡す。未知の環境・設定・列挙・capture の矛盾は `present_error` へ伝える。 @@ -428,31 +428,31 @@ Config manager にファイルクライアントを注入する。診断は stac sample と README の設定例・matrix 出力・除外説明・コマンド例を変更する。設定スキーマと matrix の説明は root README を参照先とし、各 component README には担当するコマンドとその結果を記載する。本文を同じ媒体間で複製せず、新しい見出しを英語・新しい本文を日本語にする。現在の仕様として示す旧形式の例、旧コマンド、旧 root 出力を残さない。履歴文書は変更しない。 -- [ ] **Step 4: Confirm configuration command tests pass** +- [x] **Step 4: Confirm configuration command tests pass** Run: `bundle exec rspec spec/config-manager spec/shared/interfaces/presenters` Expected: exit 0、`0 failures`。 -- [ ] **Step 5: Confirm the complete suite passes** +- [x] **Step 5: Confirm the complete suite passes** Run: `bundle exec rspec` Expected: exit 0、`0 failures`。 -- [ ] **Step 6: Validate the sample configuration through the CLI** +- [x] **Step 6: Validate the sample configuration through the CLI** Run: `bundle exec ruby config-manager/bin/config-manager validate` Expected: exit 0、`Configuration is valid` と新モデルの stack・環境数のサマリー。 -- [ ] **Step 7: Confirm stack configuration display through the CLI** +- [x] **Step 7: Confirm stack configuration display through the CLI** Run: `bundle exec ruby config-manager/bin/config-manager show` Expected: exit 0、各 stack のパス・属性・除外条件を表示する。 -- [ ] **Step 8: Review removal scope and added comments** +- [x] **Step 8: Review removal scope and added comments** リポジトリルートで次を実行し、旧モデルに依存する製品コード・fixture・現在の設定例が検索結果に残っていないことを確認する。旧形式を拒否するテスト入力は残してよい。`rg` の exit 1 は該当なしを表す。 @@ -460,7 +460,7 @@ Run: `rg -n 'stack_conventions|stack_convention_root|directory_stacks|required_a `git diff` に現れた追加・変更コメントをすべて AGENTS.md の Content Rules と照合し、複数行・自明な説明・タスクへの言及を削除する。Files 外の変更がないことを確認し、`git diff --check` が exit 0 であることを完了条件にする。 -- [ ] **Step 9: Review the implementation** +- [x] **Step 9: Review the implementation** `superpowers:requesting-code-review` を適用し、計画と設計書の要件、除外判定、全利用側の旧モデル削除をレビューする。指摘を解消し、変更した箇所に必要な検証を再実行した時点で完了とする。 From c3aa64129bb7ad46c147f3e0a3b7dff86f272973 Mon Sep 17 00:00:00 2001 From: panicboat Date: Fri, 9 Oct 2026 10:07:11 +0900 Subject: [PATCH 10/13] fix: preserve stack directory enumeration semantics Signed-off-by: panicboat --- .../infrastructure/file_system_client.rb | 32 +++++++----- .../use_cases/generate_matrix_spec.rb | 26 ++++++++++ .../infrastructure/file_system_client_spec.rb | 50 +++++++++++++++++-- docs/superpowers/plans/stack-configuration.md | 2 +- 4 files changed, 94 insertions(+), 16 deletions(-) diff --git a/action-scripts/shared/infrastructure/file_system_client.rb b/action-scripts/shared/infrastructure/file_system_client.rb index 7ca9afb..3ee83a3 100644 --- a/action-scripts/shared/infrastructure/file_system_client.rb +++ b/action-scripts/shared/infrastructure/file_system_client.rb @@ -37,7 +37,7 @@ def repository_root(start_path: __dir__) return root if File.directory?(root) && (File.directory?(git_path) || File.file?(git_path)) end - # FALLBACK: Local commands use the enclosing checkout when SOURCE_REPO_PATH does not identify a repository. + # FALLBACK: Local runs may retain SOURCE_REPO_PATH after the source checkout becomes unavailable. current = File.expand_path(start_path) loop do git_path = File.join(current, '.git') @@ -51,16 +51,22 @@ def repository_root(start_path: __dir__) def resolve_directories(pattern:, values: {}) root = repository_root - placeholders = Entities::PatternMatcher.placeholders(pattern) - glob = pattern.gsub(/#{Entities::PatternMatcher::PLACEHOLDER_REGEX.source}|[\\*?\[\]{}]/) do |token| - token.match?(/\A#{Entities::PatternMatcher::PLACEHOLDER_REGEX.source}\z/) ? '*' : "\\#{token}" - end - Dir.glob(glob, base: root).sort.filter_map do |path| - next unless File.directory?(File.join(root, path)) - captures = Entities::PatternMatcher.extract(pattern, path) - next unless captures && placeholders.all? { |key| !values.key?(key) || captures[key] == values[key] } - { working_directory: path, captures: captures } + candidates = [{ working_directory: '', captures: {} }] + parts = pattern.split('/') + parts.each_index do |index| + prefix = parts[0..index].join('/') + candidates = candidates.flat_map do |parent| + Dir.children(File.join(root, parent.fetch(:working_directory))).sort.filter_map do |name| + path = [parent.fetch(:working_directory), name].reject(&:empty?).join('/') + captures = Entities::PatternMatcher.extract(prefix, path) + next unless captures && captures.all? { |key, value| !values.key?(key) || value == values[key] } + next if captures['service']&.start_with?('.') + next unless directory_exists?(File.join(root, path)) + { working_directory: path, captures: captures } + end + end end + candidates.sort_by { |match| match.fetch(:working_directory) } end private @@ -106,9 +112,11 @@ def get_current_changes(git_dir = '.') (staged_files + unstaged_files).uniq end - # Check if directory exists def directory_exists?(path) - File.directory?(path) + File.stat(path).directory? + rescue Errno::ENOENT, Errno::ENOTDIR + # SILENT: Absent paths are valid even when entries disappear or symlinks lose their targets. + false end # Check if file exists diff --git a/action-scripts/spec/label-resolver/use_cases/generate_matrix_spec.rb b/action-scripts/spec/label-resolver/use_cases/generate_matrix_spec.rb index c74178c..005b3be 100644 --- a/action-scripts/spec/label-resolver/use_cases/generate_matrix_spec.rb +++ b/action-scripts/spec/label-resolver/use_cases/generate_matrix_spec.rb @@ -172,4 +172,30 @@ def items(**options) expect(items(labels: [])).to eq([]) expect(items(labels: ['other', 'deploy:demo:invalid'])).to eq([]) end + it 'returns a failure without partial targets for actual directory permission errors' do + config_hash['stacks'] = [{ 'name' => 'container', 'paths' => ['readable/{service}', 'blocked/{service}'] }] + directory('readable/demo'); directory('blocked/demo') + path = File.join(@root, 'blocked') + File.chmod(0, path) + expect { Dir.children(path) }.to raise_error(Errno::EACCES) + result = generate + expect(result).to be_failure + expect(result.error_message).to include('Permission denied') + expect(result.data).not_to have_key(:deployment_targets) + ensure + File.chmod(0o755, path) if path + end + + it 'agrees with change detection for dot-prefixed environments and custom captures' do + config_hash['stacks'] = [{ 'name' => 'terragrunt', 'paths' => ['teams/{team}/{service}/{environment}'], 'environments' => { '.preview' => { 'token' => nil } } }] + directory('teams/.platform/demo/.preview') + allow(file_client).to receive(:get_changed_files).and_return(['teams/.platform/demo/.preview/main.tf']) + detector = UseCases::LabelManagement::DetectChangedServices.new(config_client: config_client, file_client: file_client) + detection = detector.execute + expect(detection.services_detected).to eq(['demo']) + expect(items(labels: detection.deploy_labels.map(&:to_s))).to eq([ + { service: 'demo', stack: 'terragrunt', stack_id: 'terragrunt', environment: '.preview', working_directory: 'teams/.platform/demo/.preview', team: '.platform', token: nil } + ]) + end + end diff --git a/action-scripts/spec/shared/infrastructure/file_system_client_spec.rb b/action-scripts/spec/shared/infrastructure/file_system_client_spec.rb index c8d026f..483ede0 100644 --- a/action-scripts/spec/shared/infrastructure/file_system_client_spec.rb +++ b/action-scripts/spec/shared/infrastructure/file_system_client_spec.rb @@ -57,9 +57,53 @@ def directory(path) expect(client.resolve_directories(pattern: 'dystopia/{service}/aws')).to eq([]) end - it 'propagates directory enumeration errors' do - allow(Dir).to receive(:glob).and_raise(Errno::EACCES) - expect { client.resolve_directories(pattern: 'dystopia/{service}') }.to raise_error(Errno::EACCES) + it 'propagates actual permission errors for literal and placeholder directories' do + directory('blocked/demo') + path = File.join(@root, 'blocked') + File.chmod(0, path) + expect { Dir.children(path) }.to raise_error(Errno::EACCES) + expect { client.resolve_directories(pattern: 'blocked/{service}') }.to raise_error(Errno::EACCES) + expect { client.resolve_directories(pattern: '{team}/{service}') }.to raise_error(Errno::EACCES) + ensure + File.chmod(0o755, path) if path + end + + it 'resolves dot-prefixed environments and arbitrary placeholders without dot entries' do + directory('teams/.platform/demo/.preview') + expect(client.resolve_directories(pattern: 'teams/{team}/{service}/{environment}', values: { 'environment' => '.preview' })).to eq([ + { working_directory: 'teams/.platform/demo/.preview', captures: { 'team' => '.platform', 'service' => 'demo', 'environment' => '.preview' } } + ]) + end + + it 'does not traverse hidden services' do + directory('teams/platform/.hidden/aws') + path = File.join(@root, 'teams/platform/.hidden') + File.chmod(0, path) + expect(client.resolve_directories(pattern: 'teams/{team}/{service}/aws')).to eq([]) + ensure + File.chmod(0o755, path) if path + end + + it 'does not traverse repeated-placeholder mismatches or unselected services' do + directory('teams/platform/sandbox/demo') + path = File.join(@root, 'teams/platform/sandbox') + File.chmod(0, path) + expect(client.resolve_directories(pattern: 'teams/{team}/{team}/{service}')).to eq([]) + expect(client.resolve_directories(pattern: 'teams/{team}/{service}/{environment}', values: { 'service' => 'other' })).to eq([]) + ensure + File.chmod(0o755, path) if path + end + + it 'ignores dangling symlinks while preserving directory stat permission errors' do + File.symlink(File.join(@root, 'absent'), File.join(@root, 'dangling')) + expect(client.resolve_directories(pattern: '{service}').map { |match| match[:working_directory] }).not_to include('dangling') + directory('protected/demo') + path = File.join(@root, 'protected') + File.symlink(File.join(path, 'demo'), File.join(@root, 'linked')) + File.chmod(0, path) + expect { client.resolve_directories(pattern: 'linked/{service}') }.to raise_error(Errno::EACCES) + ensure + File.chmod(0o755, path) if path end it 'uses the source repository root' do diff --git a/docs/superpowers/plans/stack-configuration.md b/docs/superpowers/plans/stack-configuration.md index e980901..53eee47 100644 --- a/docs/superpowers/plans/stack-configuration.md +++ b/docs/superpowers/plans/stack-configuration.md @@ -464,7 +464,7 @@ Run: `rg -n 'stack_conventions|stack_convention_root|directory_stacks|required_a `superpowers:requesting-code-review` を適用し、計画と設計書の要件、除外判定、全利用側の旧モデル削除をレビューする。指摘を解消し、変更した箇所に必要な検証を再実行した時点で完了とする。 -- [ ] **Step 10: Commit the configuration commands and examples** +- [x] **Step 10: Commit the configuration commands and examples** Files の変更を指定して stage し、`git diff --cached --check` を実行する。 From 5bcf8248e16470b2136c233333a04e70713f1d22 Mon Sep 17 00:00:00 2001 From: panicboat Date: Fri, 9 Oct 2026 10:09:34 +0900 Subject: [PATCH 11/13] docs: record stack configuration verification Signed-off-by: panicboat --- docs/superpowers/plans/stack-configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/stack-configuration.md b/docs/superpowers/plans/stack-configuration.md index 53eee47..b14c1a2 100644 --- a/docs/superpowers/plans/stack-configuration.md +++ b/docs/superpowers/plans/stack-configuration.md @@ -470,7 +470,7 @@ Files の変更を指定して stage し、`git diff --cached --check` を実行 Run: `git commit -s -m 'feat: expose stack configuration through config commands'` -- [ ] **Step 11: Publish the branch as a Draft PR** +- [x] **Step 11: Publish the branch as a Draft PR** `superpowers:verification-before-completion` を適用して、コミット内容と検証結果を確認する。新規ブランチなら `git push -u origin HEAD`、追跡済みなら `git push` を実行する。既存 Draft PR を継続する場合は設計と実装をレビューできる説明へ更新し、新規 PR なら `gh pr create --draft` を使う。PR が Draft、タイトルが英語、変更ファイルと検証結果が確認できる時点で完了とする。 From bbcca1c51922a68708f30ee0d73e12d0ba7f38ed Mon Sep 17 00:00:00 2001 From: panicboat Date: Fri, 9 Oct 2026 10:35:10 +0900 Subject: [PATCH 12/13] docs: provide complete English and Japanese READMEs Readers need the configuration and component behavior in their chosen language. Keep explanations, headings, and references within each locale while sharing executable examples. Signed-off-by: panicboat --- README-ja.md | 126 ++++++++++++++--- README.md | 87 ++++++------ action-scripts/config-manager/README-ja.md | 62 ++++++--- action-scripts/config-manager/README.md | 39 +++--- action-scripts/label-dispatcher/README-ja.md | 106 +++++++++------ action-scripts/label-dispatcher/README.md | 66 ++++++--- action-scripts/label-resolver/README-ja.md | 134 ++++++++++++------- action-scripts/label-resolver/README.md | 91 +++++++++---- 8 files changed, 485 insertions(+), 226 deletions(-) diff --git a/README-ja.md b/README-ja.md index ae42762..4368d31 100644 --- a/README-ja.md +++ b/README-ja.md @@ -4,15 +4,15 @@ 複数サービスを抱えるリポジトリで、PR ラベルベースのデプロイメント・オーケストレーションを駆動する GitHub Actions ツールキット。 -## Overview +## 概要 ファイル変更からデプロイ対象ラベルを生成し、ラベルからデプロイメント・ターゲットを解決する層を提供します。実際の `plan`/`apply` 実行は、利用側で任意の Composite Action(Terragrunt / Helm / kustomize など)に委ねる構成です。 -## Components +## 構成要素 ### 1. Config Manager (`action-scripts/config-manager/`) -`workflow-config.yaml` の stack 定義を検証し、実在するサービスの設定を診断します。 +`workflow-config.yaml` の stack 定義を検証し、設定表示・環境一覧・実在ディレクトリのサービス診断・テンプレート生成を提供します。 **特徴:** @@ -57,22 +57,82 @@ PR の変更ファイルを検出し、変更があったサービスに対し ### Label Resolver +`environments` は任意の入力で、複数環境はカンマ区切りで指定します。省略すると全定義環境を対象にします。 + ```yaml - uses: panicboat/deploy-actions/label-resolver@v1 with: pr-number: ${{ github.event.pull_request.number }} repository: ${{ github.repository }} github-token: ${{ secrets.GITHUB_TOKEN }} - environments: develop # 任意。カンマ区切り (例: develop,staging) + environments: develop +``` + +## 設定 + +`workflow-config.yaml` のトップレベルには、空でない `stacks` 配列を定義します。同じ stack のパス、環境属性、除外条件を一つの定義で管理します。 + +```yaml +stacks: + - name: terragrunt + id: aws + paths: + - "dystopia/{service}/aws/{environment}" + - "system-components/{service}/infrastructure/aws/{environment}" + - "teams/{team}/{service}/aws/{environment}" + environments: + develop: + aws_region: ap-northeast-1 + production: + aws_region: us-west-2 + exclude: + - service: demo + environment: production + - team: sandbox + - name: container + paths: + - "dystopia/{service}" + - "system-components/{service}" + attributes: + repository: registry.example.com/app ``` -## Configuration +### stack の定義 + +| フィールド | 指定 | 動作 | +|---|---|---| +| `name` | 必須 | stack の種類。属性名やプロバイダーを制限しない | +| `id` | 任意 | インスタンス識別子。省略時は `name`。設定全体で一意 | +| `paths` | 必須 | 空でない相対パス配列。全パスに `{service}` が必要 | +| `environments` | 任意 | 環境名をキー、環境属性を値とする空でないマップ | +| `attributes` | 任意 | 環境共通の属性。`environments` と併用できない | +| `exclude` | 任意 | 除外条件の配列。省略時は空配列 | + +共有するプロダクトは一つの定義の `paths` に追加します。独立させる場合は、同じ `name` に異なる `id` を付けた別定義にし、それぞれにパスと属性を置きます。 + +`environments` のキーがその stack の環境名です。環境一覧は全 stack の和集合になり、環境指定を省略すると全定義環境が対象になります。各 stack は自身の定義環境だけを生成し、未知の環境指定はエラーになります。属性の値と型はそのまま出力され、環境間の属性キーを揃える必要はありません。 + +パスに `{environment}` がなくても、`environments` があれば環境ごとの対象になります。同じディレクトリを異なる環境属性で使えます。`environments` がなければ環境共通で、`environment: null` の対象を各ディレクトリにつき一度だけ生成します。この場合の属性は `attributes` から取得し、パスに `{environment}` は指定できません。 -設定仕様、共有・独立する stack、placeholder、除外条件は [Configuration](README.md#configuration) を参照してください。 +### パスの照合 -## Workflow Integration +パスはリポジトリルートからの完全な相対パターンです。絶対パスと `..` 要素は拒否し、先頭の `./`、余分な `/`、`.` 要素は正規化します。すべてのパターンに一致する実在ディレクトリを列挙し、存在しないパスは対象を生成しません。サービスの登録は不要です。ドットで始まるサービスは対象外です。 -### 1. Change Detection +`{team}` などの任意 placeholder を使えます。名前は `[a-z_][a-z0-9_]*`、値はパスの一要素です。同じ名前を繰り返した場合は、すべて同じ値に一致する必要があります。glob の記号はリテラルとして扱います。任意 placeholder と matrix の固定キー、または同じ stack のいずれかの環境属性・共通属性キーが衝突する定義は拒否します。 + +### 除外条件 + +`exclude` の各要素は空でない条件マップです。`service`、`environment`、その stack のいずれかのパスにある任意 placeholder を条件にできます。属性名は条件キーとして使えません。 + +一つのマップ内は AND、配列内は OR で完全一致を判定します。省略したキーは制約になりません。service のみ、environment のみ、任意 placeholder のみでも指定できます。照合したパスにない抽出値を要求する条件は一致しません。 + +条件値は空でない文字列で、`/`、`.`、`..` は使えません。service はドットで始められません。環境条件は自身の定義環境だけを指定できます。環境共通 stack では environment の省略または `null` を受け付け、environment 以外の条件値に `null` は使えません。 + +実行可能な設定例は [workflow-config.yaml](action-scripts/workflow-config.yaml) を参照してください。 + +## ワークフローへの組み込み + +### 1. 変更検出 ```yaml name: Detect Changes and Create Labels @@ -91,7 +151,7 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} ``` -### 2. Target Resolution +### 2. デプロイ対象の解決 ```yaml name: Deploy @@ -110,25 +170,51 @@ jobs: pr-number: ${{ github.event.pull_request.number }} repository: ${{ github.repository }} github-token: ${{ secrets.GITHUB_TOKEN }} - - # 以降は ${{ steps.resolve.outputs.targets }} を任意の deploy step に流す ``` +`${{ steps.resolve.outputs.targets }}` を利用側のデプロイ処理に渡します。 + 実行レイヤ(`aws`, `kubernetes` など)は意図的に本リポジトリから除外しています。メンテナーの個人用 wrapper は [`panicboat/panicboat-actions`](https://github.com/panicboat/panicboat-actions) にあります。 -## Matrix Output +## matrix 出力 + +`label-resolver` は `outputs.targets` と環境変数 `DEPLOYMENT_TARGETS` に JSON 配列を出力します。固定キーは次の5個で、属性と任意 placeholder の抽出値を同じ階層に展開します。 + +| キー | 出力元 | +|---|---| +| `service` | ラベルまたは `deploy:all` で探索したサービス名 | +| `environment` | 定義した環境名。環境共通なら `null` | +| `stack` | stack の `name` | +| `stack_id` | 確定した `id` | +| `working_directory` | 実在する対象ディレクトリの相対パス | +| 属性のキー | 当該環境属性、または共通 `attributes` | +| 任意 placeholder のキー | 一致したパスの抽出値 | + +上記の設定例で `teams/payments/api/aws/develop` が存在する場合、次の行を生成します。 + +```json +{ + "service": "api", + "environment": "develop", + "stack": "terragrunt", + "stack_id": "aws", + "working_directory": "teams/payments/api/aws/develop", + "aws_region": "ap-northeast-1", + "team": "payments" +} +``` -固定キー、属性・抽出値の展開、対象の同一性は [Matrix Output](README.md#matrix-output) を参照してください。 +対象の同一性は service・stack_id・environment・working_directory で決まります。同じ対象の重複は一行にまとめます。同じ対象を異なる抽出値マップで解釈するパスは、除外条件や記載順にかかわらずエラーになります。下流では `${{ matrix.team }}` のように任意のキーを参照できます。 -## Development +## 開発 -### Prerequisites +### 前提条件 -- Ruby 4.0.3 +- Ruby ([.ruby-version](action-scripts/.ruby-version)) - Bundler - Git -### Setup +### セットアップ ```bash git clone https://github.com/panicboat/deploy-actions.git @@ -137,14 +223,14 @@ bundle install bundle exec rspec ``` -### Component Testing +### 各コンポーネントの動作確認 ```bash bundle exec ruby config-manager/bin/config-manager validate -bundle exec ruby label-dispatcher/bin/dispatcher detect +bundle exec ruby label-dispatcher/bin/dispatcher test bundle exec ruby label-resolver/bin/resolver resolve PR_NUMBER ``` -## License +## ライセンス MIT — `LICENSE` を参照。 diff --git a/README.md b/README.md index 084be9c..fb3c209 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Deploy Actions -**English** | [🇯🇵 日本語](README-ja.md) +**English** | [🇯🇵 Japanese](README-ja.md) A GitHub Actions toolkit that drives PR-label-based deployment orchestration for multi-service repositories. @@ -12,7 +12,14 @@ Deploy Actions converts file changes into deployment labels and converts those l ### 1. Config Manager (`action-scripts/config-manager/`) -`workflow-config.yaml` の stack 定義を検証し、設定表示・環境一覧・実在ディレクトリのサービス診断・テンプレート生成を提供します。 +Validates stack definitions in `workflow-config.yaml` and provides configuration display, environment listing, service diagnosis using existing directories, and template generation. + +**Highlights:** + +- Configuration validation with detailed error reports +- Environment listing and service diagnosis +- Stack path and exclusion validation +- Template generation ### 2. Label Dispatcher (`label-dispatcher/`) @@ -27,12 +34,12 @@ Detects file changes from a PR and creates `deploy:` labels for affecte ### 3. Label Resolver (`label-resolver/`) -`deploy:` ラベルと指定環境から、後続 Action が使う matrix を生成します。 +Generates a matrix for downstream Actions from `deploy:` labels and the selected environments. **Highlights:** - Label-to-target resolution -- 定義された環境からの対象選択 +- Target selection from configured environments - Deployment-matrix generation - Safety validation @@ -50,18 +57,20 @@ Detects file changes from a PR and creates `deploy:` labels for affecte ### Label Resolver +`environments` is optional. Specify multiple environments as a comma-separated list; omit it to target all configured environments. + ```yaml - uses: panicboat/deploy-actions/label-resolver@v1 with: pr-number: ${{ github.event.pull_request.number }} repository: ${{ github.repository }} github-token: ${{ secrets.GITHUB_TOKEN }} - environments: develop # optional, comma-separated (e.g. develop,staging) + environments: develop ``` ## Configuration -`workflow-config.yaml` のトップレベルには、空でない `stacks` 配列を定義します。同じ stack のパス、環境属性、除外条件を一つの定義で管理します。 +Define a nonempty `stacks` array at the top level of `workflow-config.yaml`. Each stack definition contains its paths, environment attributes, and exclusion conditions. ```yaml stacks: @@ -92,38 +101,38 @@ stacks: | Field | Requirement | Behavior | |---|---|---| -| `name` | 必須 | stack の種類。属性名やプロバイダーを制限しない | -| `id` | 任意 | インスタンス識別子。省略時は `name`。設定全体で一意 | -| `paths` | 必須 | 空でない相対パス配列。全パスに `{service}` が必要 | -| `environments` | 任意 | 環境名をキー、環境属性を値とする空でないマップ | -| `attributes` | 任意 | 環境共通の属性。`environments` と併用できない | -| `exclude` | 任意 | 除外条件の配列。省略時は空配列 | +| `name` | Required | Stack type; does not restrict attribute names or providers | +| `id` | Optional | Instance identifier; defaults to `name` and must be unique across the configuration | +| `paths` | Required | Nonempty array of relative paths; every path must contain `{service}` | +| `environments` | Optional | Nonempty map from environment names to environment attributes | +| `attributes` | Optional | Attributes shared across environments; cannot be combined with `environments` | +| `exclude` | Optional | Array of exclusion conditions; defaults to an empty array | -共有するプロダクトは一つの定義の `paths` に追加します。独立させる場合は、同じ `name` に異なる `id` を付けた別定義にし、それぞれにパスと属性を置きます。 +Products that share a stack use multiple entries in one definition's `paths`. For independent stacks, use separate definitions with the same `name` and different `id` values, each with its own paths and attributes. -`environments` のキーがその stack の環境名です。環境一覧は全 stack の和集合になり、環境指定を省略すると全定義環境が対象になります。各 stack は自身の定義環境だけを生成し、未知の環境指定はエラーになります。属性の値と型はそのまま出力され、環境間の属性キーを揃える必要はありません。 +The keys in `environments` define the stack's environment names. The environment list is the union of all stacks' environment names. Omitting the environment selection targets all configured environments. Each stack generates targets only for its own environments, and selecting an unknown environment is an error. Attribute values and types are preserved in the output; environments do not need identical attribute keys. -パスに `{environment}` がなくても、`environments` があれば環境ごとの対象になります。同じディレクトリを異なる環境属性で使えます。`environments` がなければ環境共通で、`environment: null` の対象を各ディレクトリにつき一度だけ生成します。この場合の属性は `attributes` から取得し、パスに `{environment}` は指定できません。 +A stack with `environments` generates targets for each environment even when its paths do not contain `{environment}`. The same directory can use different environment attributes. A stack without `environments` is shared across environments and generates one target with `environment: null` per directory. It uses `attributes` and cannot contain `{environment}` in its paths. ### Path Matching -パスはリポジトリルートからの完全な相対パターンです。絶対パスと `..` 要素は拒否し、先頭の `./`、余分な `/`、`.` 要素は正規化します。すべてのパターンに一致する実在ディレクトリを列挙し、存在しないパスは対象を生成しません。サービスの登録は不要です。ドットで始まるサービスは対象外です。 +Paths are complete relative patterns from the repository root. Absolute paths and `..` segments are rejected. Leading `./`, extra `/` separators, and `.` segments are normalized. All existing directories matching any pattern are enumerated; missing paths generate no targets. Services do not require registration. Service names starting with a dot are excluded. -`{team}` などの任意 placeholder を使えます。名前は `[a-z_][a-z0-9_]*`、値はパスの一要素です。同じ名前を繰り返した場合は、すべて同じ値に一致する必要があります。glob の記号はリテラルとして扱います。任意 placeholder と matrix の固定キー、または同じ stack のいずれかの環境属性・共通属性キーが衝突する定義は拒否します。 +Arbitrary placeholders such as `{team}` are supported. Names must match `[a-z_][a-z0-9_]*`, and each value occupies one path segment. Repeated occurrences of a placeholder must match the same value. Glob characters are treated literally. Definitions are rejected if an arbitrary placeholder conflicts with a fixed matrix key or with any environment or shared attribute key in the same stack. ### Exclusion Conditions -`exclude` の各要素は空でない条件マップです。`service`、`environment`、その stack のいずれかのパスにある任意 placeholder を条件にできます。属性名は条件キーとして使えません。 +Each entry in `exclude` is a nonempty condition map. Conditions can use `service`, `environment`, or any arbitrary placeholder found in the stack's paths. Attribute names cannot be used as condition keys. -一つのマップ内は AND、配列内は OR で完全一致を判定します。省略したキーは制約になりません。service のみ、environment のみ、任意 placeholder のみでも指定できます。照合したパスにない抽出値を要求する条件は一致しません。 +Conditions use exact matching: keys within a map are combined with AND, and entries in the array are combined with OR. Omitted keys impose no restriction. A condition can specify only a service, only an environment, or only an arbitrary placeholder. A condition requiring a captured value absent from the matched path does not match. -条件値は空でない文字列で、`/`、`.`、`..` は使えません。service はドットで始められません。環境条件は自身の定義環境だけを指定できます。環境共通 stack では environment の省略または `null` を受け付け、environment 以外の条件値に `null` は使えません。 +Condition values must be nonempty strings representing one path segment; `/`, `.`, and `..` are not allowed. Service names cannot start with a dot. Environment conditions can name only the stack's own environments. For stacks shared across environments, `environment` can be omitted or set to `null`; other condition values cannot be `null`. -実行可能な設定例は [workflow-config.yaml](action-scripts/workflow-config.yaml) を参照してください。 +See [workflow-config.yaml](action-scripts/workflow-config.yaml) for a working configuration example. -## Workflow integration +## Workflow Integration -### 1. Change-detection workflow +### 1. Change Detection ```yaml name: Detect Changes and Create Labels @@ -142,7 +151,7 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} ``` -### 2. Deployment-target resolution +### 2. Target Resolution ```yaml name: Deploy @@ -161,27 +170,27 @@ jobs: pr-number: ${{ github.event.pull_request.number }} repository: ${{ github.repository }} github-token: ${{ secrets.GITHUB_TOKEN }} - - # Then run your own deploy step using ${{ steps.resolve.outputs.targets }} ``` +Pass `${{ steps.resolve.outputs.targets }}` to your deployment step. + The execution layer (`aws`, `kubernetes`, etc.) is intentionally not part of this repository — the maintainer's personal wrappers live at [`panicboat/panicboat-actions`](https://github.com/panicboat/panicboat-actions). ## Matrix Output -`label-resolver` は `outputs.targets` と環境変数 `DEPLOYMENT_TARGETS` に JSON 配列を出力します。固定キーは次の5個で、属性と任意 placeholder の抽出値を同じ階層に展開します。 +`label-resolver` writes a JSON array to `outputs.targets` and the `DEPLOYMENT_TARGETS` environment variable. Each target has the following five fixed keys, with attributes and captured arbitrary placeholders flattened into the same object. | Key | Source | |---|---| -| `service` | ラベルまたは `deploy:all` で探索したサービス名 | -| `environment` | 定義した環境名。環境共通なら `null` | -| `stack` | stack の `name` | -| `stack_id` | 確定した `id` | -| `working_directory` | 実在する対象ディレクトリの相対パス | -| 属性のキー | 当該環境属性、または共通 `attributes` | -| 任意 placeholder のキー | 一致したパスの抽出値 | +| `service` | Service name from a label or discovered through `deploy:all` | +| `environment` | Configured environment name, or `null` for a target shared across environments | +| `stack` | Stack `name` | +| `stack_id` | Resolved `id` | +| `working_directory` | Relative path of the existing target directory | +| Attribute keys | Selected environment attributes or shared `attributes` | +| Arbitrary placeholder keys | Values captured from the matched path | -Configuration の例で `teams/payments/api/aws/develop` が存在する場合、次の行を生成します。 +With the configuration above, an existing `teams/payments/api/aws/develop` directory generates the following row. ```json { @@ -195,13 +204,13 @@ Configuration の例で `teams/payments/api/aws/develop` が存在する場合 } ``` -対象の同一性は service・stack_id・environment・working_directory で決まります。同じ対象の重複は一行にまとめます。同じ対象を異なる抽出値マップで解釈するパスは、除外条件や記載順にかかわらずエラーになります。下流では `${{ matrix.team }}` のように任意のキーを参照できます。 +Target identity consists of `service`, `stack_id`, `environment`, and `working_directory`. Duplicate targets are merged into one row. Paths that interpret the same target with different captured value maps cause an error, regardless of exclusions or definition order. Downstream steps can reference arbitrary keys such as `${{ matrix.team }}`. ## Development ### Prerequisites -- Ruby 4.0.3 +- Ruby ([.ruby-version](action-scripts/.ruby-version)) - Bundler - Git @@ -214,11 +223,11 @@ bundle install bundle exec rspec ``` -### Testing individual components +### Component Testing ```bash bundle exec ruby config-manager/bin/config-manager validate -bundle exec ruby label-dispatcher/bin/dispatcher detect +bundle exec ruby label-dispatcher/bin/dispatcher test bundle exec ruby label-resolver/bin/resolver resolve PR_NUMBER ``` diff --git a/action-scripts/config-manager/README-ja.md b/action-scripts/config-manager/README-ja.md index 9c8e719..071cc2d 100644 --- a/action-scripts/config-manager/README-ja.md +++ b/action-scripts/config-manager/README-ja.md @@ -4,56 +4,84 @@ GitHub Actions デプロイメント自動化のための Ruby ベース設定検証・管理ツールです。 -## Overview +## 概要 -設定表示、検証、環境一覧、サービス診断を担当します。[Commands](README.md#commands) に実行方法と結果、[Service Diagnosis](README.md#service-diagnosis) に診断の説明をまとめています。 +stack 定義の読み込みと検証、設定表示、サービスの実在ディレクトリ診断を提供します。設定仕様は [設定](../../README-ja.md#設定)、出力形式は [matrix 出力](../../README-ja.md#matrix-出力) を参照してください。 -設定仕様は [Configuration](../../README.md#configuration)、出力形式は [Matrix Output](../../README.md#matrix-output) を参照してください。 +## 使い方 -## Architecture +Config Manager は `config-manager/bin/config-manager` を通じて CLI インターフェースを提供します。 -### Components +### コマンド + +`action-scripts` を作業ディレクトリとして実行します。 + +| コマンド | 結果 | +|---|---| +| `bundle exec ruby config-manager/bin/config-manager validate` | 設定の検証結果と stack・環境数 | +| `bundle exec ruby config-manager/bin/config-manager show` | 各 stack ID の全パス、環境属性または共通属性、除外条件 | +| `bundle exec ruby config-manager/bin/config-manager environments` | 定義した環境名の和集合 | +| `bundle exec ruby config-manager/bin/config-manager test SERVICE_NAME [ENVIRONMENT]` | 実在する全一致対象と除外状態 | +| `bundle exec ruby config-manager/bin/config-manager diagnostics` | 設定、環境変数、Git 状態、設定ファイルの診断 | +| `bundle exec ruby config-manager/bin/config-manager template` | 新しい設定を作るための YAML の表示 | +| `bundle exec ruby config-manager/bin/config-manager check_file` | 既定ファイルの存在、読み取り、YAML 構文の確認 | + +### サービス診断 + +環境を省略すると、そのサービスの全定義環境と共通対象を調べます。環境を指定すると、指定環境と共通対象を調べます。サービスの登録は不要です。パスに一致しないサービスは空の結果になります。 + +一致する全ディレクトリを stack ID ごとに表示し、属性と任意 placeholder の抽出値を保持します。除外された対象も `excluded: true` として表示するため、実行対象から外れる条件を確認できます。 + +```bash +bundle exec ruby config-manager/bin/config-manager test demo +bundle exec ruby config-manager/bin/config-manager test demo production +``` + +未知の環境、設定の不正、ディレクトリ列挙の失敗、同じ対象の抽出値の矛盾はエラーとして表示します。 + +## アーキテクチャ + +### 構成要素 - **ConfigManagerController**: メインオーケストレーションと CLI インターフェース - **ValidateConfig**: 包括的設定検証 - **ConfigClient**: 設定読み込みとパース - **ConsolePresenter**: 人間が読める出力フォーマット -### Validation Flow +### 検証の流れ -検証の流れは [Validation Flow](README.md#validation-flow) を参照してください。 +YAML を読み込み、設定モデルで構造と整合性を検証し、検証結果と stack・環境数を表示します。検証規則の詳細は [設定](../../README-ja.md#設定) を参照してください。 -## Error Handling +## エラー処理 以下を含む詳細なエラーレポート: + - **具体的なエラーメッセージ**: 設定問題の特定 - **検証コンテキスト**: 問題のあるセクションの明確な指示 - **提案**: 一般的な設定問題の修正ガイダンス - **サマリー統計**: 設定ヘルスの概要 -## Integration +## 連携 Config Manager は以下と統合されます: + - **Label Resolver**: デプロイメント指定のための設定提供 - **Label Dispatcher**: サービスとディレクトリ設定の検証 - **GitHub Actions**: CI/CD ワークフローの環境検証 -## Development +## 開発 -### Running Tests +### テストの実行 ```bash cd action-scripts bundle exec rspec spec/config-manager/ ``` -### Local Testing +### ローカルでの動作確認 ```bash -# カスタム設定でのテスト cp workflow-config.yaml test-config.yaml -./bin/config-manager validate - -# サービス設定のテスト -./bin/config-manager test myservice develop +WORKFLOW_CONFIG_PATH=test-config.yaml bundle exec ruby config-manager/bin/config-manager validate +bundle exec ruby config-manager/bin/config-manager test myservice develop ``` diff --git a/action-scripts/config-manager/README.md b/action-scripts/config-manager/README.md index 16ae56e..36db4b8 100644 --- a/action-scripts/config-manager/README.md +++ b/action-scripts/config-manager/README.md @@ -1,43 +1,43 @@ # Config Manager -**English** | [🇯🇵 日本語](README-ja.md) +**English** | [🇯🇵 Japanese](README-ja.md) A Ruby-based configuration validation and management tool for GitHub Actions deployment automation. ## Overview -stack 定義の読み込みと検証、設定表示、サービスの実在ディレクトリ診断を提供します。設定仕様は [Configuration](../../README.md#configuration)、出力形式は [Matrix Output](../../README.md#matrix-output) を参照してください。 +Loads and validates stack definitions, displays configuration, and diagnoses services using existing directories. See [Configuration](../../README.md#configuration) for the schema and [Matrix Output](../../README.md#matrix-output) for the output format. ## Usage -The Config Manager provides a CLI interface through `bin/config-manager`: +The Config Manager provides a CLI interface through `config-manager/bin/config-manager`: ### Commands -`action-scripts` を作業ディレクトリとして実行します。 +Run commands from the `action-scripts` directory. | Command | Result | |---|---| -| `bundle exec ruby config-manager/bin/config-manager validate` | 設定の検証結果と stack・環境数 | -| `bundle exec ruby config-manager/bin/config-manager show` | 各 stack ID の全パス、環境属性または共通属性、除外条件 | -| `bundle exec ruby config-manager/bin/config-manager environments` | 定義した環境名の和集合 | -| `bundle exec ruby config-manager/bin/config-manager test SERVICE_NAME [ENVIRONMENT]` | 実在する全一致対象と除外状態 | -| `bundle exec ruby config-manager/bin/config-manager diagnostics` | 設定、環境変数、Git 状態、設定ファイルの診断 | -| `bundle exec ruby config-manager/bin/config-manager template` | 新しい設定を作るための YAML の表示 | -| `bundle exec ruby config-manager/bin/config-manager check_file` | 既定ファイルの存在、読み取り、YAML 構文の確認 | +| `bundle exec ruby config-manager/bin/config-manager validate` | Validation result and stack/environment counts | +| `bundle exec ruby config-manager/bin/config-manager show` | All paths, environment or shared attributes, and exclusions for each stack ID | +| `bundle exec ruby config-manager/bin/config-manager environments` | Union of configured environment names | +| `bundle exec ruby config-manager/bin/config-manager test SERVICE_NAME [ENVIRONMENT]` | All existing matching targets and their exclusion status | +| `bundle exec ruby config-manager/bin/config-manager diagnostics` | Diagnostics for configuration, environment variables, Git state, and the configuration file | +| `bundle exec ruby config-manager/bin/config-manager template` | Display of YAML for creating a configuration | +| `bundle exec ruby config-manager/bin/config-manager check_file` | Existence, readability, and YAML syntax checks for the default file | ### Service Diagnosis -環境を省略すると、そのサービスの全定義環境と共通対象を調べます。環境を指定すると、指定環境と共通対象を調べます。サービスの登録は不要です。パスに一致しないサービスは空の結果になります。 +Omitting the environment inspects all configured environments and shared targets for the service. Selecting an environment inspects that environment and shared targets. Services do not require registration. A service that matches no paths produces an empty result. -一致する全ディレクトリを stack ID ごとに表示し、属性と任意 placeholder の抽出値を保持します。除外された対象も `excluded: true` として表示するため、実行対象から外れる条件を確認できます。 +All matching directories are displayed by stack ID, preserving attributes and captured arbitrary placeholders. Excluded targets are also displayed with `excluded: true`, so you can inspect why they are omitted from deployment. ```bash bundle exec ruby config-manager/bin/config-manager test demo bundle exec ruby config-manager/bin/config-manager test demo production ``` -未知の環境、設定の不正、ディレクトリ列挙の失敗、同じ対象の抽出値の矛盾はエラーとして表示します。 +Unknown environments, invalid configuration, directory enumeration failures, and conflicting captured values for the same target are reported as errors. ## Architecture @@ -50,11 +50,12 @@ bundle exec ruby config-manager/bin/config-manager test demo production ### Validation Flow -YAML を読み込み、設定モデルで構造と整合性を検証し、検証結果と stack・環境数を表示します。検証規則の詳細は [Configuration](../../README.md#configuration) を参照してください。 +Loads YAML, validates structure and consistency through the configuration model, and displays the validation result with stack/environment counts. See [Configuration](../../README.md#configuration) for the validation rules. ## Error Handling Detailed error reporting with: + - **Specific Error Messages**: Pinpoint configuration issues - **Validation Context**: Clear indication of problematic sections - **Suggestions**: Guidance for fixing common configuration problems @@ -63,6 +64,7 @@ Detailed error reporting with: ## Integration The Config Manager integrates with: + - **Label Resolver**: Provides configuration for deployment targeting - **Label Dispatcher**: Validates service and directory configurations - **GitHub Actions**: Environment validation for CI/CD workflows @@ -79,10 +81,7 @@ bundle exec rspec spec/config-manager/ ### Local Testing ```bash -# Test with custom configuration cp workflow-config.yaml test-config.yaml -./bin/config-manager validate - -# Test service configuration -./bin/config-manager test myservice develop +WORKFLOW_CONFIG_PATH=test-config.yaml bundle exec ruby config-manager/bin/config-manager validate +bundle exec ruby config-manager/bin/config-manager test myservice develop ``` diff --git a/action-scripts/label-dispatcher/README-ja.md b/action-scripts/label-dispatcher/README-ja.md index 7fff54c..b4c9e39 100644 --- a/action-scripts/label-dispatcher/README-ja.md +++ b/action-scripts/label-dispatcher/README-ja.md @@ -4,52 +4,70 @@ GitHub Actions デプロイメント自動化のための Ruby ベースのサービス変更検出・ラベル管理ツール -## Overview +## 概要 Label Dispatcherはプルリクエストのファイル変更を分析し、影響を受けるサービスを検出し、デプロイラベルを自動的に管理します。コード変更に基づいてデプロイが必要なサービスを識別することで、デプロイ自動化のエントリーポイントとして機能します。 -## Features +## 機能 - **変更検出**: Gitの差分を分析して変更されたファイルを識別 - **サービスマッピング**: ファイル変更をサービスデプロイにマッピング - **ラベル管理**: PRのデプロイラベルを自動的に追加/削除 -- **Exclusion Support**: stack ごとの照合条件で除外 +- **除外条件**: stack ごとの照合条件で除外 - **GitHub統合**: シームレスなPRラベル管理 - **ディレクトリ規則**: 柔軟なサービスディレクトリ検出 - **デプロイメント戦略非依存**: 任意のブランチ戦略や開発ワークフローに対応 -## Usage +## 使い方 -Label Dispatcherは`bin/dispatcher`を通じてCLIインターフェースを提供します: +`action-scripts` を作業ディレクトリとして実行します。 -### Commands +Label Dispatcherは`label-dispatcher/bin/dispatcher`を通じてCLIインターフェースを提供します: + +### コマンド + +PR の変更に基づいてラベルを更新します。 ```bash -# PRのラベル配信(自動モード) bundle exec ruby label-dispatcher/bin/dispatcher dispatch PR_NUMBER +``` + +PR を操作せずに変更検出を確認します。 -# PRとのやり取りなしで変更検出をテスト +```bash bundle exec ruby label-dispatcher/bin/dispatcher test +``` + +比較する Git リファレンスを指定します。 -# 特定のgitリファレンスでテスト +```bash bundle exec ruby label-dispatcher/bin/dispatcher test --base-ref=main --head-ref=feature/auth +``` -# GitHub Actions環境のシミュレーション +GitHub Actions の実行環境をシミュレートします。 + +```bash bundle exec ruby label-dispatcher/bin/dispatcher simulate PR_NUMBER +``` + +実行環境の設定を検証します。 -# 環境設定の検証 +```bash bundle exec ruby label-dispatcher/bin/dispatcher validate_env +``` + +使用例と実行方法を表示します。 -# 使用例とヒントの表示 +```bash bundle exec ruby label-dispatcher/bin/dispatcher help_usage ``` -### Workflow Integration +### ワークフローへの組み込み ディスパッチャーは通常GitHub Actionsワークフローから呼び出されます: ```yaml -- name: ラベル配信 +- name: Dispatch labels uses: panicboat/deploy-actions/label-dispatcher@v1 with: pr-number: ${{ github.event.pull_request.number }} @@ -57,7 +75,7 @@ bundle exec ruby label-dispatcher/bin/dispatcher help_usage github-token: ${{ secrets.GITHUB_TOKEN }} ``` -### Environment Variables +### 環境変数 ディスパッチャーは GitHub Actions 用に以下の環境変数を設定します: @@ -66,42 +84,47 @@ bundle exec ruby label-dispatcher/bin/dispatcher help_usage - `LABELS_REMOVED`: 削除されたラベルの JSON 配列 - `HAS_CHANGES`: 変更が検出されたかを示すブール値 -## Change Matching +## 変更パスの照合 + +変更ファイルを定義された stack のパスに照合し、サービス・環境・任意 placeholder を抽出します。パスに環境名がない場合は、その stack の定義環境ごとに評価します。環境共通の stack は environment が null の照合として扱います。 -変更パスの照合とラベル生成の条件は [Change Matching](README.md#change-matching) を参照してください。 +各照合に除外条件を適用し、除外されない照合が一つでもあるサービスを一度だけラベル対象にします。サービス全体のディレクトリを検出対象にする場合は、そのパスも stack に定義します。削除されたファイルも照合するため、ディレクトリの存在は要求しません。 -## Configuration +## 設定 -設定仕様はルートの [Configuration](../../README.md#configuration) を参照してください。 +設定仕様はルートの [設定](../../README-ja.md#設定) を参照してください。 -## Architecture +## アーキテクチャ Label Dispatcherはクリーンアーキテクチャパターンに従います: -### Controllers +### コントローラー + - `LabelDispatcherController`: 配信プロセスの調整 -### Use Cases +### ユースケース + - `DetectChangedServices`: ファイル変更を分析してサービスにマッピング - `ManageLabels`: PRラベル操作を処理 -### Infrastructure +### インフラストラクチャ + - `GitHubClient`: GitHub APIとのやり取り - `FileSystemClient`: Git操作とファイル分析 - `ConfigClient`: 設定管理 -## Required Environment Variables +## 実行環境の設定 - `GITHUB_TOKEN`: GitHub API アクセスに必要 - `GITHUB_REPOSITORY`: リポジトリ名(owner/repo 形式) - `GITHUB_ACTIONS`: GitHub Actions 出力フォーマットを有効化 - `WORKFLOW_CONFIG_PATH`: 設定ファイルのパス(オプション、デフォルトは workflow-config.yaml) -## Detection Results +## 検出結果 -検出結果と GitHub Actions の出力は [Detection Results](README.md#detection-results) を参照してください。 +検出結果にはラベル、変更ファイル、対象サービスを含みます。GitHub Actions では `deploy-labels`、`labels-added`、`labels-removed`、`services-detected`、`has-changes` を出力します。除外状態の確認には [サービス診断](../config-manager/README-ja.md#サービス診断) を使います。 -## Error Handling +## エラー処理 ディスパッチャーは包括的なエラーハンドリングを提供します: @@ -110,30 +133,37 @@ Label Dispatcherはクリーンアーキテクチャパターンに従います - **設定問題**: 詳細なエラーメッセージを提供 - **権限エラー**: トークン権限の明確なガイダンス -## Development +## 開発 -### Dependencies +### 依存関係 -- Ruby 3.4+ +- Ruby ([.ruby-version](../.ruby-version)) - Bundler - Thor (CLIフレームワーク) - Octokit (GitHub API) - Git (システム依存関係) -### Testing +### 動作確認 + +現在の作業ディレクトリで変更検出を確認します。 ```bash -# 現在の作業ディレクトリでテスト bundle exec ruby label-dispatcher/bin/dispatcher test +``` -# 特定のリファレンスでテスト +比較する Git リファレンスを指定して変更検出を確認します。 + +```bash bundle exec ruby label-dispatcher/bin/dispatcher test --base-ref=main --head-ref=HEAD +``` + +実行環境を検証します。 -# 環境の検証 +```bash bundle exec ruby label-dispatcher/bin/dispatcher validate_env ``` -## Integration Points +## 連携先 Label Dispatcherは以下と統合します: @@ -142,7 +172,7 @@ Label Dispatcherは以下と統合します: 3. **GitHub Actions**: PRイベントでトリガーされ更新 4. **Gitリポジトリ**: ファイル変更と履歴を分析 -## Label Conventions +## ラベルの規約 ディスパッチャーは標準化されたラベル形式を使用します: @@ -150,10 +180,10 @@ Label Dispatcherは以下と統合します: - `deploy:all` - 全サービスのデプロイ(特殊ケース) - ラベルは自動的に管理・同期される -## Safety Features +## 安全性に関する機能 - **変更検証**: 関連する変更のみがデプロイをトリガーすることを確保 - **設定検証**: 処理前の設定検証 - **権限チェック**: GitHubトークンの権限を確認 -- **Exclusion Respect**: 各照合の除外条件を適用 +- **除外条件の適用**: 各照合の除外条件を適用 - **監査証跡**: トラブルシューティング用のすべてのラベル操作をログ記録 diff --git a/action-scripts/label-dispatcher/README.md b/action-scripts/label-dispatcher/README.md index 8204072..b5768e0 100644 --- a/action-scripts/label-dispatcher/README.md +++ b/action-scripts/label-dispatcher/README.md @@ -1,6 +1,6 @@ # Label Dispatcher -**English** | [🇯🇵 日本語](README-ja.md) +**English** | [🇯🇵 Japanese](README-ja.md) A Ruby-based service change detection and label management tool for GitHub Actions deployment automation. @@ -13,34 +13,52 @@ The Label Dispatcher analyzes file changes in pull requests, detects affected se - **Change Detection**: Analyze Git diffs to identify modified files - **Service Mapping**: Map file changes to service deployments - **Label Management**: Automatically add/remove deployment labels on PRs -- **Exclusion Support**: stack ごとの照合条件で除外 +- **Exclusion Support**: Exclude matches using conditions defined for each stack - **GitHub Integration**: Seamless PR label management - **Directory Conventions**: Flexible service directory detection - **Deployment Strategy Agnostic**: Works with any branching strategy or development workflow ## Usage -The Label Dispatcher provides a CLI interface through `bin/dispatcher`: +Run commands from the `action-scripts` directory. -### Basic Commands +The Label Dispatcher provides a CLI interface through `label-dispatcher/bin/dispatcher`: + +### Commands + +Dispatch labels for a PR (automatic mode). ```bash -# Dispatch labels for a PR (automatic mode) bundle exec ruby label-dispatcher/bin/dispatcher dispatch PR_NUMBER +``` + +Test change detection without PR interaction. -# Test change detection without PR interaction +```bash bundle exec ruby label-dispatcher/bin/dispatcher test +``` + +Test with specific git references. -# Test with specific git references +```bash bundle exec ruby label-dispatcher/bin/dispatcher test --base-ref=main --head-ref=feature/auth +``` -# Simulate GitHub Actions environment +Simulate GitHub Actions environment. + +```bash bundle exec ruby label-dispatcher/bin/dispatcher simulate PR_NUMBER +``` + +Validate environment configuration. -# Validate environment configuration +```bash bundle exec ruby label-dispatcher/bin/dispatcher validate_env +``` + +Show usage examples and tips. -# Show usage examples and tips +```bash bundle exec ruby label-dispatcher/bin/dispatcher help_usage ``` @@ -68,26 +86,29 @@ The dispatcher sets the following environment variables for GitHub Actions: ## Change Matching -変更ファイルを定義された stack のパスに照合し、サービス・環境・任意 placeholder を抽出します。パスに環境名がない場合は、その stack の定義環境ごとに評価します。環境共通の stack は environment が null の照合として扱います。 +Matches changed files against configured stack paths and extracts services, environments, and arbitrary placeholders. A path without an environment name is evaluated for each environment defined by its stack. A stack shared across environments is evaluated with `environment: null`. -各照合に除外条件を適用し、除外されない照合が一つでもあるサービスを一度だけラベル対象にします。サービス全体のディレクトリを検出対象にする場合は、そのパスも stack に定義します。削除されたファイルも照合するため、ディレクトリの存在は要求しません。 +Applies exclusion conditions to each match. A service receives one label if at least one match is not excluded. To detect changes throughout a service directory, include that path in a stack definition. Directories need not exist because deleted files are also matched. ## Configuration -設定仕様はルートの [Configuration](../../README.md#configuration) を参照してください。 +See the root [Configuration](../../README.md#configuration) section for the configuration schema. ## Architecture The Label Dispatcher follows a clean architecture pattern: ### Controllers + - `LabelDispatcherController`: Orchestrates the dispatch process ### Use Cases + - `DetectChangedServices`: Analyzes file changes and maps to services - `ManageLabels`: Handles PR label operations ### Infrastructure + - `GitHubClient`: GitHub API interactions - `FileSystemClient`: Git operations and file analysis - `ConfigClient`: Configuration management @@ -101,7 +122,7 @@ The Label Dispatcher follows a clean architecture pattern: ## Detection Results -検出結果にはラベル、変更ファイル、対象サービスを含みます。GitHub Actions では `deploy-labels`、`labels-added`、`labels-removed`、`services-detected`、`has-changes` を出力します。除外状態の確認には [Service Diagnosis](../config-manager/README.md#service-diagnosis) を使います。 +Detection results include labels, changed files, and affected services. GitHub Actions outputs are `deploy-labels`, `labels-added`, `labels-removed`, `services-detected`, and `has-changes`. Use [Service Diagnosis](../config-manager/README.md#service-diagnosis) to inspect exclusion status. ## Error Handling @@ -116,7 +137,7 @@ The dispatcher provides comprehensive error handling: ### Dependencies -- Ruby 3.4+ +- Ruby ([.ruby-version](../.ruby-version)) - Bundler - Thor (CLI framework) - Octokit (GitHub API) @@ -124,14 +145,21 @@ The dispatcher provides comprehensive error handling: ### Testing +Inspect change detection in the current working directory. + ```bash -# Test with current working directory bundle exec ruby label-dispatcher/bin/dispatcher test +``` + +Test with specific refs. -# Test with specific refs +```bash bundle exec ruby label-dispatcher/bin/dispatcher test --base-ref=main --head-ref=HEAD +``` + +Validate environment. -# Validate environment +```bash bundle exec ruby label-dispatcher/bin/dispatcher validate_env ``` @@ -157,5 +185,5 @@ The dispatcher uses standardized label formats: - **Change Validation**: Ensures only relevant changes trigger deployments - **Configuration Validation**: Validates configuration before processing - **Permission Checks**: Verifies GitHub token permissions -- **Exclusion Respect**: 各照合の除外条件を適用 +- **Exclusion Respect**: Apply exclusion conditions to each match - **Audit Trail**: Logs all label operations for troubleshooting diff --git a/action-scripts/label-resolver/README-ja.md b/action-scripts/label-resolver/README-ja.md index d9ede4e..7cbf80e 100644 --- a/action-scripts/label-resolver/README-ja.md +++ b/action-scripts/label-resolver/README-ja.md @@ -4,11 +4,11 @@ PR ラベルを明示的な環境指定を使って GitHub Actions 自動化のためのデプロイメントターゲットに変換する Ruby ベースのデプロイメント解決ツールです。 -## Overview +## 概要 Label Resolver は PR ラベルを分析し、指定された環境に対するデプロイメントターゲットを生成します。デプロイメントの安全性を検証し、マルチサービスデプロイメント用のデプロイメントマトリクスを作成し、デプロイメント自動化の意思決定の中心的なオーケストレーターとして機能します。 -## Features +## 機能 - **ラベル解決**: PR 情報からデプロイメントラベルを抽出 - **明示的環境指定**: ブランチ依存なしの直接的な環境指定 @@ -16,71 +16,105 @@ Label Resolver は PR ラベルを分析し、指定された環境に対する - **マトリクス生成**: 並列実行用のデプロイメントマトリクス作成 - **GitHub Actions 統合**: GitHub Actions ワークフローとのシームレスな統合 -## Usage +## 使い方 -Label Resolver は `bin/resolver` を通じて CLI インターフェースを提供します: +`action-scripts` を作業ディレクトリとして実行します。 -### Commands +Label Resolver は `label-resolver/bin/resolver` を通じて CLI インターフェースを提供します: + +### コマンド + +指定環境に対するデプロイ対象を PR ラベルから解決します。 ```bash -# 特定の環境に対する PR ラベルからのデプロイメント解決 -bundle exec ruby label-resolver/bin/resolver resolve PR番号 [環境一覧] +bundle exec ruby label-resolver/bin/resolver resolve PR_NUMBER [ENVIRONMENTS] +``` -# デプロイメントワークフローのテスト -bundle exec ruby label-resolver/bin/resolver test PR番号 [環境一覧] +デプロイ対象の解決結果を確認します。 -# GitHub Actions 環境のシミュレーション -bundle exec ruby label-resolver/bin/resolver simulate PR番号 [環境一覧] +```bash +bundle exec ruby label-resolver/bin/resolver test PR_NUMBER [ENVIRONMENTS] +``` + +GitHub Actions の実行環境をシミュレートします。 + +```bash +bundle exec ruby label-resolver/bin/resolver simulate PR_NUMBER [ENVIRONMENTS] +``` -# 環境設定の検証 +実行環境の設定を検証します。 + +```bash bundle exec ruby label-resolver/bin/resolver validate_env +``` + +処理を段階ごとにデバッグします。 -# ワークフローのステップバイステップデバッグ -bundle exec ruby label-resolver/bin/resolver debug PR番号 [環境一覧] +```bash +bundle exec ruby label-resolver/bin/resolver debug PR_NUMBER [ENVIRONMENTS] ``` **環境指定:** + - 単一環境: `develop` - 複数環境: `develop,staging` (カンマ区切り) - 全環境: 環境一覧パラメータを省略 -### Examples +### 実行例 + +`develop` 環境のデプロイ対象を解決します。 + +```bash +bundle exec ruby label-resolver/bin/resolver resolve 123 develop +``` + +複数環境のデプロイ対象をまとめて確認します。 ```bash -# develop 環境のデプロイメント解決 -./bin/resolver resolve 123 develop +bundle exec ruby label-resolver/bin/resolver test 456 develop,staging +``` -# 複数環境への同時テスト -./bin/resolver test 456 develop,staging +`production` 環境のデプロイ対象の解決をデバッグします。 -# production デプロイメントのデバッグ -./bin/resolver debug 789 production +```bash +bundle exec ruby label-resolver/bin/resolver debug 789 production +``` + +定義された全環境のデプロイ対象を解決します。 -# 利用可能な全環境へのデプロイ -./bin/resolver resolve 123 +```bash +bundle exec ruby label-resolver/bin/resolver resolve 123 ``` -### Workflow Integration +### ワークフローへの組み込み リゾルバーは通常 GitHub Actions ワークフローから呼び出されます: +単一環境を指定する場合: + ```yaml -# 単一環境デプロイメント -- name: デプロイメントターゲットの解決 +- name: Resolve deployment targets uses: panicboat/deploy-actions/label-resolver@v1 with: pr-number: ${{ github.event.pull_request.number }} + repository: ${{ github.repository }} + github-token: ${{ secrets.GITHUB_TOKEN }} environments: ${{ inputs.target_environment }} +``` + +複数環境を指定する場合: -# 複数環境デプロイメント -- name: デプロイメントターゲットの解決 +```yaml +- name: Resolve deployment targets uses: panicboat/deploy-actions/label-resolver@v1 with: pr-number: ${{ github.event.pull_request.number }} + repository: ${{ github.repository }} + github-token: ${{ secrets.GITHUB_TOKEN }} environments: "develop,staging" ``` -### Environment Variables +### 環境変数 リゾルバーは GitHub Actions 用に以下の環境変数を設定します: @@ -90,7 +124,7 @@ bundle exec ruby label-resolver/bin/resolver debug PR番号 [環境一覧] - `SAFETY_STATUS`: 安全性検証の結果 - `MERGED_PR_NUMBER`: デプロイメント追跡用の PR 番号 -### Action Outputs +### Action の出力 リゾルバーは以下の GitHub Actions 出力を提供します: @@ -98,9 +132,9 @@ bundle exec ruby label-resolver/bin/resolver debug PR番号 [環境一覧] - `has-targets`: ターゲットが存在するかを示すブール値 (`true`/`false`) - `safety-status`: 安全性検証の結果 (`passed`/`failed`) -## Architecture +## アーキテクチャ -### Components +### 構成要素 - **LabelResolverController**: メインオーケストレーションロジック - **DetermineTargetEnvironment**: 複数環境検証 @@ -108,7 +142,7 @@ bundle exec ruby label-resolver/bin/resolver debug PR番号 [環境一覧] - **ValidateDeploymentSafety**: 安全性チェック(現在簡素化) - **GenerateMatrix**: 複数環境用デプロイメントマトリクス生成 -### Flow +### 処理の流れ 1. **ラベル抽出**: PR からデプロイラベルを取得 2. **環境検証**: 全ての対象環境が存在することを検証 @@ -116,11 +150,11 @@ bundle exec ruby label-resolver/bin/resolver debug PR番号 [環境一覧] 4. **マトリクス生成**: ディレクトリ構造に基づいて全環境のデプロイメントターゲットを作成 5. **出力生成**: 簡素化された出力で GitHub Actions 用に結果をフォーマット -## Configuration +## 設定 -設定仕様と matrix の形式はルートの [Configuration](../../README.md#configuration) と [Matrix Output](../../README.md#matrix-output) を参照してください。 +設定仕様と matrix の形式はルートの [設定](../../README-ja.md#設定) と [matrix 出力](../../README-ja.md#matrix-出力) を参照してください。 -## Deploy Labels +## デプロイラベル システムは `deploy:service` 形式のラベルを認識します: @@ -129,7 +163,7 @@ bundle exec ruby label-resolver/bin/resolver debug PR番号 [環境一覧] - `deploy:frontend` - frontend サービスをデプロイ - `deploy:all` - 実在する全サービスの対象を解決 -## Environment Targeting +## 環境の指定 **トランクベース開発**: リゾルバーはブランチベースマッピングではなく明示的な環境指定を使用: @@ -138,37 +172,43 @@ bundle exec ruby label-resolver/bin/resolver debug PR番号 [環境一覧] - 設定で定義された任意のデプロイメント環境をサポート - 複数環境への同時デプロイメントが可能 -## Target Resolution +## デプロイ対象の解決 + +指定環境を各 stack の定義環境に絞り、全パスの実在ディレクトリを列挙します。環境指定の省略または空白入力は全定義環境を選択します。環境共通の対象は各ディレクトリにつき一度だけ生成します。 -環境選択、実在ディレクトリの列挙、除外、失敗条件は [Target Resolution](README.md#target-resolution) を参照してください。 +`deploy:all` は設定したパスから全サービスを探索します。除外条件に一致する対象は matrix に含めません。存在しないパスは正常な空の結果になり、未知環境・列挙エラー・抽出値の矛盾は失敗になります。 -## Error Handling +## エラー処理 リゾルバーは包括的なエラーハンドリングを提供します: - **無効な環境**: 対象環境が存在しない場合の明確なエラー - **ラベル不足**: デプロイラベルのない PR の適切な処理 - **設定エラー**: ワークフロー設定の詳細な検証 -- **Directory Detection**: 存在しないパスは正常な空の結果 +- **ディレクトリ検出**: 存在しないパスは正常な空の結果 -## Development +## 開発 -### Running Tests +### テストの実行 ```bash cd action-scripts bundle exec rspec spec/label-resolver/ ``` -### Local Testing +### ローカルでの動作確認 + +実行環境を設定します。 ```bash -# 環境設定 export GITHUB_TOKEN=your_token export GITHUB_REPOSITORY=owner/repo export SOURCE_REPO_PATH=your_source_path export WORKFLOW_CONFIG_PATH=workflow-config.yaml +``` -# 実際の PR でテスト -./bin/resolver debug 123 develop +実際の PR を使ってデバッグします。 + +```bash +bundle exec ruby label-resolver/bin/resolver debug 123 develop ``` diff --git a/action-scripts/label-resolver/README.md b/action-scripts/label-resolver/README.md index ac30db1..d4ee5d6 100644 --- a/action-scripts/label-resolver/README.md +++ b/action-scripts/label-resolver/README.md @@ -1,6 +1,6 @@ # Label Resolver -**English** | [🇯🇵 日本語](README-ja.md) +**English** | [🇯🇵 Japanese](README-ja.md) A Ruby-based deployment resolution tool that converts PR labels into deployment targets for GitHub Actions automation using explicit environment targeting. @@ -18,65 +18,99 @@ The Label Resolver analyzes PR labels and generates deployment targets for speci ## Usage -The Label Resolver provides a CLI interface through `bin/resolver`: +Run commands from the `action-scripts` directory. -### Basic Commands +The Label Resolver provides a CLI interface through `label-resolver/bin/resolver`: + +### Commands + +Resolve deployment from PR labels for specific environment(s). ```bash -# Resolve deployment from PR labels for specific environment(s) bundle exec ruby label-resolver/bin/resolver resolve PR_NUMBER [ENVIRONMENTS] +``` -# Test deployment workflow +Inspect deployment target resolution. + +```bash bundle exec ruby label-resolver/bin/resolver test PR_NUMBER [ENVIRONMENTS] +``` + +Simulate GitHub Actions environment. -# Simulate GitHub Actions environment +```bash bundle exec ruby label-resolver/bin/resolver simulate PR_NUMBER [ENVIRONMENTS] +``` -# Validate environment configuration +Validate environment configuration. + +```bash bundle exec ruby label-resolver/bin/resolver validate_env +``` + +Debug workflow step-by-step. -# Debug workflow step-by-step +```bash bundle exec ruby label-resolver/bin/resolver debug PR_NUMBER [ENVIRONMENTS] ``` **Environment Specification:** + - Single environment: `develop` - Multiple environments: `develop,staging` (comma-separated) - All environments: omit ENVIRONMENTS parameter ### Examples +Resolve deployments for develop environment. + ```bash -# Resolve deployments for develop environment -./bin/resolver resolve 123 develop +bundle exec ruby label-resolver/bin/resolver resolve 123 develop +``` -# Test multiple environments simultaneously -./bin/resolver test 456 develop,staging +Test multiple environments simultaneously. -# Debug production deployment -./bin/resolver debug 789 production +```bash +bundle exec ruby label-resolver/bin/resolver test 456 develop,staging +``` + +Debug production deployment. + +```bash +bundle exec ruby label-resolver/bin/resolver debug 789 production +``` -# Deploy to all available environments -./bin/resolver resolve 123 +Resolve deployment targets for all configured environments. + +```bash +bundle exec ruby label-resolver/bin/resolver resolve 123 ``` ### Workflow Integration The resolver is typically called from GitHub Actions workflows: +Single environment deployment. + ```yaml -# Single environment deployment - name: Resolve deployment targets uses: panicboat/deploy-actions/label-resolver@v1 with: pr-number: ${{ github.event.pull_request.number }} + repository: ${{ github.repository }} + github-token: ${{ secrets.GITHUB_TOKEN }} environments: ${{ inputs.target_environment }} +``` -# Multiple environment deployment +Multiple environment deployment. + +```yaml - name: Resolve deployment targets uses: panicboat/deploy-actions/label-resolver@v1 with: pr-number: ${{ github.event.pull_request.number }} + repository: ${{ github.repository }} + github-token: ${{ secrets.GITHUB_TOKEN }} environments: "develop,staging" ``` @@ -118,7 +152,7 @@ The resolver provides the following GitHub Actions outputs: ## Configuration -設定仕様と matrix の形式はルートの [Configuration](../../README.md#configuration) と [Matrix Output](../../README.md#matrix-output) を参照してください。 +See the root [Configuration](../../README.md#configuration) and [Matrix Output](../../README.md#matrix-output) sections for the schema and matrix format. ## Deploy Labels @@ -127,7 +161,7 @@ The system recognizes labels in the format `deploy:service`: - `deploy:auth` - Deploy auth service - `deploy:api` - Deploy api service - `deploy:frontend` - Deploy frontend service -- `deploy:all` - 実在する全サービスの対象を解決 +- `deploy:all` - Resolve targets for all existing services ## Environment Targeting @@ -136,12 +170,13 @@ The system recognizes labels in the format `deploy:service`: - Environments are specified directly as parameters - No dependency on branch names for environment determination - Supports any deployment environment defined in configuration +- Supports targeting multiple environments in one invocation ## Target Resolution -指定環境を各 stack の定義環境に絞り、全パスの実在ディレクトリを列挙します。環境指定の省略または空白入力は全定義環境を選択します。環境共通の対象は各ディレクトリにつき一度だけ生成します。 +Intersects the requested environments with each stack's configured environments and enumerates existing directories across all paths. Omitting the environment selection or providing only whitespace selects all configured environments. Targets shared across environments are generated once per directory. -`deploy:all` は設定したパスから全サービスを探索します。除外条件に一致する対象は matrix に含めません。存在しないパスは正常な空の結果になり、未知環境・列挙エラー・抽出値の矛盾は失敗になります。 +`deploy:all` discovers all services from the configured paths. Targets matching exclusion conditions are omitted from the matrix. Missing paths produce a valid empty result; unknown environments, enumeration errors, and conflicting captured values cause failures. ## Error Handling @@ -150,7 +185,7 @@ The resolver provides comprehensive error handling: - **Invalid Environment**: Clear error when target environment doesn't exist - **Missing Labels**: Graceful handling of PRs without deploy labels - **Configuration Errors**: Detailed validation of workflow configuration -- **Directory Detection**: 存在しないパスは正常な空の結果 +- **Directory Detection**: Missing paths produce a valid empty result ## Development @@ -163,13 +198,17 @@ bundle exec rspec spec/label-resolver/ ### Local Testing +Set up environment. + ```bash -# Set up environment export GITHUB_TOKEN=your_token export GITHUB_REPOSITORY=owner/repo export SOURCE_REPO_PATH=your_source_path export WORKFLOW_CONFIG_PATH=workflow-config.yaml +``` -# Test with real PR -./bin/resolver debug 123 develop +Test with real PR. + +```bash +bundle exec ruby label-resolver/bin/resolver debug 123 develop ``` From 71480486ae745814b7a31cf8c3722614a3a42f3d Mon Sep 17 00:00:00 2001 From: panicboat Date: Fri, 9 Oct 2026 11:04:43 +0900 Subject: [PATCH 13/13] fix: load colorize for console rendering in CI ConsolePresenter requires String#colorize even when instantiated under GITHUB_ACTIONS. Load the existing dependency regardless of presenter selection and cover rendering in a fresh CI process so local preload state cannot hide missing dependencies. Signed-off-by: panicboat --- action-scripts/shared/shared_loader.rb | 2 +- .../interfaces/presenters/console_presenter_spec.rb | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/action-scripts/shared/shared_loader.rb b/action-scripts/shared/shared_loader.rb index 44ea6cc..16aa29c 100644 --- a/action-scripts/shared/shared_loader.rb +++ b/action-scripts/shared/shared_loader.rb @@ -7,7 +7,7 @@ require 'yaml' require 'json' require 'octokit' -require 'colorize' unless ENV['GITHUB_ACTIONS'] +require 'colorize' # Load shared components in Clean Architecture dependency order [ diff --git a/action-scripts/spec/shared/interfaces/presenters/console_presenter_spec.rb b/action-scripts/spec/shared/interfaces/presenters/console_presenter_spec.rb index fbe2a27..9f9f410 100644 --- a/action-scripts/spec/shared/interfaces/presenters/console_presenter_spec.rb +++ b/action-scripts/spec/shared/interfaces/presenters/console_presenter_spec.rb @@ -1,4 +1,6 @@ require 'spec_helper' +require 'open3' +require 'rbconfig' RSpec.describe Interfaces::Presenters::ConsolePresenter do it 'displays labels and changed files without service exclusion metadata' do @@ -7,6 +9,15 @@ end.to output(/Deploy Labels: deploy:demo.*Changed Files: 1 files/m).to_stdout end + it 'displays console results when loaded in a GitHub Actions process' do + loader = File.expand_path('../../../../shared/shared_loader', __dir__) + script = "Interfaces::Presenters::ConsolePresenter.new.present_label_dispatch_result(deploy_labels: [Entities::DeployLabel.new('deploy:demo')], labels_added: ['deploy:demo'], labels_removed: [], changed_files: ['dystopia/demo/main.rb'])" + stdout, stderr, status = Open3.capture3({ 'GITHUB_ACTIONS' => 'true' }, RbConfig.ruby, '-r', loader, '-e', script) + + expect(status).to be_success, stderr + expect(stdout).to include('Deploy Labels: deploy:demo', 'Changed Files: 1 files') + end + it 'accepts only the dispatch result fields' do expect(described_class.instance_method(:present_label_dispatch_result).parameters.map(&:last)).to eq(%i[deploy_labels labels_added labels_removed changed_files]) end