Skip to content

Commit 1712951

Browse files
committed
Archive proposal
1 parent f626dc2 commit 1712951

File tree

6 files changed

+108
-31
lines changed

6 files changed

+108
-31
lines changed

openspec/changes/ilm-single-nested-and-action-validation/.openspec.yaml renamed to openspec/changes/archive/2026-03-24-ilm-single-nested-and-action-validation/.openspec.yaml

File renamed without changes.

openspec/changes/ilm-single-nested-and-action-validation/design.md renamed to openspec/changes/archive/2026-03-24-ilm-single-nested-and-action-validation/design.md

File renamed without changes.

openspec/changes/ilm-single-nested-and-action-validation/proposal.md renamed to openspec/changes/archive/2026-03-24-ilm-single-nested-and-action-validation/proposal.md

File renamed without changes.

openspec/changes/ilm-single-nested-and-action-validation/specs/elasticsearch-index-lifecycle/spec.md renamed to openspec/changes/archive/2026-03-24-ilm-single-nested-and-action-validation/specs/elasticsearch-index-lifecycle/spec.md

File renamed without changes.

openspec/changes/ilm-single-nested-and-action-validation/tasks.md renamed to openspec/changes/archive/2026-03-24-ilm-single-nested-and-action-validation/tasks.md

File renamed without changes.

openspec/specs/elasticsearch-index-lifecycle/spec.md

