From 99f3de6e4a257d0e36c8fe18e266ac1f69b855e5 Mon Sep 17 00:00:00 2001 From: panicboat Date: Sat, 10 Oct 2026 01:58:29 +0900 Subject: [PATCH] feat!: discover stack environments and expand attributes Resolve attribute-free environment targets from repository paths and derive attribute values from the same captures used for target identity. BREAKING CHANGE: stacks without attributes or environments require {environment} in every path. Environment-shared stacks must explicitly specify attributes, including an empty map. Signed-off-by: panicboat --- README-ja.md | 28 +++++--- README.md | 28 +++++--- action-scripts/config-manager/README-ja.md | 10 +-- action-scripts/config-manager/README.md | 10 +-- .../config-manager/bin/config-manager | 3 +- .../controllers/config_manager_controller.rb | 17 +++-- action-scripts/label-dispatcher/README-ja.md | 2 +- action-scripts/label-dispatcher/README.md | 2 +- .../use_cases/detect_changed_services.rb | 8 ++- action-scripts/label-resolver/README-ja.md | 6 +- action-scripts/label-resolver/README.md | 6 +- action-scripts/label-resolver/application.rb | 3 +- .../use_cases/determine_target_environment.rb | 8 ++- .../use_cases/generated_matrix.rb | 15 +++-- .../shared/entities/deployment_target.rb | 19 +++++- .../shared/entities/workflow_config.rb | 40 ++++++++--- .../infrastructure/file_system_client.rb | 10 +++ .../spec/config-manager/cli_spec.rb | 19 ++++++ .../config_manager_controller_spec.rb | 20 +++++- .../use_cases/validate_config_spec.rb | 10 ++- .../use_cases/detect_changed_services_spec.rb | 14 +++- .../determine_target_environment_spec.rb | 25 ++++++- .../use_cases/generate_matrix_spec.rb | 66 +++++++++++++++++-- .../shared/entities/deployment_target_spec.rb | 19 ++++++ .../shared/entities/workflow_config_spec.rb | 54 +++++++++++++-- .../infrastructure/file_system_client_spec.rb | 27 ++++++++ action-scripts/workflow-config.yaml | 2 +- .../specs/stack-configuration-design.md | 38 ++++++++--- 28 files changed, 421 insertions(+), 88 deletions(-) diff --git a/README-ja.md b/README-ja.md index 4368d31..0a0b481 100644 --- a/README-ja.md +++ b/README-ja.md @@ -39,7 +39,7 @@ PR の変更ファイルを検出し、変更があったサービスに対し **特徴:** - ラベルからターゲットへの解決 -- 定義された環境からの対象選択 +- 定義環境と探索した環境からの対象選択 - デプロイメント matrix 生成 - 安全性検証 @@ -57,7 +57,7 @@ PR の変更ファイルを検出し、変更があったサービスに対し ### Label Resolver -`environments` は任意の入力で、複数環境はカンマ区切りで指定します。省略すると全定義環境を対象にします。 +`environments` は任意の入力で、複数環境はカンマ区切りで指定します。省略すると定義環境と探索した環境をすべて対象にします。 ```yaml - uses: panicboat/deploy-actions/label-resolver@v1 @@ -94,7 +94,11 @@ stacks: - "dystopia/{service}" - "system-components/{service}" attributes: - repository: registry.example.com/app + repository: registry.example.com/{service} + - name: kubernetes + paths: + - "dystopia/{service}/kubernetes/overlays/{environment}" + - "system-components/{service}/kubernetes/overlays/{environment}" ``` ### stack の定義 @@ -105,14 +109,22 @@ stacks: | `id` | 任意 | インスタンス識別子。省略時は `name`。設定全体で一意 | | `paths` | 必須 | 空でない相対パス配列。全パスに `{service}` が必要 | | `environments` | 任意 | 環境名をキー、環境属性を値とする空でないマップ | -| `attributes` | 任意 | 環境共通の属性。`environments` と併用できない | +| `attributes` | 任意 | 環境共通の対象を明示する。`attributes: {}` も有効。`environments` と併用できない | | `exclude` | 任意 | 除外条件の配列。省略時は空配列 | 共有するプロダクトは一つの定義の `paths` に追加します。独立させる場合は、同じ `name` に異なる `id` を付けた別定義にし、それぞれにパスと属性を置きます。 -`environments` のキーがその stack の環境名です。環境一覧は全 stack の和集合になり、環境指定を省略すると全定義環境が対象になります。各 stack は自身の定義環境だけを生成し、未知の環境指定はエラーになります。属性の値と型はそのまま出力され、環境間の属性キーを揃える必要はありません。 +`environments` のキーがその stack の環境名です。`environments` と `attributes` の両方を省略した stack は、パスに一致する実在ディレクトリの `{environment}` の位置から環境名を取得します。この場合は全パスに `{environment}` が必要で、含まないパスがあれば設定エラーになります。探索した対象に追加属性はなく、別 stack の環境・属性・除外条件は継承しません。 + +選択できる環境一覧は、全 stack の定義環境と実行時に探索した環境名の和集合です。除外された環境名も選択できます。環境指定を省略するとこの和集合を対象にします。各 stack は自身の定義環境または探索した環境だけを生成し、和集合にない環境指定はエラーになります。 + +パスに `{environment}` がなくても、`environments` があれば環境ごとの対象になります。同じディレクトリを異なる環境属性で使えます。`attributes` があれば環境共通で、環境選択数にかかわらず `environment: null` の対象を各ディレクトリにつき一度だけ生成します。この場合はパスに `{environment}` を指定できません。属性が不要な環境共通の対象には `attributes: {}` を明示します。 + +### Attribute Values + +`attributes` と環境属性マップの文字列値では、`{service}` とパスから抽出した `{team}` などの任意 placeholder を使えます。環境属性では、パスに含まれない場合も `{environment}` を使えます。マップや配列に入れ子になった文字列にも同じ規則を適用します。マップのキーはリテラルのままで、文字列以外の値は型を保持します。環境間で属性キーを揃える必要はありません。 -パスに `{environment}` がなくても、`environments` があれば環境ごとの対象になります。同じディレクトリを異なる環境属性で使えます。`environments` がなければ環境共通で、`environment: null` の対象を各ディレクトリにつき一度だけ生成します。この場合の属性は `attributes` から取得し、パスに `{environment}` は指定できません。 +属性が参照する placeholder は、その stack のすべてのパスで取得できる必要があり、解決できない参照は設定エラーになります。環境共通の属性では `{environment}` を参照できません。例えば `paths: ["teams/{team}/{service}"]` と `attributes: {repository: "ghcr.io/{team}/{service}"}` の組み合わせは、`teams/payments/api` に対して `ghcr.io/payments/api` を出力します。 ### パスの照合 @@ -126,7 +138,7 @@ stacks: 一つのマップ内は AND、配列内は OR で完全一致を判定します。省略したキーは制約になりません。service のみ、environment のみ、任意 placeholder のみでも指定できます。照合したパスにない抽出値を要求する条件は一致しません。 -条件値は空でない文字列で、`/`、`.`、`..` は使えません。service はドットで始められません。環境条件は自身の定義環境だけを指定できます。環境共通 stack では environment の省略または `null` を受け付け、environment 以外の条件値に `null` は使えません。 +条件値は空でない文字列で、`/`、`.`、`..` は使えません。service はドットで始められません。`environments` を指定した stack の環境条件は自身の定義環境だけを指定できます。環境を探索する stack では、まだ存在しないディレクトリの環境値も条件にできます。環境共通 stack では environment の省略または `null` を受け付け、environment 以外の条件値に `null` は使えません。条件値はリテラルで、placeholder は展開しません。 実行可能な設定例は [workflow-config.yaml](action-scripts/workflow-config.yaml) を参照してください。 @@ -183,7 +195,7 @@ jobs: | キー | 出力元 | |---|---| | `service` | ラベルまたは `deploy:all` で探索したサービス名 | -| `environment` | 定義した環境名。環境共通なら `null` | +| `environment` | 定義または探索した環境名。環境共通なら `null` | | `stack` | stack の `name` | | `stack_id` | 確定した `id` | | `working_directory` | 実在する対象ディレクトリの相対パス | diff --git a/README.md b/README.md index fb3c209..7f07f2f 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Generates a matrix for downstream Actions from `deploy:` labels and the **Highlights:** - Label-to-target resolution -- Target selection from configured environments +- Target selection from declared and discovered environments - Deployment-matrix generation - Safety validation @@ -57,7 +57,7 @@ Generates a matrix for downstream Actions from `deploy:` labels and the ### Label Resolver -`environments` is optional. Specify multiple environments as a comma-separated list; omit it to target all configured environments. +`environments` is optional. Specify multiple environments as a comma-separated list; omit it to target all declared and discovered environments. ```yaml - uses: panicboat/deploy-actions/label-resolver@v1 @@ -94,7 +94,11 @@ stacks: - "dystopia/{service}" - "system-components/{service}" attributes: - repository: registry.example.com/app + repository: registry.example.com/{service} + - name: kubernetes + paths: + - "dystopia/{service}/kubernetes/overlays/{environment}" + - "system-components/{service}/kubernetes/overlays/{environment}" ``` ### Stack Definitions @@ -105,14 +109,22 @@ stacks: | `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` | +| `attributes` | Optional | Explicitly selects a target shared across environments, including `attributes: {}`; cannot be combined with `environments` | | `exclude` | Optional | Array of exclusion conditions; defaults to an empty array | 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. -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. +The keys in `environments` define the stack's environment names. A stack with neither `environments` nor `attributes` discovers environment names from the `{environment}` position in existing directories matching its paths. Every path in that stack must contain `{environment}`; otherwise the configuration is invalid. Discovered targets have no additional attributes and do not inherit another stack's environments, attributes, or exclusions. + +The selectable environment list is the union of all declared names and names discovered at execution time. Excluded environments remain selectable. Omitting the environment selection targets this union. Each stack generates targets only for its own declared or discovered environments. Selecting a name absent from the union is an error. + +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 with `attributes` generates one target with `environment: null` per directory, regardless of environment selection. Its paths cannot contain `{environment}`. Use `attributes: {}` when a shared target needs no attributes. + +### Attribute Values + +String values in `attributes` and environment attribute maps support `{service}` and arbitrary placeholders captured from the path, such as `{team}`. Environment attributes also support `{environment}`, even if the path does not contain it. The same rules apply to strings nested in maps or arrays. Map keys remain literal, and non-string values retain their types. Environments do not need identical attribute keys. -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. +Every placeholder referenced by an attribute must be available for every path in that stack; unresolved references are configuration errors. Shared attributes cannot reference `{environment}`. For example, `paths: ["teams/{team}/{service}"]` with `attributes: {repository: "ghcr.io/{team}/{service}"}` produces `ghcr.io/payments/api` for `teams/payments/api`. ### Path Matching @@ -126,7 +138,7 @@ Each entry in `exclude` is a nonempty condition map. Conditions can use `service 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. -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`. +Condition values must be nonempty strings representing one path segment; `/`, `.`, and `..` are not allowed. Service names cannot start with a dot. When `environments` is specified, environment conditions can name only the stack's declared environments. For stacks that discover environments, an environment condition may name a directory value that does not yet exist. For stacks shared across environments, `environment` can be omitted or set to `null`; other condition values cannot be `null`. Condition values are literal and do not expand placeholders. See [workflow-config.yaml](action-scripts/workflow-config.yaml) for a working configuration example. @@ -183,7 +195,7 @@ The execution layer (`aws`, `kubernetes`, etc.) is intentionally not part of thi | Key | Source | |---|---| | `service` | Service name from a label or discovered through `deploy:all` | -| `environment` | Configured environment name, or `null` for a target shared across environments | +| `environment` | Declared or discovered 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 | diff --git a/action-scripts/config-manager/README-ja.md b/action-scripts/config-manager/README-ja.md index 071cc2d..6b29de2 100644 --- a/action-scripts/config-manager/README-ja.md +++ b/action-scripts/config-manager/README-ja.md @@ -18,9 +18,9 @@ Config Manager は `config-manager/bin/config-manager` を通じて CLI イン | コマンド | 結果 | |---|---| -| `bundle exec ruby config-manager/bin/config-manager validate` | 設定の検証結果と stack・環境数 | +| `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 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 の表示 | @@ -28,9 +28,9 @@ Config Manager は `config-manager/bin/config-manager` を通じて CLI イン ### サービス診断 -環境を省略すると、そのサービスの全定義環境と共通対象を調べます。環境を指定すると、指定環境と共通対象を調べます。サービスの登録は不要です。パスに一致しないサービスは空の結果になります。 +環境を省略すると、そのサービスの定義環境・探索した環境・共通対象をすべて調べます。環境を指定すると、指定環境と共通対象を調べます。サービスの登録は不要です。パスに一致しないサービスは空の結果になります。 -一致する全ディレクトリを stack ID ごとに表示し、属性と任意 placeholder の抽出値を保持します。除外された対象も `excluded: true` として表示するため、実行対象から外れる条件を確認できます。 +一致する全ディレクトリを stack ID ごとに表示し、展開後の属性値と任意 placeholder の抽出値を出力します。除外された対象も `excluded: true` として表示するため、実行対象から外れる条件を確認できます。 ```bash bundle exec ruby config-manager/bin/config-manager test demo @@ -50,7 +50,7 @@ bundle exec ruby config-manager/bin/config-manager test demo production ### 検証の流れ -YAML を読み込み、設定モデルで構造と整合性を検証し、検証結果と stack・環境数を表示します。検証規則の詳細は [設定](../../README-ja.md#設定) を参照してください。 +YAML を読み込み、設定モデルで構造と整合性を検証し、検証結果と stack・定義環境数を表示します。検証と設定表示ではディレクトリを列挙しません。実在ディレクトリの環境名は `environments` またはサービス診断で取得します。検証規則の詳細は [設定](../../README-ja.md#設定) を参照してください。 ## エラー処理 diff --git a/action-scripts/config-manager/README.md b/action-scripts/config-manager/README.md index 36db4b8..b010e7c 100644 --- a/action-scripts/config-manager/README.md +++ b/action-scripts/config-manager/README.md @@ -18,9 +18,9 @@ Run commands from the `action-scripts` directory. | Command | Result | |---|---| -| `bundle exec ruby config-manager/bin/config-manager validate` | Validation result and stack/environment counts | +| `bundle exec ruby config-manager/bin/config-manager validate` | Validation result and stack/declared 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 environments` | Union of declared and discovered 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 | @@ -28,9 +28,9 @@ Run commands from the `action-scripts` directory. ### 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. +Omitting the environment inspects all declared and discovered 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. -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. +All matching directories are displayed by stack ID, with expanded attribute values 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 @@ -50,7 +50,7 @@ Unknown environments, invalid configuration, directory enumeration failures, and ### Validation Flow -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. +Loads YAML, validates structure and consistency through the configuration model, and displays the validation result with stack/declared environment counts. Validation and configuration display do not enumerate directories; use `environments` or service diagnosis to discover environment names from existing directories. See [Configuration](../../README.md#configuration) for the validation rules. ## Error Handling diff --git a/action-scripts/config-manager/bin/config-manager b/action-scripts/config-manager/bin/config-manager index 32dbec2..3e41c66 100644 --- a/action-scripts/config-manager/bin/config-manager +++ b/action-scripts/config-manager/bin/config-manager @@ -77,8 +77,9 @@ class ConfigManagerCLI < Thor begin config_client = ConfigManagerContainer.resolve(:config_client) config = config_client.load_workflow_config + file_client = ConfigManagerContainer.resolve(:file_client) puts "Configured Environments:" - config.environment_names.each { |name| puts " #{name}" } + file_client.environment_names(config: config).each { |name| puts " #{name}" } rescue => error puts "❌ Failed to load environments: #{error.message}" exit 1 diff --git a/action-scripts/config-manager/controllers/config_manager_controller.rb b/action-scripts/config-manager/controllers/config_manager_controller.rb index e323ca8..1156236 100644 --- a/action-scripts/config-manager/controllers/config_manager_controller.rb +++ b/action-scripts/config-manager/controllers/config_manager_controller.rb @@ -48,13 +48,20 @@ def show_configuration def test_service_configuration(service_name:, environment: nil) config = @config_client.load_workflow_config - if environment && !config.environment_names.include?(environment) + available = @file_client.environment_names(config: config) + if environment && !available.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 = if stack.key?('environments') + stack['environments'].keys + elsif stack.key?('attributes') + [nil] + else + available + end + environments &= [environment] if environment && !stack.key?('attributes') environments.each do |selected| values = { 'service' => service_name, 'environment' => selected } stack['paths'].each do |pattern| @@ -76,7 +83,7 @@ def test_service_configuration(service_name:, environment: nil) stack = candidate.fetch(:stack) selected = candidate.fetch(:environment) captures = candidate.fetch(:captures) - attributes = stack.key?('environments') ? stack['environments'].fetch(selected) : stack['attributes'] + attributes = stack.key?('environments') ? stack['environments'].fetch(selected) : stack.fetch('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 @@ -173,7 +180,7 @@ def build_config_template - "dystopia/{service}" - "system-components/{service}" attributes: - repository: registry.example.com/app + repository: registry.example.com/{service} YAML end end diff --git a/action-scripts/label-dispatcher/README-ja.md b/action-scripts/label-dispatcher/README-ja.md index b4c9e39..c7b45a5 100644 --- a/action-scripts/label-dispatcher/README-ja.md +++ b/action-scripts/label-dispatcher/README-ja.md @@ -86,7 +86,7 @@ bundle exec ruby label-dispatcher/bin/dispatcher help_usage ## 変更パスの照合 -変更ファイルを定義された stack のパスに照合し、サービス・環境・任意 placeholder を抽出します。パスに環境名がない場合は、その stack の定義環境ごとに評価します。環境共通の stack は environment が null の照合として扱います。 +変更ファイルを定義された stack のパスに照合し、サービス・環境・任意 placeholder を抽出します。`environments` を持つ stack は定義環境だけを受け付け、パスに環境名がない場合は定義環境ごとに評価します。両方の属性マップがない stack は変更パスから抽出した環境名を使います。`attributes` を持つ stack は `environment: null` の照合として扱います。 各照合に除外条件を適用し、除外されない照合が一つでもあるサービスを一度だけラベル対象にします。サービス全体のディレクトリを検出対象にする場合は、そのパスも stack に定義します。削除されたファイルも照合するため、ディレクトリの存在は要求しません。 diff --git a/action-scripts/label-dispatcher/README.md b/action-scripts/label-dispatcher/README.md index b5768e0..b0b9ef5 100644 --- a/action-scripts/label-dispatcher/README.md +++ b/action-scripts/label-dispatcher/README.md @@ -86,7 +86,7 @@ The dispatcher sets the following environment variables for GitHub Actions: ## Change Matching -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`. +Matches changed files against configured stack paths and extracts services, environments, and arbitrary placeholders. A stack with `environments` accepts only its declared names; a path without an environment name is evaluated for each declared environment. A stack with neither attribute map uses the environment captured from the changed path. A stack with `attributes` is evaluated with `environment: null`. 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. 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 5a6e1bf..eefae34 100644 --- a/action-scripts/label-dispatcher/use_cases/detect_changed_services.rb +++ b/action-scripts/label-dispatcher/use_cases/detect_changed_services.rb @@ -17,7 +17,13 @@ def execute(base_ref: nil, head_ref: nil) next unless captures service = captures.fetch('service') next if service.start_with?('.') - environments = stack.key?('environments') ? stack['environments'].keys : [nil] + environments = if stack.key?('environments') + stack['environments'].keys + elsif stack.key?('attributes') + [nil] + else + [captures.fetch('environment')] + end environments &= [captures['environment']] if captures.key?('environment') directory = Entities::PatternMatcher.expand(pattern, captures) custom = captures.reject { |key, _| %w[service environment].include?(key) } diff --git a/action-scripts/label-resolver/README-ja.md b/action-scripts/label-resolver/README-ja.md index 7cbf80e..77a456c 100644 --- a/action-scripts/label-resolver/README-ja.md +++ b/action-scripts/label-resolver/README-ja.md @@ -80,7 +80,7 @@ bundle exec ruby label-resolver/bin/resolver test 456 develop,staging bundle exec ruby label-resolver/bin/resolver debug 789 production ``` -定義された全環境のデプロイ対象を解決します。 +定義環境と探索した環境すべてのデプロイ対象を解決します。 ```bash bundle exec ruby label-resolver/bin/resolver resolve 123 @@ -169,12 +169,12 @@ bundle exec ruby label-resolver/bin/resolver resolve 123 - 環境はパラメータとして直接指定 - 環境決定にブランチ名への依存なし -- 設定で定義された任意のデプロイメント環境をサポート +- 設定で定義した環境と stack のパスから探索した環境をサポート - 複数環境への同時デプロイメントが可能 ## デプロイ対象の解決 -指定環境を各 stack の定義環境に絞り、全パスの実在ディレクトリを列挙します。環境指定の省略または空白入力は全定義環境を選択します。環境共通の対象は各ディレクトリにつき一度だけ生成します。 +指定環境を各 stack の定義環境または探索した環境から選び、全パスの実在ディレクトリを列挙します。環境指定の省略または空白入力は定義環境と探索した環境の和集合を選択します。`attributes` を持つ環境共通の対象は各ディレクトリにつき一度だけ生成します。 `deploy:all` は設定したパスから全サービスを探索します。除外条件に一致する対象は matrix に含めません。存在しないパスは正常な空の結果になり、未知環境・列挙エラー・抽出値の矛盾は失敗になります。 diff --git a/action-scripts/label-resolver/README.md b/action-scripts/label-resolver/README.md index d4ee5d6..82bb1c3 100644 --- a/action-scripts/label-resolver/README.md +++ b/action-scripts/label-resolver/README.md @@ -80,7 +80,7 @@ Debug production deployment. bundle exec ruby label-resolver/bin/resolver debug 789 production ``` -Resolve deployment targets for all configured environments. +Resolve deployment targets for all declared and discovered environments. ```bash bundle exec ruby label-resolver/bin/resolver resolve 123 @@ -169,12 +169,12 @@ 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 environments declared in configuration or discovered from stack paths - Supports targeting multiple environments in one invocation ## Target Resolution -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. +Selects requested environments from each stack's declared or discovered environments and enumerates existing directories across all paths. Omitting the environment selection or providing only whitespace selects the union of declared and discovered names. Targets with `attributes` are shared across environments and generated once per directory. `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. diff --git a/action-scripts/label-resolver/application.rb b/action-scripts/label-resolver/application.rb index 3cd083f..7f33f2f 100644 --- a/action-scripts/label-resolver/application.rb +++ b/action-scripts/label-resolver/application.rb @@ -43,7 +43,8 @@ def self.build_container # Use cases container[:determine_target_environment] = UseCases::LabelResolver::DetermineTargetEnvironment.new( - config_client: container[:config_client] + config_client: container[:config_client], + file_client: container[:file_client] ) if container[:github_client] 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 b688574..f5aeece 100644 --- a/action-scripts/label-resolver/use_cases/determine_target_environment.rb +++ b/action-scripts/label-resolver/use_cases/determine_target_environment.rb @@ -1,14 +1,16 @@ module UseCases module LabelResolver class DetermineTargetEnvironment - def initialize(config_client:) + def initialize(config_client:, file_client:) @config_client = config_client + @file_client = file_client end 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 + available = @file_client.environment_names(config: config) + environments = target_environments.nil? || target_environments.empty? ? available : target_environments.uniq + unknown = environments - available return Entities::Result.failure(error_message: "Target environments not found in configuration: #{unknown.join(', ')}") unless unknown.empty? Entities::Result.success(target_environments: environments) diff --git a/action-scripts/label-resolver/use_cases/generated_matrix.rb b/action-scripts/label-resolver/use_cases/generated_matrix.rb index de837dd..33b11db 100644 --- a/action-scripts/label-resolver/use_cases/generated_matrix.rb +++ b/action-scripts/label-resolver/use_cases/generated_matrix.rb @@ -8,15 +8,22 @@ def initialize(config_client:, file_client:) def execute(deploy_labels:, 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 + available = @file_client.environment_names(config: config) + environments = target_environments.nil? || target_environments.empty? ? available : target_environments.uniq + unknown = environments - available 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 = if stack.key?('environments') + environments & stack['environments'].keys + elsif stack.key?('attributes') + [nil] + else + environments + end selected.each do |environment| services.each do |service| values = { 'environment' => environment } @@ -44,7 +51,7 @@ def execute(deploy_labels:, target_environments:) 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'] + attributes = stack.key?('environments') ? stack['environments'].fetch(environment) : stack.fetch('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 diff --git a/action-scripts/shared/entities/deployment_target.rb b/action-scripts/shared/entities/deployment_target.rb index 9cd995d..d3e08a6 100644 --- a/action-scripts/shared/entities/deployment_target.rb +++ b/action-scripts/shared/entities/deployment_target.rb @@ -20,7 +20,9 @@ def initialize(service:, stack:, stack_id:, working_directory:, environment: nil @stack = stack @stack_id = stack_id @working_directory = working_directory - @attributes = attributes.dup.freeze + values = captures.transform_keys(&:to_s).merge('service' => service) + values['environment'] = environment unless environment.nil? + @attributes = expand_attributes(attributes, values).freeze @captures = captures.dup.freeze end @@ -39,5 +41,20 @@ def hash end alias eql? == + + private + + def expand_attributes(value, values) + case value + when String + PatternMatcher.expand(value, values) + when Hash + value.transform_values { |nested| expand_attributes(nested, values) } + when Array + value.map { |nested| expand_attributes(nested, values) } + else + value + end + end end end diff --git a/action-scripts/shared/entities/workflow_config.rb b/action-scripts/shared/entities/workflow_config.rb index fe43713..b072caa 100644 --- a/action-scripts/shared/entities/workflow_config.rb +++ b/action-scripts/shared/entities/workflow_config.rb @@ -22,8 +22,8 @@ def initialize(config_hash) ) if stack.key?('environments') normalized['environments'] = stack['environments'].transform_values(&:dup) - else - normalized['attributes'] = stack.fetch('attributes', {}).dup + elsif stack.key?('attributes') + normalized['attributes'] = stack['attributes'].dup end normalized end @@ -57,8 +57,8 @@ def validate_stack!(stack, position) validate_attributes!(attributes, "#{position}.environments.#{name}") attribute_keys.concat(attributes.keys) end - else - attributes = stack.fetch('attributes', {}) + elsif stack.key?('attributes') + attributes = stack['attributes'] validate_attributes!(attributes, "#{position}.attributes") attribute_keys.concat(attributes.keys) end @@ -73,12 +73,22 @@ def validate_stack!(stack, position) 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') + if stack.key?('attributes') && names.include?('environment') + invalid!(path_position, '{environment} cannot be used with common attributes') + elsif !stack.key?('attributes') && !stack.key?('environments') && !names.include?('environment') + invalid!(path_position, 'environment discovery requires {environment}; common targets require attributes: {}') 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 + available = stack.key?('environments') ? names + ['environment'] : names + if stack.key?('environments') + stack['environments'].each do |environment, attributes| + validate_attribute_templates!(attributes, "#{position}.environments.#{environment}", available, path_position) + end + elsif stack.key?('attributes') + validate_attribute_templates!(stack['attributes'], "#{position}.attributes", available, path_position) + end placeholders.concat(names) end @@ -101,8 +111,10 @@ def validate_exclusions!(stack, position, placeholders) unless stack['environments'].key?(value) invalid!(condition_position, "environment '#{value}' is not declared in this stack") end - elsif !value.nil? - invalid!(condition_position, 'common targets require a null environment') + elsif stack.key?('attributes') + invalid!(condition_position, 'common targets require a null environment') unless value.nil? + else + validate_segment!(value, condition_position) end else validate_segment!(value, condition_position) @@ -119,6 +131,18 @@ def validate_attributes!(attributes, position) end end + def validate_attribute_templates!(value, position, available, path_position) + case value + when String + unknown = PatternMatcher.placeholders(value) - available + invalid!(position, "placeholders #{unknown.join(', ')} cannot be resolved by #{path_position}") unless unknown.empty? + when Hash + value.each { |key, nested| validate_attribute_templates!(nested, "#{position}.#{key}", available, path_position) } + when Array + value.each_with_index { |nested, index| validate_attribute_templates!(nested, "#{position}[#{index}]", available, path_position) } + end + end + def validate_map!(value, position, allowed: nil) invalid!(position, 'must be a map') unless value.is_a?(Hash) value.each_key do |key| diff --git a/action-scripts/shared/infrastructure/file_system_client.rb b/action-scripts/shared/infrastructure/file_system_client.rb index 3ee83a3..84dcf67 100644 --- a/action-scripts/shared/infrastructure/file_system_client.rb +++ b/action-scripts/shared/infrastructure/file_system_client.rb @@ -69,6 +69,16 @@ def resolve_directories(pattern:, values: {}) candidates.sort_by { |match| match.fetch(:working_directory) } end + def environment_names(config:) + discovered = config.stacks.flat_map do |stack| + next [] if stack.key?('environments') || stack.key?('attributes') + stack['paths'].flat_map do |pattern| + resolve_directories(pattern: pattern).map { |match| match.fetch(:captures).fetch('environment') } + end + end + (config.environment_names + discovered).uniq + end + private # Get the source repository path for composite actions diff --git a/action-scripts/spec/config-manager/cli_spec.rb b/action-scripts/spec/config-manager/cli_spec.rb index 6ad63b6..89525ee 100644 --- a/action-scripts/spec/config-manager/cli_spec.rb +++ b/action-scripts/spec/config-manager/cli_spec.rb @@ -1,5 +1,7 @@ require 'spec_helper' require 'thor' +require 'tmpdir' +require 'fileutils' RSpec.describe 'ConfigManagerCLI' do before(:all) { load File.expand_path('../../config-manager/bin/config-manager', __dir__) } @@ -20,9 +22,26 @@ ]) client = instance_double(Infrastructure::ConfigClient, load_workflow_config: config) allow(ConfigManagerContainer).to receive(:resolve).with(:config_client).and_return(client) + allow(ConfigManagerContainer).to receive(:resolve).with(:file_client).and_return(Infrastructure::FileSystemClient.new) expect { ConfigManagerCLI.new.environments }.to output(/production.*preview.*sandbox/m).to_stdout end + it 'lists environment names discovered from paths' do + original = ENV['SOURCE_REPO_PATH'] + Dir.mktmpdir do |root| + FileUtils.mkdir_p(File.join(root, '.git')) + FileUtils.mkdir_p(File.join(root, 'apps/demo/preview')) + ENV['SOURCE_REPO_PATH'] = root + config = Entities::WorkflowConfig.new('stacks' => [{ 'name' => 'kubernetes', 'paths' => ['apps/{service}/{environment}'] }]) + client = instance_double(Infrastructure::ConfigClient, load_workflow_config: config) + allow(ConfigManagerContainer).to receive(:resolve).with(:config_client).and_return(client) + allow(ConfigManagerContainer).to receive(:resolve).with(:file_client).and_return(Infrastructure::FileSystemClient.new) + expect { ConfigManagerCLI.new.environments }.to output(/preview/).to_stdout + end + ensure + ENV['SOURCE_REPO_PATH'] = original + 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) 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 cbc153a..f9b0162 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 @@ -187,12 +187,28 @@ def matches(environment: nil, service: 'demo') expect(rows.first[:excluded]).to be(true) end + it 'diagnoses inferred environments and renders common attribute templates' do + config_hash['stacks'] = [ + { 'name' => 'kubernetes', 'paths' => ['teams/{team}/{service}/{environment}'], 'exclude' => [{ 'environment' => 'production' }] }, + { 'name' => 'container', 'paths' => ['teams/{team}/{service}'], 'attributes' => { 'repository' => 'ghcr.io/{team}/{service}' } } + ] + %w[teams/payments/demo/production teams/payments/demo/preview].each { |path| directory(path) } + rows = matches(environment: 'production') + expect(rows.length).to eq(2) + expect(rows.find { |row| row[:target].stack == 'kubernetes' }).to include(excluded: true) + expect(rows.find { |row| row[:target].stack == 'container' }[:target].attributes).to eq('repository' => 'ghcr.io/payments/demo') + expect(presenter).to receive(:present_service_test_result) do |args| + expect(args[:matches].map { |row| row[:target].environment }).to contain_exactly('production', 'preview', nil) + end + controller.test_service_configuration(service_name: 'demo') + end + it 'returns an empty list when no directory matches' do expect(matches(service: 'unregistered')).to eq([]) end it 'deduplicates identical matches' do - config_hash['stacks'] = [{ 'name' => 'container', 'paths' => ['dystopia/{service}', 'dystopia/{service}'] }] + config_hash['stacks'] = [{ 'name' => 'container', 'paths' => ['dystopia/{service}', 'dystopia/{service}'], 'attributes' => {} }] directory('dystopia/demo') expect(matches.length).to eq(1) end @@ -205,7 +221,7 @@ def matches(environment: nil, service: 'demo') end it 'reports conflicting captures before exclusions' do - config_hash['stacks'] = [{ 'name' => 'container', 'paths' => ['teams/{team}/{service}', 'teams/{product}/{service}'], 'exclude' => [{ 'team' => 'platform' }] }] + config_hash['stacks'] = [{ 'name' => 'container', 'paths' => ['teams/{team}/{service}', 'teams/{product}/{service}'], 'attributes' => {}, 'exclude' => [{ 'team' => 'platform' }] }] directory('teams/platform/demo') allow(presenter).to receive(:present_error) expect(presenter).not_to receive(:present_service_test_result) 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 640450f..7b9cd80 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 @@ -19,11 +19,19 @@ end it 'accepts configurations with only common stacks' do - file.write({ 'stacks' => [{ 'name' => 'container', 'paths' => ['apps/{service}'] }] }.to_yaml) + file.write({ 'stacks' => [{ 'name' => 'container', 'paths' => ['apps/{service}'], 'attributes' => {} }] }.to_yaml) file.flush expect(use_case.execute).to be_success end + it 'validates inferred environments without requiring matching directories' do + file.write({ 'stacks' => [{ 'name' => 'kubernetes', 'paths' => ['absent/{service}/{environment}'] }] }.to_yaml) + file.flush + result = use_case.execute + expect(result).to be_success + expect(result.validation_summary).to include('environments: 0') + end + it 'returns the original configuration error in validation errors' do file.write("stacks:\n - name: container\n paths: []\n") file.flush 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 4555ff0..caba8dd 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 @@ -47,7 +47,7 @@ def services end it 'keeps services with a non-excluded stack match' do - config_hash['stacks'] << { 'name' => 'container', 'paths' => ['teams/{team}/{service}'] } + config_hash['stacks'] << { 'name' => 'container', 'paths' => ['teams/{team}/{service}'], 'attributes' => {} } expect(services).to eq(['demo']) end @@ -58,7 +58,7 @@ def services end it 'detects common stacks with null environment' do - config_hash['stacks'] = [{ 'name' => 'container', 'paths' => ['dystopia/{service}'], 'exclude' => [{ 'service' => 'demo', 'environment' => nil }] }] + config_hash['stacks'] = [{ 'name' => 'container', 'paths' => ['dystopia/{service}'], 'attributes' => {}, 'exclude' => [{ 'service' => 'demo', 'environment' => nil }] }] changed_files.replace(['dystopia/demo/main.rb', 'dystopia/api/main.rb']) expect(services).to eq(['api']) end @@ -75,6 +75,14 @@ def services expect(services).to eq(['unregistered']) end + it 'captures inferred environments from changed paths including deleted files' do + stack.delete('environments') + stack['exclude'] = [{ 'environment' => 'production' }] + changed_files.replace(['teams/platform/demo/aws/production/deleted.tf', 'teams/platform/api/aws/preview/deleted.tf']) + expect(file_client).not_to receive(:resolve_directories) + expect(services).to eq(['api']) + end + 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']) @@ -90,7 +98,7 @@ def services 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' }] }] + config_hash['stacks'] = [{ 'name' => 'terragrunt', 'paths' => paths, 'attributes' => {}, 'exclude' => [{ 'team' => 'platform' }] }] result = use_case.execute expect(result).to be_failure expect(result.error_message).to include('Conflicting captures', 'teams/platform/demo/aws') 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 d59f236..196bb8c 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,9 +1,11 @@ require 'spec_helper' +require 'tmpdir' +require 'fileutils' RSpec.describe UseCases::LabelResolver::DetermineTargetEnvironment do let(:config) { build(:workflow_config) } let(:client) { instance_double(Infrastructure::ConfigClient, load_workflow_config: config) } - subject(:use_case) { described_class.new(config_client: client) } + subject(:use_case) { described_class.new(config_client: client, file_client: Infrastructure::FileSystemClient.new) } [nil, []].each do |input| it "selects all declared environments for #{input.inspect}" do @@ -25,11 +27,30 @@ end it 'selects no environment for a common-only configuration' do - config = Entities::WorkflowConfig.new('stacks' => [{ 'name' => 'container', 'paths' => ['dystopia/{service}'] }]) + config = Entities::WorkflowConfig.new('stacks' => [{ 'name' => 'container', 'paths' => ['dystopia/{service}'], 'attributes' => {} }]) allow(client).to receive(:load_workflow_config).and_return(config) expect(use_case.execute(target_environments: nil).target_environments).to eq([]) end + it 'accepts discovered environments and combines them with declared names' do + original = ENV['SOURCE_REPO_PATH'] + Dir.mktmpdir do |root| + FileUtils.mkdir_p(File.join(root, '.git')) + FileUtils.mkdir_p(File.join(root, 'apps/demo/preview')) + ENV['SOURCE_REPO_PATH'] = root + discovered = Entities::WorkflowConfig.new('stacks' => [ + { 'name' => 'aws', 'paths' => ['{service}/aws'], 'environments' => { 'production' => {} } }, + { 'name' => 'kubernetes', 'paths' => ['apps/{service}/{environment}'] } + ]) + allow(client).to receive(:load_workflow_config).and_return(discovered) + expect(use_case.execute(target_environments: nil).target_environments).to eq(%w[production preview]) + expect(use_case.execute(target_environments: ['preview']).target_environments).to eq(['preview']) + expect(use_case.execute(target_environments: ['unknown'])).to be_failure + end + ensure + ENV['SOURCE_REPO_PATH'] = original + 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') 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 005b3be..ac925d6 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 @@ -64,6 +64,60 @@ def items(**options) expect(items.map { |row| [row[:stack], row[:environment]] }).to contain_exactly(['terragrunt', 'develop'], ['kubernetes', 'production']) end + it 'discovers environments independently from every configured path' do + config_hash['stacks'] = [ + { 'name' => 'kubernetes', 'paths' => ['teams/{team}/{service}/k8s/{environment}', 'components/{service}/overlays/{environment}'], 'exclude' => [{ 'team' => 'sandbox' }] } + ] + %w[teams/payments/demo/k8s/production teams/payments/demo/k8s/preview teams/sandbox/demo/k8s/production components/demo/overlays/staging teams/payments/.hidden/k8s/secret].each { |path| directory(path) } + expect(items.map { |row| [row[:environment], row[:working_directory]] }).to contain_exactly( + ['production', 'teams/payments/demo/k8s/production'], ['preview', 'teams/payments/demo/k8s/preview'], ['staging', 'components/demo/overlays/staging'] + ) + expect(items(environments: ['preview'])).to eq([ + { service: 'demo', environment: 'preview', stack: 'kubernetes', stack_id: 'kubernetes', working_directory: 'teams/payments/demo/k8s/preview', team: 'payments' } + ]) + end + + it 'combines declared and discovered environments without inheriting stack exclusions' do + config_hash['stacks'] = [ + { 'name' => 'container', 'paths' => ['dystopia/{service}'], 'attributes' => { 'repository' => 'ghcr.io/{service}' } }, + { 'name' => 'terragrunt', 'id' => 'aws', 'paths' => ['dystopia/{service}/aws/{environment}'], 'environments' => { 'production' => {} }, 'exclude' => [{ 'environment' => 'production' }] }, + { 'name' => 'kubernetes', 'paths' => ['dystopia/{service}/overlays/{environment}'] } + ] + %w[dystopia/demo/aws/production dystopia/demo/overlays/production dystopia/api/overlays/preview].each { |path| directory(path) } + rows = items(labels: ['deploy:all']) + expect(rows.map { |row| [row[:service], row[:stack], row[:environment]] }).to contain_exactly( + ['demo', 'container', nil], ['api', 'container', nil], ['demo', 'kubernetes', 'production'], ['api', 'kubernetes', 'preview'] + ) + expect(rows.select { |row| row[:stack] == 'container' }.map { |row| row[:repository] }).to contain_exactly('ghcr.io/demo', 'ghcr.io/api') + end + + it 'expands environment attributes from the selected directory captures' do + config_hash['stacks'] = [{ 'name' => 'terragrunt', 'paths' => ['teams/{team}/{service}/{environment}'], 'environments' => { 'production' => { 'role' => 'arn:aws:iam::123456789012:role/{team}-{service}-{environment}' } } }] + directory('teams/payments/demo/production') + expect(items.first).to include(role: 'arn:aws:iam::123456789012:role/payments-demo-production') + end + + it 'expands common repository attributes from arbitrary path captures' do + config_hash['stacks'] = [{ 'name' => 'container', 'paths' => ['teams/{team}/{service}'], 'attributes' => { 'repository' => 'ghcr.io/{team}/{service}' } }] + directory('teams/payments/demo') + expect(items).to eq([ + { service: 'demo', environment: nil, stack: 'container', stack_id: 'container', working_directory: 'teams/payments/demo', repository: 'ghcr.io/payments/demo', team: 'payments' } + ]) + end + + it 'expands environment attributes when the path has no environment placeholder' do + config_hash['stacks'] = [{ 'name' => 'terragrunt', 'paths' => ['teams/{team}/{service}'], 'environments' => { + 'production' => { 'role' => '{team}-{service}-{environment}' }, 'preview' => { 'role' => '{team}-{service}-{environment}' } + } }] + directory('teams/payments/demo') + expect(items.map { |row| [row[:environment], row[:role]] }).to contain_exactly(['production', 'payments-demo-production'], ['preview', 'payments-demo-preview']) + end + + it 'returns empty targets when inferred directories are absent' do + config_hash['stacks'] = [{ 'name' => 'kubernetes', 'paths' => ['dystopia/{service}/overlays/{environment}'] }] + expect(generate.data).to eq(deployment_targets: [], has_deployments: false, total_targets: 0) + end + it 'reuses an environment-less directory across its declared environments' do config_hash['stacks'].first['paths'] = ['dystopia/{service}/aws'] directory('dystopia/demo/aws') @@ -85,7 +139,7 @@ def items(**options) end it 'resolves every arbitrary placeholder value and exposes captures' do - config_hash['stacks'] = [{ 'name' => 'container', 'paths' => ['teams/{team}/{service}'] }] + config_hash['stacks'] = [{ 'name' => 'container', 'paths' => ['teams/{team}/{service}'], 'attributes' => {} }] 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 @@ -110,19 +164,19 @@ def items(**options) end it 'does not match missing captures' do - config_hash['stacks'] = [{ 'name' => 'container', 'paths' => ['teams/{team}/{service}', 'dystopia/{service}'], 'exclude' => [{ 'team' => 'sandbox' }] }] + config_hash['stacks'] = [{ 'name' => 'container', 'paths' => ['teams/{team}/{service}', 'dystopia/{service}'], 'attributes' => {}, 'exclude' => [{ 'team' => 'sandbox' }] }] directory('teams/sandbox/demo'); directory('dystopia/demo') expect(items.map { |row| row[:working_directory] }).to eq(['dystopia/demo']) end it 'applies null environment exclusions to common targets' do - config_hash['stacks'] = [{ 'name' => 'container', 'paths' => ['dystopia/{service}'], 'exclude' => [{ 'environment' => nil }] }] + config_hash['stacks'] = [{ 'name' => 'container', 'paths' => ['dystopia/{service}'], 'attributes' => {}, 'exclude' => [{ 'environment' => nil }] }] directory('dystopia/demo') expect(items).to eq([]) end it 'deduplicates identical paths and repeated labels' do - config_hash['stacks'] = [{ 'name' => 'container', 'paths' => ['dystopia/{service}', 'dystopia/{service}'] }] + config_hash['stacks'] = [{ 'name' => 'container', 'paths' => ['dystopia/{service}', 'dystopia/{service}'], 'attributes' => {} }] directory('dystopia/demo') expect(items(labels: ['deploy:demo', 'deploy:demo']).length).to eq(1) end @@ -131,7 +185,7 @@ def items(**options) 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' }] }] + config_hash['stacks'] = [{ 'name' => 'terragrunt', 'paths' => paths, 'attributes' => {}, 'exclude' => [{ 'team' => 'platform' }] }] directory('teams/platform/demo/aws') result = generate expect(result).to be_failure @@ -173,7 +227,7 @@ def items(**options) 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}'] }] + config_hash['stacks'] = [{ 'name' => 'container', 'paths' => ['readable/{service}', 'blocked/{service}'], 'attributes' => {} }] directory('readable/demo'); directory('blocked/demo') path = File.join(@root, 'blocked') File.chmod(0, path) diff --git a/action-scripts/spec/shared/entities/deployment_target_spec.rb b/action-scripts/spec/shared/entities/deployment_target_spec.rb index 5433cb8..6192ce3 100644 --- a/action-scripts/spec/shared/entities/deployment_target_spec.rb +++ b/action-scripts/spec/shared/entities/deployment_target_spec.rb @@ -8,6 +8,25 @@ expect(target.to_matrix_item).to eq(params.merge(environment: nil, token: nil, enabled: true, count: 2, team: 'platform')) end + it 'expands service and arbitrary placeholders in common attributes' do + attributes = { 'repository' => 'ghcr.io/{team}/{service}', 'tag' => '{service}-{service}' } + target = described_class.new(**params, attributes: attributes, captures: { 'team' => 'payments' }) + expect(target.to_matrix_item).to include(repository: 'ghcr.io/payments/demo', tag: 'demo-demo', team: 'payments', environment: nil) + expect(attributes).to eq('repository' => 'ghcr.io/{team}/{service}', 'tag' => '{service}-{service}') + end + + it 'expands nested environment attributes without changing value types' do + attributes = { 'settings' => { 'images' => ['ghcr.io/{team}/{service}:{environment}', 3, nil, false] }, 'enabled' => true } + target = described_class.new(**params, environment: 'production', attributes: attributes, captures: { 'team' => 'payments' }) + expect(target.attributes).to eq('settings' => { 'images' => ['ghcr.io/payments/demo:production', 3, nil, false] }, 'enabled' => true) + expect(attributes['settings']['images'].first).to eq('ghcr.io/{team}/{service}:{environment}') + end + + it 'rejects unresolved attribute placeholders' do + expect { described_class.new(**params, attributes: { 'repository' => 'ghcr.io/{team}/{service}' }) }.to raise_error(Entities::UnresolvedPlaceholderError, /team/) + expect { described_class.new(**params, attributes: { 'tag' => '{environment}' }) }.to raise_error(Entities::UnresolvedPlaceholderError, /environment/) + 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/) diff --git a/action-scripts/spec/shared/entities/workflow_config_spec.rb b/action-scripts/spec/shared/entities/workflow_config_spec.rb index 130b784..4a70563 100644 --- a/action-scripts/spec/shared/entities/workflow_config_spec.rb +++ b/action-scripts/spec/shared/entities/workflow_config_spec.rb @@ -12,7 +12,7 @@ subject(:config) { described_class.new(config_hash) } it 'resolves omitted identities and environment names' do - config_hash['stacks'] << { 'name' => 'container', 'paths' => ['dystopia/{service}'] } + config_hash['stacks'] << { 'name' => 'container', 'paths' => ['dystopia/{service}'], 'attributes' => {} } 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({}) @@ -47,6 +47,50 @@ expect { config }.not_to raise_error end + it 'accepts environment discovery without an environment or attribute map' do + stack.delete('environments') + stack['exclude'] = [{ 'environment' => 'preview' }] + expect { config }.not_to raise_error + expect(config.stacks.first).not_to have_key('attributes') + expect(config.stacks.first).not_to have_key('environments') + end + + [nil, '', 1, [], 'a/b', '.', '..'].each do |value| + it "rejects invalid inferred environment conditions #{value.inspect}" do + stack.delete('environments') + stack['exclude'] = [{ 'environment' => value }] + expect { config }.to raise_error(ArgumentError, /stacks\[0\].exclude\[0\].environment/) + end + end + + it 'requires environment placeholders when neither attribute map is specified' do + stack.delete('environments') + stack['paths'] = ['dystopia/{service}/aws'] + expect { config }.to raise_error(ArgumentError, /requires.*environment/) + end + + it 'requires an environment placeholder in every inferred path' do + stack.delete('environments') + stack['paths'] << 'other/{service}/aws' + expect { config }.to raise_error(ArgumentError, /paths\[1\]/) + end + + it 'rejects attribute templates that cannot be resolved by every path' do + stack['paths'] << 'teams/{team}/{service}/aws/{environment}' + stack['environments']['develop']['repository'] = 'ghcr.io/{team}/{service}' + expect { config }.to raise_error(ArgumentError, /team/) + end + + it 'rejects environment templates for common attributes' do + stack.replace('name' => 'container', 'paths' => ['dystopia/{service}'], 'attributes' => { 'repository' => 'ghcr.io/{service}/{environment}' }) + expect { config }.to raise_error(ArgumentError, /environment/) + end + + it 'validates placeholders inside nested attribute values' do + stack['environments']['develop']['settings'] = { 'images' => ['ghcr.io/{unknown}/{service}'] } + expect { config }.to raise_error(ArgumentError, /unknown/) + end + it 'matches all conditions in a rule and any rule in the array' do stack['paths'] = ['{team}/{service}/aws/{environment}'] stack['exclude'] = [ @@ -91,7 +135,7 @@ end context 'with common targets' do - let(:stack) { { 'name' => 'container', 'paths' => ['dystopia/{service}'] } } + let(:stack) { { 'name' => 'container', 'paths' => ['dystopia/{service}'], 'attributes' => {} } } it 'derives an empty environment list' do expect(config.environment_names).to eq([]) @@ -196,7 +240,7 @@ end it 'does not permit placeholders declared in another stack as exclusion keys' do - config_hash['stacks'] << { 'name' => 'container', 'paths' => ['teams/{team}/{service}'] } + config_hash['stacks'] << { 'name' => 'container', 'paths' => ['teams/{team}/{service}'], 'attributes' => {} } stack['exclude'] = [{ 'team' => 'sandbox' }] expect { config }.to raise_error(ArgumentError, /stacks\[0\].exclude\[0\].team/) end @@ -208,13 +252,13 @@ it 'rejects identities shared by separate definitions' do stack['id'] = 'aws' - config_hash['stacks'] << { 'name' => 'aws', 'paths' => ['other/{service}'] } + config_hash['stacks'] << { 'name' => 'aws', 'paths' => ['other/{service}'], 'attributes' => {} } expect { config }.to raise_error(ArgumentError, /stacks\[1\].id/) end it 'accepts distinct identities for the same stack kind' do stack['id'] = 'aws' - config_hash['stacks'] << { 'name' => 'terragrunt', 'id' => 'stripe', 'paths' => ['other/{service}'] } + config_hash['stacks'] << { 'name' => 'terragrunt', 'id' => 'stripe', 'paths' => ['other/{service}'], 'attributes' => {} } expect(config.stacks.map { |entry| entry['id'] }).to eq(%w[aws stripe]) 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 483ede0..86b3414 100644 --- a/action-scripts/spec/shared/infrastructure/file_system_client_spec.rb +++ b/action-scripts/spec/shared/infrastructure/file_system_client_spec.rb @@ -57,6 +57,33 @@ def directory(path) expect(client.resolve_directories(pattern: 'dystopia/{service}/aws')).to eq([]) end + it 'combines declared and discovered environments without applying exclusions' do + %w[teams/platform/demo/production teams/platform/demo/.preview teams/sandbox/api/production teams/platform/.hidden/hidden].each { |path| directory(path) } + config = Entities::WorkflowConfig.new('stacks' => [ + { 'name' => 'aws', 'paths' => ['{service}/aws'], 'environments' => { 'production' => {}, 'develop' => {} } }, + { 'name' => 'kubernetes', 'paths' => ['teams/{team}/{service}/{environment}'], 'exclude' => [{ 'environment' => '.preview' }] }, + { 'name' => 'container', 'paths' => ['{service}'], 'attributes' => {} } + ]) + expect(client.environment_names(config: config)).to eq(%w[production develop .preview]) + directory('teams/platform/demo/sandbox') + expect(client.environment_names(config: config)).to eq(%w[production develop .preview sandbox]) + end + + it 'returns no environments when inferred directories are absent' do + config = Entities::WorkflowConfig.new('stacks' => [{ 'name' => 'kubernetes', 'paths' => ['apps/{service}/{environment}'] }]) + expect(client.environment_names(config: config)).to eq([]) + end + + it 'propagates permission errors during environment discovery' do + directory('blocked/demo/production') + path = File.join(@root, 'blocked') + File.chmod(0, path) + config = Entities::WorkflowConfig.new('stacks' => [{ 'name' => 'kubernetes', 'paths' => ['blocked/{service}/{environment}'] }]) + expect { client.environment_names(config: config) }.to raise_error(Errno::EACCES) + ensure + File.chmod(0o755, path) if path + end + it 'propagates actual permission errors for literal and placeholder directories' do directory('blocked/demo') path = File.join(@root, 'blocked') diff --git a/action-scripts/workflow-config.yaml b/action-scripts/workflow-config.yaml index 7147c55..fd31712 100644 --- a/action-scripts/workflow-config.yaml +++ b/action-scripts/workflow-config.yaml @@ -24,7 +24,7 @@ stacks: - "dystopia/{service}" - "system-components/{service}" attributes: - repository: registry.example.com/app + repository: registry.example.com/{service} exclude: - service: demo - name: kubernetes diff --git a/docs/superpowers/specs/stack-configuration-design.md b/docs/superpowers/specs/stack-configuration-design.md index 973fa48..0cd750b 100644 --- a/docs/superpowers/specs/stack-configuration-design.md +++ b/docs/superpowers/specs/stack-configuration-design.md @@ -35,7 +35,7 @@ stacks: - "dystopia/{service}" - "system-components/{service}" attributes: - repository: registry.example.com/app + repository: registry.example.com/{service} ``` ### Stack Identity @@ -56,16 +56,28 @@ stacks: `environments` のキーが環境名であり、その値が当該環境の属性である。キーは空でない文字列、値は属性のマップとする。環境名は一つのパス要素として利用できる値とし、`/` を含む値と、`.` または `..` そのものを拒否する。環境に属性が不要なら空のマップを指定できる。 -`environments` を持つ stack は、定義した環境だけで実行する。全体の環境一覧は各 stack の環境名の和集合から導出する。全体の環境一覧を設定には登録せず、環境属性の識別には `environments` のキーを使う。 +`environments` を持つ stack は、定義した環境だけで実行する。環境属性の識別には `environments` のキーを使う。 実行単位は環境の定義で決まる。環境を持つ stack のパスに `{environment}` がなくても、そのディレクトリを環境ごとに異なる属性で実行できる。 ### Shared Attributes -`environments` を持たない stack は環境共通である。属性は任意の `attributes` マップに置き、省略時は空のマップとして扱う。環境選択数にかかわらず、実在するディレクトリごとに一度実行する。 +`attributes` を明示した stack は環境共通である。属性が不要な場合も `attributes: {}` を指定する。環境選択数にかかわらず、実在するディレクトリごとに一度実行する。 `attributes` と `environments` は併用しない。環境共通の属性と環境属性を暗黙にマージする規則は設けない。環境共通の stack のパスに `{environment}` がある場合は設定エラーとする。 +### Environment Discovery + +`attributes` と `environments` の両方を持たない stack は環境別の対象である。全パスに `{environment}` を要求し、実在する一致ディレクトリから環境名を抽出する。環境の属性は空のマップで、他 stack の環境・属性・除外規則は継承しない。 + +選択可能な環境一覧は、全 stack の定義環境と抽出した環境の和集合である。除外規則は環境一覧の生成には適用しない。設定読み込み時にはディレクトリを列挙せず、環境選択・matrix 生成・環境一覧・サービス診断の実行時に列挙するため、設定を再利用してもディレクトリの増減を反映する。 + +### Attribute Values + +共通属性と環境属性の文字列値では `{service}` とパスで取得した任意 placeholder を展開する。環境属性では、パスに含まれない場合も選択した環境名で `{environment}` を展開する。共通属性は `{environment}` を参照できない。 + +マップ・配列内の文字列も再帰的に展開し、マップのキーと文字列以外の値は保持する。元の設定マップは変更しない。各参照は当該 stack のすべてのパスで解決できることを設定検証で要求する。別の属性値を参照する規則は設けない。 + ### Product Boundaries 属性セットを共有するプロダクトのパスは一つの stack 定義に集める。除外規則はその定義内で管理し、placeholder の条件で対象を絞り込める。属性セットや設定の管理単位が独立する場合は、同じ `name` を持つ別の定義にする。 @@ -101,7 +113,7 @@ stacks: `service` と `environment` は実行対象として確定した値を照合する。パスに `{environment}` がなくても環境別の除外ができる。任意の placeholder は、照合したパスから抽出した値を使う。当該パスから条件のキーに対応する値を抽出できなかった場合は、その規則には一致しない。 -条件値は空でない文字列とし、`/` を含む値と、`.` または `..` そのものを拒否する。`service` はさらに `.` で始まる値を拒否する。環境を持つ stack では `environment` に当該 stack に定義された環境名を指定する。環境共通の stack では `environment` に `null` だけを指定でき、条件から省略することもできる。`null` を許容するキーは `environment` だけとする。サービスや任意の placeholder の値がまだディレクトリに存在しない場合も、規則の定義は有効である。 +条件値は空でない文字列とし、`/` を含む値と、`.` または `..` そのものを拒否する。`service` はさらに `.` で始まる値を拒否する。`environments` を持つ stack では `environment` に当該 stack に定義された環境名を指定する。環境を探索する stack では、まだ存在しない環境名も条件にできる。環境共通の stack では `environment` に `null` だけを指定でき、条件から省略することもできる。`null` を許容するキーは `environment` だけとする。サービスや任意の placeholder の値がまだディレクトリに存在しない場合も、規則の定義は有効である。条件値はリテラルとして照合し、placeholder は展開しない。 ```yaml stacks: @@ -132,6 +144,8 @@ stacks: | `ConfigClient` | 設定ファイルを読み、YAML を解析して設定モデルへ渡す。ファイル読み込みと YAML 構文のエラーを伝える。 | | `WorkflowConfig` | スキーマと設定内の整合性を一か所で検証し、stack 定義・環境名・当該 stack の確定した値に基づく除外判定を提供する。 | | `PatternMatcher` | placeholder の文法、照合、展開、抽出を扱う。プロダクトや stack の属性を判断しない。 | +| `FileSystemClient` | 実在ディレクトリを照合し、定義環境と探索した環境の和集合を提供する。 | +| `DeploymentTarget` | 対象の確定値で属性値を展開し、matrix 行を生成する。 | | `DetectChangedServices` | 各 stack のパスに変更ファイルを照合し、環境名と任意の placeholder を解決して、除外されない照合があるサービスをラベル対象にする。 | | `DetermineTargetEnvironment` | 環境選択を導出された環境一覧と照合する。 | | `GenerateMatrix` | stack の環境と各パスから実在する実行対象を列挙し、除外規則を適用してその定義の属性を渡す。 | @@ -142,16 +156,16 @@ stacks: ## Runtime Flow 1. 設定ファイルを読み、スキーマと設定内の整合性を検証する。成功時だけ検証済みモデルを利用側へ渡す。 -2. 環境指定を検証する。指定省略時は定義された全環境を選択し、重複した指定は一つにまとめる。未知の環境名があれば処理を失敗させる。 +2. 環境を探索して定義環境と合わせ、環境指定を検証する。指定省略時は和集合の全環境を選択し、重複した指定は一つにまとめる。未知の環境名があれば処理を失敗させる。 3. ラベルのサービス指定を確認する。通常のサービスラベルでは指定した名前でパスを照合し、`deploy:all` では実在するパスからすべてのサービスを取得する。 -4. stack ごとに対象環境を決める。環境を持つ stack は定義された環境と選択された環境の共通部分を使い、環境共通の stack は環境値を `null` として一度処理する。 +4. stack ごとに対象環境を決める。`environments` を持つ stack は定義環境と選択環境の共通部分を使い、探索する stack は選択環境をパスに照合する。`attributes` を持つ stack は環境値を `null` として一度処理する。 5. 各パスの実在するディレクトリを列挙し、サービス名と環境名を確認する。任意の placeholder に複数の値が一致する場合も各ディレクトリを対象にする。存在しないパスは対象を生成しない。 6. 各対象のサービス名・環境名・任意の placeholder の抽出値を当該 stack の除外規則と照合し、一致した対象を除く。通常のサービスラベルと `deploy:all` の双方で同じ規則を適用する。 -7. 各対象へ、その stack 定義の環境属性または環境共通の属性と、パスから抽出した値を渡す。対象の一意性を確認した配列が matrix 生成の完了条件となる。 +7. 各対象の属性値を確定したサービス名・環境名・任意 placeholder の抽出値で展開し、属性と抽出値を渡す。探索する stack の属性は空のマップである。対象の一意性を確認した配列が matrix 生成の完了条件となる。 -環境共通の stack だけを持つ設定も有効である。環境指定を省略した場合は空の環境一覧から環境共通の対象を生成できる。明示的な環境指定は、定義された環境名であることを要求する。 +環境共通の stack だけを持つ設定も有効である。環境指定を省略した場合は空の環境一覧から環境共通の対象を生成できる。明示的な環境指定は、定義または探索した環境名であることを要求する。 -変更検出は各 `paths` が指すディレクトリとその配下を対象にする。サービス全体の変更を検出するには、`dystopia/{service}` のようにサービスディレクトリを指すパスを stack に定義する。`{environment}` を含むパスで定義外の環境を抽出した場合は、その照合をラベル対象にしない。 +変更検出は各 `paths` が指すディレクトリとその配下を対象にする。サービス全体の変更を検出するには、`dystopia/{service}` のようにサービスディレクトリを指すパスを stack に定義する。`environments` を持つ stack で定義外の環境を抽出した場合は、その照合をラベル対象にしない。探索する stack は変更パスから抽出した環境名をそのまま使い、削除済みディレクトリも照合する。 変更ファイルが照合したパスからサービス名と任意の placeholder の値を抽出し、環境名を解決して除外規則を適用する。環境を持つ stack のパスに `{environment}` がない場合は、その stack の定義された環境ごとに判定する。環境共通のパスは環境値を `null` として判定する。条件のキーに対応する抽出値がない場合は、その規則には一致しない。除外されない照合が一つでもあればサービスラベルを生成し、除外された照合しかない場合は生成しない。 @@ -175,6 +189,8 @@ matrix の固定キーは `service`、`environment`、`stack`、`stack_id`、`wo - 各パターンの `{service}`、placeholder の文法、同名 placeholder の一致規則。 - `environments` が指定された場合の空でないマップ、環境名と属性マップの型。`environments: {}` は設定エラーとする。 - `attributes` の型、`environments` との併用、環境共通のパスでの `{environment}` 使用。 +- 両方の属性マップがない stack の全パスに `{environment}` があること。 +- 属性の文字列値で参照する placeholder が当該 stack の全パスで解決できること。入れ子のマップ・配列も検証する。 - 属性キーは空でない文字列であり、matrix の固定キーと衝突しないこと。 - 任意の placeholder 名が matrix の固定キーや当該 stack の属性キーと衝突しないこと。環境属性のキーはその stack の全環境から収集する。 - `exclude` の配列と各規則の空でないマップ。キーが `service`・`environment` または当該 stack の `paths` の placeholder 名であること。 @@ -220,7 +236,7 @@ root ごとのグループ内へ属性を移す形式は、同じ stack が複 1. プロダクトごとに異なるパスが同じ属性を共有し、実在する各パスが対象になる。 2. 同じ `name` の独立した定義が、各識別子の属性を持つ対象になる。 -3. 定義した環境だけを対象にし、未定義の環境でディレクトリが存在しても対象にしない。 +3. `environments` を持つ stack は定義環境だけを対象にし、両方の属性マップがない stack は実在するパスから環境を取得する。 4. 環境共通の対象が、複数環境選択時と環境共通だけの設定の双方で、各ディレクトリにつき一度生成される。その属性も matrix に渡る。 5. 環境を持つ stack のパスに `{environment}` がなくても、環境ごとの対象と属性が得られる。 6. 同名サービスの複数プロダクト、任意の placeholder の複数一致、重複したパスが、対象の識別規則どおりに処理される。 @@ -233,5 +249,7 @@ root ごとのグループ内へ属性を移す形式は、同じ stack が複 13. 旧形式と廃止したフィールドを拒否する。属性キーが環境ごとに異なる設定と、必須キーの宣言がない設定を受け付ける。 14. matrix に固定キー・属性・抽出値が正しく入り、ルート由来の出力を持たない。 15. Config manager の検証・表示・環境一覧・サービス診断・テンプレートが同じ設定モデルを利用する。 +16. 環境共通には `attributes: {}` も含めた明示指定を要求し、両方の属性マップも `{environment}` もない定義を拒否する。 +17. 属性の文字列値でサービス・環境・任意 placeholder を展開し、入れ子の非文字列値と元の設定を保持する。解決できない参照は設定エラーにする。 実装時には対象のテストと全体の RSpec を実行し、サンプル設定を Config manager で検証する。旧モデルの経路と互換コードが利用側に残っていないことを検索と差分レビューで確認する。