Lines changed: 108 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,12 @@ resource "elasticstack_elasticsearch_index_lifecycle" "example" {
1818
modified_date = <computed, string> # last modification time from the cluster
1919
2020
# At least one of hot, warm, cold, frozen, delete MUST be set (schema: AtLeastOneOf).
21-
# Each phase is a list with MaxItems = 1; the single element is an object (see below).
22-
hot = [<phase_hot>]
23-
warm = [<phase_warm>]
24-
cold = [<phase_cold>]
25-
frozen = [<phase_frozen>]
26-
delete = [<phase_delete>]
21+
# Each phase is a Plugin Framework SingleNestedBlock (at most one block per phase; state stores a single object or null).
22+
hot { /* phase_hot */ }
23+
warm { /* phase_warm */ }
24+
cold { /* phase_cold */ }
25+
frozen { /* phase_frozen */ }
26+
delete { /* phase_delete */ }
2727
2828
elasticsearch_connection {
2929
endpoints = <optional, list(string)>
@@ -44,7 +44,7 @@ resource "elasticstack_elasticsearch_index_lifecycle" "example" {
4444
}
4545
```
4646

47-
In Terraform configuration, each phase is written as a **single nested block** (for example `hot { ... }`), which corresponds to a one-element list in the provider schema.
47+
In Terraform configuration, each phase is written as a **`SingleNestedBlock`** (for example `hot { ... }`). State stores that phase as an object-shaped value (or null when absent), not as a single-element list.
4848

4949
### Per-phase object (common)
5050

@@ -56,7 +56,7 @@ Every phase object MAY include:
5656

5757
### Allowed nested actions by phase
5858

59-
| Phase | Nested action blocks (each is a list with **MaxItems = 1** unless noted) |
59+
| Phase | Nested action blocks (each is a **`SingleNestedBlock`**) |
6060
|-------|-----------------------------------------------------------------------------|
6161
| **hot** | `set_priority`, `unfollow`, `rollover`, `readonly`, `shrink`, `forcemerge`, `searchable_snapshot`, `downsample` |
6262
| **warm** | `set_priority`, `unfollow`, `readonly`, `allocate`, `migrate`, `shrink`, `forcemerge`, `downsample` |
@@ -66,7 +66,7 @@ Every phase object MAY include:
6666

6767
### Nested action block schemas
6868

69-
Each action below is expressed as Terraform nested block syntax. All such blocks are **optional**; each uses **list** semantics with **max 1** element in practice (`action { ... }` is one list element).
69+
Each action below is expressed as Terraform nested block syntax. All such blocks are **optional** and use **`SingleNestedBlock`** semantics (`action { ... }`); state stores each declared action as an object, not as a list.
7070

7171
```hcl
7272
# allocate — warm, cold only
@@ -84,8 +84,9 @@ delete {
8484
}
8585
8686
# forcemerge — hot, warm only
87+
# When the block is omitted, max_num_segments is not required. When the block is declared, max_num_segments is required (object-level AlsoRequires).
8788
forcemerge {
88-
max_num_segments = <required, int, >= 1>
89+
max_num_segments = <optional, int, >= 1> # required when block is present
8990
index_codec = <optional, string>
9091
}
9192
@@ -119,14 +120,16 @@ rollover {
119120
}
120121
121122
# searchable_snapshot — hot, cold, frozen only
123+
# snapshot_repository required when block is present (object-level AlsoRequires).
122124
searchable_snapshot {
123-
snapshot_repository = <required, string>
125+
snapshot_repository = <optional, string> # required when block is present
124126
force_merge_index = <optional, bool, default true>
125127
}
126128
127129
# set_priority — hot, warm, cold only
130+
# priority required when block is present (object-level AlsoRequires).
128131
set_priority {
129-
priority = <required, int, >= 0> # index recovery priority for this phase
132+
priority = <optional, int, >= 0> # required when block is present; index recovery priority for this phase
130133
}
131134
132135
# shrink — hot, warm only
@@ -142,26 +145,28 @@ unfollow {
142145
}
143146
144147
# wait_for_snapshot — delete phase only
148+
# policy required when block is present (object-level AlsoRequires).
145149
wait_for_snapshot {
146-
policy = <required, string> # SLM policy name to wait for
150+
policy = <optional, string> # required when block is present; SLM policy name to wait for
147151
}
148152
149153
# downsample — hot, warm, cold only
154+
# fixed_interval required when block is present (object-level AlsoRequires).
150155
downsample {
151-
fixed_interval = <required, string>
156+
fixed_interval = <optional, string> # required when block is present
152157
wait_timeout = <optional + computed, string> # may be set by the cluster on read
153158
}
154159
```
155160

156161
### Example: fully expanded phase shapes (illustrative)
157162

158-
The `[{ ... }]` form below is equivalent to one nested block per phase (e.g. `hot { min_age = "1h" ... }`).
163+
Each phase is one `SingleNestedBlock` (e.g. `hot { min_age = "1h" ... }`).
159164

160165
```hcl
161-
hot = [{
166+
hot {
162167
min_age = <optional+computed, string>
163168
164-
set_priority { priority = <required int> }
169+
set_priority { priority = <optional int; required when block present> }
165170
unfollow { enabled = <optional bool> }
166171
rollover {
167172
max_age = <optional string>
@@ -174,20 +179,20 @@ The `[{ ... }]` form below is equivalent to one nested block per phase (e.g. `ho
174179
allow_write_after_shrink = <optional bool>
175180
}
176181
forcemerge {
177-
max_num_segments = <required int>
182+
max_num_segments = <optional int; required when block present>
178183
index_codec = <optional string>
179184
}
180185
searchable_snapshot {
181-
snapshot_repository = <required string>
186+
snapshot_repository = <optional string; required when block present>
182187
force_merge_index = <optional bool>
183188
}
184189
downsample {
185-
fixed_interval = <required string>
190+
fixed_interval = <optional string; required when block present>
186191
wait_timeout = <optional+computed string>
187192
}
188-
}]
193+
}
189194
190-
warm = [{
195+
warm {
191196
min_age = <optional+computed, string>
192197
set_priority { ... }
193198
unfollow { ... }
@@ -197,9 +202,9 @@ The `[{ ... }]` form below is equivalent to one nested block per phase (e.g. `ho
197202
shrink { ... }
198203
forcemerge { ... }
199204
downsample { ... }
200-
}]
205+
}
201206
202-
cold = [{
207+
cold {
203208
min_age = <optional+computed, string>
204209
set_priority { ... }
205210
unfollow { ... }
@@ -209,21 +214,21 @@ The `[{ ... }]` form below is equivalent to one nested block per phase (e.g. `ho
209214
migrate { ... }
210215
freeze { ... }
211216
downsample { ... }
212-
}]
217+
}
213218
214-
frozen = [{
219+
frozen {
215220
min_age = <optional+computed, string>
216221
searchable_snapshot {
217-
snapshot_repository = <required string>
222+
snapshot_repository = <optional string; required when block present>
218223
force_merge_index = <optional bool>
219224
}
220-
}]
225+
}
221226
222-
delete = [{
227+
delete {
223228
min_age = <optional+computed, string>
224-
wait_for_snapshot { policy = <required string> }
229+
wait_for_snapshot { policy = <optional string; required when block present> }
225230
delete { delete_searchable_snapshot = <optional bool> }
226-
}]
231+
}
227232
```
228233

229234
## Requirements
@@ -369,3 +374,75 @@ If expansion encounters an action key that is not supported by the provider’s
369374
- GIVEN an internal expansion path surfaces an unknown action name
370375
- WHEN the policy is expanded
371376
- THEN the provider SHALL return a diagnostic
377+
378+
### Requirement: Single nested blocks for phases and actions (REQ-020)
379+
380+
The resource SHALL model each of the phase blocks `hot`, `warm`, `cold`, `frozen`, and `delete` as a **Plugin Framework `SingleNestedBlock`** (at most one block per phase in configuration; state stores a single nested object or null when absent), not as a list nested block with a maximum length of one.
381+
382+
Each ILM action block allowed under a phase (for example `set_priority`, `rollover`, `forcemerge`, `searchable_snapshot`, `wait_for_snapshot`, `delete`, and other actions defined by the provider schema) SHALL likewise be modeled as a **`SingleNestedBlock`**.
383+
384+
The **`elasticsearch_connection`** block SHALL remain a **list nested block** as provided by the shared provider connection schema.
385+
386+
#### Scenario: Phase block cardinality
387+
388+
- GIVEN a Terraform configuration for this resource
389+
- WHEN the user declares a phase (for example `hot { ... }`)
390+
- THEN the schema SHALL allow at most one such block for that phase and SHALL persist that phase as an object-shaped value in state, not as a single-element list
391+
392+
#### Scenario: Action block cardinality
393+
394+
- GIVEN a phase that supports an ILM action block
395+
- WHEN the user declares that action (for example `forcemerge { ... }`)
396+
- THEN the schema SHALL allow at most one such block and SHALL persist it as an object-shaped value in state, not as a single-element list
397+
398+
### Requirement: State schema version and upgrade (REQ-021)
399+
400+
The resource SHALL use a **non-zero** `schema.Schema.Version` for this resource type after this change.
401+
402+
The resource SHALL implement **`ResourceWithUpgradeState`** and SHALL migrate stored Terraform state from the **prior version** (list-shaped nested values for phases and ILM actions) to the **new version** (object-shaped nested values) for the same logical configuration.
403+
404+
The migration SHALL unwrap list-encoded values **only** for known ILM phase keys and known ILM action keys under those phases (including the delete-phase ILM action block named `delete`). The migration SHALL **not** alter the encoding of **`elasticsearch_connection`**.
405+
406+
#### Scenario: Upgrade from list-shaped phase state
407+
408+
- GIVEN persisted state where a phase is stored as a JSON array containing one object
409+
- WHEN Terraform loads state and runs the state upgrader
410+
- THEN the upgraded state SHALL store that phase as a single object (or equivalent null) consistent with `SingleNestedBlock` semantics
411+
412+
#### Scenario: Connection block unchanged by upgrade
413+
414+
- GIVEN persisted state that includes `elasticsearch_connection` as a list
415+
- WHEN the state upgrader runs
416+
- THEN the `elasticsearch_connection` value SHALL remain list-shaped as defined by the connection schema
417+
418+
### Requirement: Action fields optional with object-level AlsoRequires (REQ-022)
419+
420+
For the ILM action blocks **`forcemerge`**, **`searchable_snapshot`**, **`set_priority`**, **`wait_for_snapshot`**, and **`downsample`**, each attribute that is **required for API correctness when the action is declared** SHALL be **optional** at the Terraform attribute level (so an entirely omitted action block does not force those attributes to appear).
421+
422+
When the user **declares** one of these action blocks in configuration, validation SHALL require that all of the following previously required attributes are set (non-null), using object-level validation equivalent to **`objectvalidator.AlsoRequires`**:
423+
424+
- **`forcemerge`**: `max_num_segments`
425+
- **`searchable_snapshot`**: `snapshot_repository`
426+
- **`set_priority`**: `priority`
427+
- **`wait_for_snapshot`**: `policy`
428+
- **`downsample`**: `fixed_interval`
429+
430+
Existing attribute-level validators (for example minimum values) SHALL remain on those attributes where applicable.
431+
432+
#### Scenario: Omitted action block is valid
433+
434+
- GIVEN a phase without a particular action block (for example no `forcemerge` block)
435+
- WHEN Terraform validates configuration
436+
- THEN validation SHALL NOT fail solely because `max_num_segments` is unset
437+
438+
#### Scenario: Empty action block is invalid
439+
440+
- GIVEN the user declares `forcemerge { }` with no attributes
441+
- WHEN Terraform validates configuration
442+
- THEN validation SHALL fail with a diagnostic indicating the required fields when the block is present
443+
444+
#### Scenario: Searchable snapshot requires repository when present
445+
446+
- GIVEN the user declares `searchable_snapshot { force_merge_index = true }` without `snapshot_repository`
447+
- WHEN Terraform validates configuration
448+
- THEN validation SHALL fail with a diagnostic

0 commit comments

Comments
 (0)