cs-image-system-local-state-plugin 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2026 Mykel Alvis <mykelalvis@infrastructurebuilder.org>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,20 @@
1
+ # SPDX-FileCopyrightText: 2026 Mykel Alvis <mykelalvis@infrastructurebuilder.org>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from typing import TypeVar
6
+
7
+ from cs_image_system.base.basic.builder_base_state import StateBuilderBase
8
+
9
+ from .local_state_models import LOCAL_STATE, LocalStateBuilderModel
10
+
11
+ Q = TypeVar("Q", bound=LocalStateBuilderModel)
12
+
13
+
14
+ class LocalStateBuilder(StateBuilderBase[Q]):
15
+ """The local backend's builder: it emits nothing and runs nothing; the
16
+ consumers do, through the collector (like the S3 builder)."""
17
+
18
+ @classmethod
19
+ def csis_name(cls) -> str:
20
+ return LOCAL_STATE
@@ -0,0 +1,122 @@
1
+ # SPDX-FileCopyrightText: 2026 Mykel Alvis <mykelalvis@infrastructurebuilder.org>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """The ``local`` state backend type (stage 47.3): a state file on disk.
6
+
7
+ The type that needs nothing -- no bucket, no cloud, no credentials -- so a
8
+ developer or a test can run a real ``tofu init`` against it. An entry
9
+ declares a directory (``path``, relative to the configuration root or
10
+ absolute); a workspace's state is ``<path>/<workspace>.tfstate`` in it. The
11
+ root that binds to it sits a fixed four directories below the configuration
12
+ root (``generated/<lifecycle>/<workspace>/<phase>/``), so a relative
13
+ directory is written into the backend file relative to that depth, and the
14
+ emission names no absolute path of the machine that generated it.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from typing import Any, Mapping
20
+
21
+ from pydantic.dataclasses import dataclass # stage 23: validation at construction
22
+
23
+ from cs_image_system.base.constants import VCT
24
+ from cs_image_system.base.models.model_config import CSIS_MODEL_CONFIG
25
+ from cs_image_system.base.models.state_builder import StateBuilderModel
26
+ from cs_image_system.base.utils import super_safe_name
27
+ from cs_image_system.hashicorp_utils.collector import BackendRegistration, StateLocation, TerraformCollector
28
+
29
+ LOCAL_STATE: str = "local"
30
+ ROOT_DEPTH = 4 # generated/<lifecycle>/<workspace>/<phase>/ below the configuration root
31
+ _SLASHES = re.compile(r"/{2,}")
32
+
33
+
34
+ class LocalBackendKind:
35
+ """The local type's renderings: the location is ``local://<directory>/
36
+ <workspace>.tfstate``; the backend file and a consumer's data source
37
+ both carry one setting, ``path``."""
38
+ type = LOCAL_STATE
39
+
40
+ @staticmethod
41
+ def directory(settings: Mapping[str, Any]) -> str:
42
+ """The declared directory, normalised: repeated slashes collapsed, a
43
+ leading ``./`` and a trailing slash dropped, an absolute path kept."""
44
+ path = _SLASHES.sub("/", str(settings.get("path") or "."))
45
+ while path.startswith("./"):
46
+ path = path[2:]
47
+ path = path.rstrip("/") if len(path) > 1 else path
48
+ return path or "."
49
+
50
+ def location(self, settings: Mapping[str, Any], workspace: str) -> StateLocation:
51
+ return StateLocation(type=self.type, container=self.directory(settings),
52
+ key=f"{super_safe_name(workspace)}.tfstate")
53
+
54
+ def state_path(self, settings: Mapping[str, Any], workspace: str) -> str:
55
+ """What a root writes into its backend file: an absolute directory as
56
+ declared; a relative one rewritten from the root directory's depth."""
57
+ loc = self.location(settings, workspace)
58
+ if loc.container.startswith("/"):
59
+ return f"{loc.container}/{loc.key}"
60
+ climb = "../" * ROOT_DEPTH
61
+ return f"{climb}{loc.key}" if loc.container == "." else f"{climb}{loc.container}/{loc.key}"
62
+
63
+ def backend_settings(self, settings: Mapping[str, Any], workspace: str) -> dict[str, Any]:
64
+ return {"path": self.state_path(settings, workspace)}
65
+
66
+ def remote_state_settings(self, settings: Mapping[str, Any], workspace: str) -> dict[str, Any]:
67
+ return {"path": self.state_path(settings, workspace)}
68
+
69
+
70
+ LOCAL_KIND = LocalBackendKind()
71
+
72
+
73
+ @dataclass(kw_only=True, config=CSIS_MODEL_CONFIG)
74
+ class LocalStateBuilderModel(StateBuilderModel):
75
+ """A ``local`` entry under ``state_backends:``."""
76
+ path: str = "state"
77
+ type = LOCAL_STATE
78
+ # the executables entry these roots run; None = unspecified (no version
79
+ # check), a declared name is checked like every builder's (stage 63)
80
+ executable: str | None = None
81
+
82
+ @classmethod
83
+ def csis_name(cls) -> str:
84
+ return LOCAL_STATE
85
+
86
+ @classmethod
87
+ def csis_classifier(cls) -> VCT:
88
+ return VCT.STATE_BACKEND_MODEL
89
+
90
+ def __post_init__(self) -> None:
91
+ if not self.path or not self.path.strip():
92
+ raise ValueError("Terraform state backend 'path' cannot be empty for 'local' type.")
93
+ self.path = self.path.strip()
94
+ # stage 63: one directory has one spelling. `a/../b` and `b` were two
95
+ # locations to the collision check, and a bare `/` rendered
96
+ # `//<ws>.tfstate` (state files in the filesystem root)
97
+ normalised = LOCAL_KIND.directory({"path": self.path})
98
+ parts = [s for s in normalised.split("/") if s]
99
+ # `.` alone is the configuration root itself (a supported place);
100
+ # only a `.` or `..` INSIDE a longer path, or `..` anywhere, is refused
101
+ if normalised != "." and any(s in (".", "..") for s in parts):
102
+ raise ValueError(f"state backend {self.name!r}: `path: {self.path}` has a `.` or `..` "
103
+ "segment; write the directory once, without them")
104
+ if normalised == "/":
105
+ raise ValueError(f"state backend {self.name!r}: `path: /` would put state files in the "
106
+ "filesystem root; name a directory")
107
+ super().__post_init__()
108
+
109
+ def to_backend_registration(self) -> BackendRegistration:
110
+ return BackendRegistration(name=self.name, type=self.type_, settings={"path": self.path},
111
+ kind=LOCAL_KIND, is_default=self.get_is_default(),
112
+ aliases=tuple(sorted(self.aliases or ())))
113
+
114
+ def get_state_file_path(self, builder_name: str) -> str:
115
+ return self.to_backend_registration().state_file_path(builder_name)
116
+
117
+ def finalize(self) -> None:
118
+ if self._finalized:
119
+ return
120
+ super().finalize()
121
+ # available run-wide, like the S3 backend: consumers resolve it by name or as the default
122
+ TerraformCollector().register_backend(self.to_backend_registration())
@@ -0,0 +1,25 @@
1
+ # SPDX-FileCopyrightText: 2026 Mykel Alvis <mykelalvis@infrastructurebuilder.org>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import logging
6
+
7
+ from cs_image_system.base.basic.abstract_plugin_metadata import AbstractPluginMetadata
8
+ from cs_image_system.base.protocols.plugin_metadata import PluginMetadataProtocol
9
+
10
+ from .local_state_builder import LocalStateBuilder
11
+ from .local_state_models import LOCAL_STATE, LocalStateBuilderModel
12
+
13
+ log = logging.getLogger(__name__)
14
+
15
+
16
+ class LocalStateTypes(AbstractPluginMetadata, PluginMetadataProtocol):
17
+ def __init__(self) -> None:
18
+ super().__init__("1", "3.13",
19
+ {LOCAL_STATE: [LocalStateBuilderModel, LocalStateBuilder]},
20
+ {LocalStateBuilderModel: LocalStateBuilder})
21
+
22
+
23
+ def initialize():
24
+ log.debug("Initializing local state provider")
25
+ return LocalStateTypes()
@@ -0,0 +1,518 @@
1
+ Metadata-Version: 2.5
2
+ Name: cs-image-system-local-state-plugin
3
+ Version: 0.1.1
4
+ Summary: The local state backend plugin: a state file on disk for every terraform root that names it; no cloud, no credentials
5
+ Project-URL: Repository, https://github.com/infrastructurebuilder/cs-image-system-3
6
+ Project-URL: Documentation, https://github.com/infrastructurebuilder/cs-image-system-3/blob/develop/docs/OPERATIONS.md
7
+ Project-URL: Package README, https://github.com/infrastructurebuilder/cs-image-system-3/blob/develop/packages/local-state-plugin/README.md
8
+ Author-email: Mykel Alvis <mykelalvis@infrastructurebuilder.org>
9
+ License-Expression: Apache-2.0
10
+ License-File: LICENSE
11
+ Requires-Python: >=3.13
12
+ Requires-Dist: cs-image-system-hashicorp-utils==0.1.1
13
+ Requires-Dist: cs-image-system-system==0.1.1
14
+ Requires-Dist: pydantic>=2.13
15
+ Description-Content-Type: text/markdown
16
+
17
+ # cs-image-system-local-state-plugin
18
+
19
+ The `local` **state backend** plugin (stage 47): a state file on disk. It
20
+ registers one model, `LocalStateBuilderModel`, for entries with `type:
21
+ local` under `state_backends:` in `cfg/state-backends*.yml`. It is the type
22
+ that needs nothing -- no bucket, no cloud, no credentials -- so a developer
23
+ or a test can run a real `tofu init` against it, and it is what lets the
24
+ frozen fixture exercise two backend types without a second cloud account.
25
+ Like the S3 plugin it emits no files and runs no commands of its own: the
26
+ terraform roots that bind to it get an empty `backend "local" {}` block, a
27
+ partial configuration file carrying one setting, and consumers reading
28
+ their state get a `terraform_remote_state` data source with the same
29
+ setting.
30
+
31
+ ## What it registers
32
+
33
+ ```toml
34
+ [project.entry-points."cs_image_system.plugins.state"]
35
+ local_state_plugin = "cs_image_system.local_state_plugin.main:initialize"
36
+ ```
37
+
38
+ | Service name (`type:` of a backend entry) | Model class | Builder class | Classifications (VCT) |
39
+ |---|---|---|---|
40
+ | `local` | `LocalStateBuilderModel` | `LocalStateBuilder` | `STATE_BACKEND_MODEL`, `STATE_BACKEND` |
41
+
42
+ `initialize()` in [main.py](src/cs_image_system/local_state_plugin/main.py)
43
+ returns a `LocalStateTypes` metadata object (plugin metadata version `1`,
44
+ Python `3.13`). `LocalStateBuilder` in
45
+ [local_state_builder.py](src/cs_image_system/local_state_plugin/local_state_builder.py)
46
+ extends `StateBuilderBase` and implements no hooks: it generates nothing
47
+ and defers nothing, and exists so the registry can pair the model with a
48
+ builder class.
49
+
50
+ ## The model
51
+
52
+ | Field | Type | Default | Meaning |
53
+ |---|---|---|---|
54
+ | common builder fields | | | `name` is what a root's `state_configuration` names; `is_default` marks the backend `default` resolves to |
55
+ | `path` | str | `state` | a directory: relative to the configuration root, or absolute; one spelling per directory, so a `.` or `..` segment and a bare `/` are refused at load (stage 63) |
56
+ | `executable` | str or null | null | the `cfg/executables.yml` entry the bound roots' tofu comes from; when declared it must exist there and is version-checked, when unset nothing is checked (stage 63; see "Configuration reference") |
57
+
58
+ ## The kind
59
+
60
+ `LocalBackendKind` in
61
+ [local_state_models.py](src/cs_image_system/local_state_plugin/local_state_models.py)
62
+ renders the type for the collector (the contract the S3 plugin's README
63
+ describes under "Adding a backend type"):
64
+
65
+ | `location(settings, workspace)` | `backend_settings(...)` | `remote_state_settings(...)` |
66
+ |---|---|---|
67
+ | `local://<directory>/<super_safe_name(workspace)>.tfstate`, the directory normalised (repeated slashes collapsed, `./` and a trailing slash dropped, an absolute path kept); a `.` or `..` segment never reaches here, the model refuses it at load (stage 63) | `path = "<state path>"` | `path = "<state path>"` |
68
+
69
+ The state path is the absolute directory as declared, or, for a relative
70
+ one, the directory rewritten from the root directory's fixed depth:
71
+ `../../../../<directory>/<workspace>.tfstate` (a root sits at
72
+ `generated/<lifecycle>/<workspace>/<phase>/`, four below the configuration
73
+ root). The emission therefore names no absolute path of the machine that
74
+ generated it, and the same file works from a fresh clone.
75
+
76
+ Two roots on one directory whose names collapse under `super_safe_name`
77
+ would share a state file and are refused by the stage-46 collision check;
78
+ the same name under `s3` and under `local` are two locations, not a
79
+ collision. Because the model refuses a `.` or `..` segment at load (stage
80
+ 63), the directory the check compares is the directory's one spelling:
81
+ `a/../b` can no longer pose as a second location beside `b` (until
82
+ 2026-09-25 it could, and the check saw two places where the disk had one).
83
+
84
+ ## Emission
85
+
86
+ The frozen fixture binds its identity roots (`oktagroups`, `okta-tf-users`)
87
+ to `local-dev` (`cfg/state-backends-3.yml`, `path: state`), so the golden
88
+ carries
89
+ [oktagroups-group-generation.tfbackend.hcl](../../tests/fixtures/v2_golden/generated/identity/oktagroups/group-generation/oktagroups-group-generation.tfbackend.hcl):
90
+
91
+ ```hcl
92
+ # Backend 'local-dev' (local) partial configuration for workspace oktagroups
93
+ path = "../../../../state/oktagroups.tfstate"
94
+ ```
95
+
96
+ and, in every storage and instance root that reads the identity root's
97
+ outputs, a `data "terraform_remote_state" "oktagroups"` with
98
+ `backend = "local"` and `config = { path = "../../../../state/oktagroups.tfstate" }`
99
+ -- a read across backend types, since those roots keep their own state in
100
+ S3. The runner header lists `# state: workspace oktagroups ->
101
+ local://state/oktagroups.tfstate`.
102
+
103
+ ## Example configuration
104
+
105
+ ```yaml
106
+ ---
107
+ state_backends:
108
+ - name: local-dev
109
+ type: local
110
+ path: state
111
+ ```
112
+
113
+ A root binds with `state_configuration: local-dev`. The live configuration
114
+ keeps every root on its S3 backend by the standing decision; this type is
115
+ for development, tests and configurations that have no cloud state.
116
+
117
+ ## Prerequisites and integration
118
+
119
+ **Nothing beyond the core.** No account, no IAM role, no API, no
120
+ credential and no network: the package's own test
121
+ ([test_local_backend.py](tests/test_local_backend.py)) runs a real
122
+ `tofu init` and `tofu state pull` against the file the kind renders with an
123
+ environment of `PATH`, `HOME` and `TF_IN_AUTOMATION` only, and asserts
124
+ that no `AWS_*`, `GOOGLE_*` or `OKTA_*` variable is present. What the
125
+ type does depend on is what the core already needs, and it is worth
126
+ saying where each piece comes from.
127
+
128
+ - **The package, installed.** The root
129
+ [pyproject.toml](../../pyproject.toml) lists
130
+ `cs-image-system-local-state-plugin` as a workspace member, so
131
+ `just init` installs it beside the others. The core finds it by the
132
+ entry point above: at every CLI start
133
+ [loader.py](../base/src/cs_image_system/base/loader.py) enumerates the
134
+ `cs_image_system.plugins.state` group and registers every service each
135
+ plugin returns. In an environment without this package no model is
136
+ registered under the type `local`, and a configuration whose entry says
137
+ `type: local` does not load.
138
+ - **The gate.** The whole backend mechanism is behind
139
+ `config.use_state_backends` in `cfg/_config.yml`. Off, the entry is
140
+ loaded and registered but no root binds to it: no `backend "local" {}`
141
+ block, no `.tfbackend.hcl`, no remote-state data source, no
142
+ `# state:` header line, and no record in
143
+ `meta-state/state-locations.yaml`.
144
+ Off is itself refused by `validate` when any root would read another's
145
+ outputs through remote state (stage 63 item 19), so it is a shape only
146
+ for a tree where nothing reads anything.
147
+ - **A tofu binary for the root, not for the backend.** The commands that
148
+ touch the state (`init`, `plan`, `apply`, `state pull`) are the ROOT's:
149
+ the identity, storage or instance builder's `executable:` names an
150
+ entry in `cfg/executables.yml`, and that binary is what runs (the
151
+ golden's identity runner calls `/usr/local/bin/tofu`). The backend
152
+ entry's own `executable` field does not pick the binary; since stage 63
153
+ it is a declaration that is CHECKED: when set it must name an entry of
154
+ `cfg/executables.yml` (so the tofu the roots on this backend run is
155
+ declared and version-checked like every other entry), and when unset --
156
+ the default now, where it used to default to `tofu` and be checked by
157
+ nothing until 2026-09-25 -- nothing is checked on the backend's behalf.
158
+ The `local` backend
159
+ is built into OpenTofu and Terraform: it downloads no provider and
160
+ needs no plugin cache entry. This plugin sets no version floor; the
161
+ `version` requirement declared on the root's executable in
162
+ `cfg/executables.yml` is what `validate` and every run check (stage 48).
163
+ OpenTofu 1.12.6 is what the package test and the probes recorded below
164
+ ran against.
165
+ - **A directory the operator can write.** `path` names it, relative to
166
+ the configuration root (the directory that holds `cfg/`, `generated/`
167
+ and `meta-state/`) or absolute. It need not exist beforehand: tofu
168
+ creates the directory and the file at the first state write (probed
169
+ 2026-09-23 with OpenTofu 1.12.6: `init` against a missing directory
170
+ succeeds, `state pull` answers empty, `apply` creates the directory
171
+ and writes `<workspace>.tfstate`). There is no lock service; tofu's
172
+ local backend locks the file itself for the duration of a command, and
173
+ the system runs one tofu process at a time.
174
+ - **The state stays on the machine.** A local state file never travels
175
+ with the configuration repository: the live tree's `.gitignore` ignores
176
+ `*.tfstate`, the run's `--commit` stages only `meta-state/` and
177
+ `generated/` ([meta_state.py](../base/src/cs_image_system/base/meta_state.py),
178
+ `commit_meta_state`), and the public-safe rules refuse a `*.tfstate` at
179
+ any depth under those trees. So a fresh clone, a second machine and CI
180
+ hold NO state for a root bound to this type -- which is why the live
181
+ configuration keeps every root on S3, and why the consequences are
182
+ spelled out under "When it fails".
183
+ - **The private mirror.** Since stage 49 a real run materialises a root
184
+ into `_private/<lifecycle>/<workspace>/<phase>/` and runs tofu there.
185
+ `mirror_path` in
186
+ [materialize.py](../base/src/cs_image_system/base/materialize.py) puts
187
+ `_private/` exactly where `generated/` stands, at the same depth, so
188
+ `../../../../<directory>/<workspace>.tfstate` counts up to the same
189
+ configuration root from either tree and one state file serves both. No
190
+ `_private/state/` is ever created.
191
+ - **Reads across types.** A consumer root (an S3-bound storage or
192
+ instance root reading the identity root's outputs) gets a
193
+ `terraform_remote_state` data source with `backend = "local"` and the
194
+ same relative `path`. Terraform reads that file on the machine where the
195
+ consumer's plan runs: the producer's state must be present THERE.
196
+
197
+ ## Configuration reference
198
+
199
+ The entry under `state_backends:` in `cfg/state-backends*.yml`. The model
200
+ is a pydantic dataclass with `extra="forbid"`: an unknown key is refused at
201
+ load, and a `parameters:` key is refused with a message naming
202
+ `variables:` (stage 26). Inherited fields come from `NameTyped` and
203
+ `BuilderModel` in
204
+ [builder_model.py](../base/src/cs_image_system/base/models/builder_model.py);
205
+ `StateBuilderModel` adds nothing but the classification, and a state
206
+ backend has no `runtime` field.
207
+
208
+ | Field | Type | Default | Meaning |
209
+ |---|---|---|---|
210
+ | `name` | str | required | The backend's name, normalised at load (lowercased, spaces and `:` to `_`); `/` and `\` are refused. This is the value a root's `state_configuration:` (or a runtime's) names; the collector looks the name up first and then the backends' `aliases` (stage 63). |
211
+ | `type` | str | required | `local`. Selects this plugin at load and is the terraform backend type written into the consumers' `backend "local" {}` block and `backend = "local"` data sources. |
212
+ | `path` | str | `state` | The directory the state files live in. Whitespace is stripped; empty or blank is refused at load. Relative means relative to the configuration root; absolute is kept as declared. Normalised for the location: repeated slashes collapsed, a leading `./` dropped, a trailing `/` dropped. No `~` or environment-variable expansion. Since stage 63 the normalised form may not contain a `.` or `..` segment (`a/../b`, `./x/./y`, `..`, `../state` are refused at load) and may not be the bare filesystem root `/`: one directory has one spelling. `path: .` (or `./`) alone is still accepted: it is the configuration root itself, one spelling of one directory. Until 2026-09-25 a `..` segment was kept as written (so `a/../b` and `b` were two locations to the collision check), and `path: /` rendered `//<workspace>.tfstate`. Each bound workspace gets `<path>/<super_safe_name(workspace)>.tfstate`. |
213
+ | `is_default` | bool | `false` | Marks the backend that `state_configuration: default` (and an omitted field) resolves to. More than one default among the declared backends is refused when a root resolves it. |
214
+ | `description` | str or null | null | Accepted, not read. |
215
+ | `aliases` | set[str] | `{}` | Other names the backend answers to (stage 63). They travel on the `BackendRegistration`, and the collector's `resolve_backend` tries the backend names first and then every backend's aliases, so a root's (or runtime's) `state_configuration:` may name an alias and `validate` accepts the binding. Until 2026-09-25 aliases were registered and never read, and a binding to one was refused as not declared. The records (`# state:` header, `state-locations.yaml`) still name the backend by its `name`. |
216
+ | `executable` | str or null | null | The `cfg/executables.yml` entry the tofu for the roots on this backend comes from (stage 63). Unset (the default): nothing is checked on the backend's behalf. Declared: `validate` and every run refuse a name that is not an entry of `cfg/executables.yml` (`check_state_backend_executables`), and the entry itself is version-checked like every executables entry (stage 48). The plugin still runs nothing under it, and the binary a root runs is still the root builder's own `executable`. Until 2026-09-25 the field defaulted to `tofu` and was checked by nothing. |
217
+ | `config` | dict | `{}` | Accepted, not read. |
218
+ | `gitignore` | list[str] | `[]` | Accepted, not read. |
219
+ | `tags` | dict[str, str] | `{}` | Accepted, not read. |
220
+
221
+ `path` is the only field the kind renders from: it is the one setting the
222
+ registration carries (`settings={"path": ...}`). `aliases` travel beside it
223
+ on the registration for the lookup, and `executable` is read by
224
+ `validate`, not by the plugin.
225
+
226
+ Fields outside this file that decide what the entry does:
227
+
228
+ | Where | Field | Effect |
229
+ |---|---|---|
230
+ | `cfg/_config.yml` | `config.use_state_backends` | The gate. False: the entry is inert. |
231
+ | a root's builder (group, user, storage, instance) | `state_configuration` | Names this backend, or `default`; the root's own value wins, else its runtime's, else the default (stage 46). |
232
+ | a runtime builder | `state_configuration` | The second rung: every storage and instance root on that runtime inherits it unless the root names its own. The identity roots have no runtime and skip this rung. |
233
+
234
+ See [docs/CONFIGURATION.md](../../docs/CONFIGURATION.md) section 10 for
235
+ the chain and the collision rule in full.
236
+
237
+ ### Variations
238
+
239
+ - **Relative vs absolute `path`.** When `path` is relative (`state`,
240
+ `state/dev`), the backend file and every data source carry
241
+ `../../../../<directory>/<workspace>.tfstate`, climbing four directories
242
+ from the phase directory to the configuration root, and the emission
243
+ names nothing of the generating machine. When `path` is absolute
244
+ (`/var/tf/state`), the file carries `/var/tf/state/<workspace>.tfstate`
245
+ verbatim: the emission then names a path that must exist on every
246
+ machine that runs the root, and a fresh clone elsewhere cannot use it
247
+ unchanged. The location record and the `# state:` header show the
248
+ declared directory in both cases (`local://state/...`,
249
+ `local:///var/tf/state/...`).
250
+ - **`path: .`** (or `./`): the state files live in the configuration
251
+ root itself, and the backend file carries
252
+ `../../../../<workspace>.tfstate` with no directory segment. This is the
253
+ one place a `.` is accepted: stage 63 refuses a `.` or `..` segment
254
+ inside a longer path, not `.` alone.
255
+ - **Named vs alias binding.** A root may name the backend by its `name`
256
+ or by any of its `aliases` (stage 63); both resolve to the same
257
+ registration and the same location, so switching a root from one
258
+ spelling to the other moves nothing.
259
+ - **`executable` declared or not.** Unset, nothing about the backend is
260
+ version-checked. Declared (`executable: open-tofu-1`), the name must be
261
+ an entry of `cfg/executables.yml`, and that entry's version requirement
262
+ is checked by `validate` and at the head of every run (stage 63).
263
+ - **Settings that were encrypted.** Any value may be an `ENC[age:...]`
264
+ marker. Since stage 63 the partial configuration file writes such a
265
+ setting as the ciphertext it was read from (`render_backend_value` in
266
+ the collector calls `emit`), and the execution's materialise step puts
267
+ the plaintext back in the `_private/` copy tofu runs from; until
268
+ 2026-09-25 it was written in clear and only the plaintext guard caught
269
+ it. A `path` is not a secret, so for this type the rule is a
270
+ consistency guarantee rather than a practical option; the data source,
271
+ the `# state:` header and the location record are not part of that
272
+ change.
273
+ - **Dry run vs real run.** A dry run (the default) initialises every root
274
+ with `init -backend=false`: providers are installed and the emission is
275
+ validated, but the backend file is not read, the state directory is not
276
+ created and no state file is touched. The frozen fixture's proof runs
277
+ are dry, which is why `tests/fixtures/config/` has no `state/`
278
+ directory. A real run (`--no-dry-run`) initialises with
279
+ `init -input=false -reconfigure -backend-config=<workspace>-<phase>.tfbackend.hcl`,
280
+ at generation time and again in the runner script before every plan,
281
+ and every plan and apply then reads and writes the file on disk.
282
+ - **`use_state_backends` on vs off.** On, everything above. Off, the entry
283
+ is loaded and registered but no root is bound: no block, no file, no
284
+ data source, no header line, no location record, and the collision and
285
+ move checks skip (`check_state_locations` returns nothing when backends
286
+ are disabled).
287
+ - **Named vs default vs inherited binding.** A root that names this
288
+ backend (`state_configuration: local-dev`) is bound to it whatever its
289
+ runtime says. A root at `default` on a runtime whose
290
+ `state_configuration` names a backend inherits THAT one. Only a root at
291
+ `default` with no runtime rung (the identity roots, or a runtime also
292
+ at `default`) takes the entry marked `is_default: true`.
293
+ - **This type vs the object-store types.** The S3 and GCS kinds render a
294
+ bucket, a key or prefix and, for the backend file, encryption, locking
295
+ and identity settings; this kind renders one `path` in both the backend
296
+ file and the data source, and the stage-39 encryption rule
297
+ ([test_v2_state_encryption.py](../../tests/test_v2_state_encryption.py))
298
+ is scoped to `s3` and does not apply to it. A consumer in one type reads
299
+ a producer in another through a data source of the PRODUCER's type: an
300
+ S3-bound root reading a `local`-bound one emits
301
+ `backend = "local"` with a `path`, and the read happens on disk.
302
+ - **`--migrate-state <root>` on a root bound to this type.** The move
303
+ operation (stage 46.4) is type-agnostic: `state-migration begin` writes
304
+ the previous location from `meta-state/state-locations.yaml` beside the
305
+ root as `<workspace>.tfbackend.previous.hcl`, initialises against the
306
+ new file and pulls; for a `local` new location that has no file yet
307
+ the pull answers empty and the location counts as empty, so the move
308
+ proceeds (`init -migrate-state -force-copy`, a plan that must be clean,
309
+ `state-migration finish`). A dry run refuses the flag outright.
310
+ - **CI.** The CI recording job is dry, so it never initialises against
311
+ the file and never needs the directory; the `perform` job runs the live
312
+ tree, which binds no root to this type.
313
+
314
+ ## What it tests and verifies
315
+
316
+ - **At load (pydantic, `__post_init__`).** `path` empty or whitespace is
317
+ refused; the value is stripped. The common checks apply: `name` with
318
+ `/` or `\` is refused, a name in the reserved set (`default`, `self`,
319
+ `none`, empty, null; refused where the file is read, naming it) is
320
+ refused, an unknown key is refused, `parameters` is refused.
321
+ Since stage 63, a `path` whose normalised form has a `.` or `..`
322
+ segment, or is the bare `/`, is refused too (messages under "When it
323
+ fails").
324
+ The verdict is a load error: the command that loaded the configuration
325
+ (`validate`, `run`, anything) stops before any check runs, with the
326
+ pydantic or `ValueError` message on stderr and exit 1.
327
+ - **At `finalize()` (configuration load, before any generation phase).**
328
+ The model registers `BackendRegistration(name, "local", {"path": ...},
329
+ LOCAL_KIND, is_default, aliases)` with the run-wide `TerraformCollector`
330
+ (the aliases since stage 63). A
331
+ second registration under the same name with different settings is
332
+ refused (`HclConfigConflictError`); the same settings again are a
333
+ no-op.
334
+ - **At `validate`, and at the head of every `run`
335
+ (`check_state_locations` in
336
+ [validate.py](../base/src/cs_image_system/base/commands/validate.py)).**
337
+ From the declarations alone, with nothing generated: every terraform
338
+ root's backend is resolved through the chain (by name, or by alias
339
+ since stage 63) and must be declared;
340
+ more than one `is_default` among the backends is refused; every root's
341
+ location is rendered by this kind (`local://<directory>/<workspace>.tfstate`)
342
+ and no two roots may share one; and a root whose resolved location
343
+ differs from the one `meta-state/state-locations.yaml` recorded is
344
+ refused while the records show live resources in it (a storage not
345
+ destroyed, a group or user the identity read-model attributes to it
346
+ after an executed identity run, an instance pinned to a build). A root
347
+ with nothing deployed moves with one INFO line. Verdicts are entries in
348
+ the validation error list: `validate` prints each and exits 1;
349
+ `run` records them in `generated/run-summary.json` under
350
+ `validation_errors`, generates nothing and exits 1.
351
+ - **At `validate` and every run, the executables check
352
+ (`check_state_backend_executables`, stage 63).** A backend entry whose
353
+ `executable` is declared must name an entry of `cfg/executables.yml`;
354
+ that entry is version-checked with the others (stage 48). An unset
355
+ `executable` is skipped. Until 2026-09-25 the backend's `executable`
356
+ was not among the executables checked.
357
+ - **At generation.** Each root binds itself with `set_backend`; a second
358
+ binding of one workspace to a different backend is refused
359
+ (`HclConfigConflictError`). Before the terraform block is rendered the
360
+ collector runs `validate_state_locations()` again over the bound
361
+ workspaces. The kind then renders the backend file, the data sources
362
+ and the `# state:` header, and the run records every bound workspace's
363
+ location in `meta-state/state-locations.yaml` as `backend`, `type`,
364
+ `location` and the file's `path` (a migrating root keeps its old record
365
+ until `finish`).
366
+ - **At apply.** Nothing of the plugin's. Tofu's own `init` (real-run
367
+ form) validates the backend file and `plan`/`apply` read and write the
368
+ state file; their verdicts are tofu's exit codes in the runner, and
369
+ the run's `apply` status in `run-summary.json` and `meta-state/runs.yaml`.
370
+ - **After apply (post-finalize hooks).** Nothing: the builder implements
371
+ no hooks.
372
+ - **In the state query.** Nothing: this plugin contributes no provider
373
+ view, and the state file itself is never inspected by the system. The
374
+ only memory the system keeps of where the state is, is the location
375
+ record above.
376
+ - **In the suite.** [test_local_backend.py](tests/test_local_backend.py)
377
+ pins the three renderings (relative, absolute, normalised spelling),
378
+ the collision rule across types, and a real `tofu init` plus
379
+ `state pull` with an empty environment (skipped when `tofu` is not on
380
+ the PATH). [tests/test_v2_state_locations.py](../../tests/test_v2_state_locations.py)
381
+ and [tests/test_v2_gate4_identity_storage.py](../../tests/test_v2_gate4_identity_storage.py)
382
+ pin the golden's record, header line and cross-type data source.
383
+
384
+ ## When it fails
385
+
386
+ ### Failures that have happened
387
+
388
+ - **2026-09-19, the private mirror one level too deep.** Found by probing
389
+ an availability-zone change: `tofu init` in the materialised copy
390
+ could not read its module, because the mirror stood one directory
391
+ deeper than the root it mirrored. Every relative path in an emission counts
392
+ directories up to the configuration root, and this type's
393
+ `../../../../<directory>/<workspace>.tfstate` is one of them: from the
394
+ deeper mirror it would have named a directory that does not exist
395
+ (or, worse, a different `state/` under `_private/`) and a real run
396
+ would have planned against an empty state. The fix in
397
+ [materialize.py](../base/src/cs_image_system/base/materialize.py)
398
+ (`mirror_path`) replaces the generation directory's name with
399
+ `_private` instead of nesting under it, and its docstring names this
400
+ type. What to look for if it recurs: an `init` or `plan` in
401
+ `_private/...` that fails to find a module source or reports an empty
402
+ state while `generated/...` on the same machine has it; compare the
403
+ depth of the two directories.
404
+
405
+ ### Failures the code raises
406
+
407
+ - `Terraform state backend 'path' cannot be empty for 'local' type.` --
408
+ at load, `path: ""` or `path: " "`. Give the entry a directory or
409
+ delete the key (the default is `state`).
410
+ - ``state backend '<name>': `path: <path>` has a `.` or `..` segment;
411
+ write the directory once, without them`` -- at load (stage 63), a
412
+ `path` such as `a/../b`, `./x/./y`, `..` or `../state` (`.` alone is accepted). Write the
413
+ directory the way it is meant (`b`, `x/y`), or give an absolute path
414
+ for a directory outside the configuration root. Until 2026-09-25 such a
415
+ path was accepted and `a/../b` was a different location from `b` to the
416
+ collision check.
417
+ - ``state backend '<name>': `path: /` would put state files in the
418
+ filesystem root; name a directory`` -- at load (stage 63), `path: /` (or
419
+ `//`). Until 2026-09-25 it rendered `//<workspace>.tfstate`. Name a real
420
+ directory.
421
+ - `Executable <x> specified for state backend <name> not found in
422
+ executables list.` -- at `validate`/`run` (stage 63), the entry's
423
+ `executable` names nothing in `cfg/executables.yml`. Fix the spelling,
424
+ declare the entry, or remove the key (unset means unchecked).
425
+ - `Name '<name>' contains invalid characters: {'/', '\\'}` -- at load, a
426
+ backend name with a slash. Name it without one; the directory belongs
427
+ in `path`.
428
+ - A pydantic "extra inputs are not permitted" error naming the key -- at
429
+ load, a key the model does not have (a `bucket:` or `key:` copied from
430
+ an S3 entry, a typo). The only own field is `path`.
431
+ - A message saying `parameters` was retired (stage 26) and naming
432
+ `variables:` -- at load; delete the key.
433
+ - `State backend '<name>' registered twice with different configurations`
434
+ -- at load (`finalize`), two entries with one name across the
435
+ `state-backends*.yml` files. Rename one.
436
+ - `workspace '<ws>' names state backend '<name>', which is not declared`
437
+ -- at `validate`/`run`, a root's (or its runtime's) `state_configuration`
438
+ names something no entry is registered under, by name or by alias.
439
+ Check the spelling against the entry's `name` and `aliases` (an alias
440
+ resolves since stage 63; until 2026-09-25 it was refused here).
441
+ - `Multiple default state backends registered: [...]` -- at
442
+ `validate`/`run` when a root resolves `default` and more than one entry
443
+ says `is_default: true`. Leave one.
444
+ - `state location collision: workspaces <a>, <b> would share the state
445
+ object local://<directory>/<name>.tfstate` -- at `validate`/`run`: two
446
+ roots on one directory whose names collapse under `super_safe_name`
447
+ (`ws-local` and `ws_local`), or two backend entries that normalise to
448
+ one directory (`state` and `./state//`). Rename a root or separate the
449
+ directories. The check compares the normalised directory; since stage
450
+ 63 a `.` or `..` segment is refused at load, so `a/../b` can no longer
451
+ slip past as a second spelling of `b` (it could until 2026-09-25). An
452
+ absolute path is still never matched against the relative spelling of
453
+ the same directory.
454
+ - `Workspace '<ws>' is bound to state backend '<x>' and cannot be rebound
455
+ to '<y>'` -- at generation, a builder trying to bind one workspace to
456
+ two backends; not reachable from configuration alone.
457
+ - `workspace '<ws>' would move its state from local://... to <new> while
458
+ <what stands> stand(s) in it: the new location is empty, so the next
459
+ plan would create everything again and strand the old state. To MOVE
460
+ the state: `run --no-dry-run ... --migrate-state <ws>` ...` -- at
461
+ `validate`/`run`, after a root bound to this type is rebound (to S3, or
462
+ to another directory) while its records show live resources. Either
463
+ run the migration, or correct the records if the resources are gone;
464
+ never edit the record to get past it.
465
+ - `--migrate-state moves state and needs --no-dry-run: a dry run never
466
+ moves state` -- on stderr, exit 2, before the configuration loads.
467
+ - `state-migration begin: the new location local://... already holds
468
+ state (lineage ...) that is not this workspace's: a collision, refused.
469
+ The old state is untouched.` -- in the runner of a migrating root, when
470
+ a file already exists at the new path from another lineage. Move or
471
+ remove that file by hand after checking whose it is.
472
+ - `State backend '<name>' (type 'local') was registered without a kind`
473
+ -- cannot occur through this model, which always supplies `LOCAL_KIND`;
474
+ it is the collector's guard for a hand-built registration.
475
+
476
+ ### Failures tofu raises that this type invites
477
+
478
+ The system never inspects the state file, so these surface from tofu in
479
+ the runner (a non-zero exit stops the runner at that root; the run
480
+ records the lifecycle's apply as `failed`).
481
+
482
+ - **A plan that creates everything again, on a machine that has no state
483
+ file.** The state stays on the machine that applied (see
484
+ "Prerequisites"), so a real run of a `local`-bound root from a fresh
485
+ clone, another machine or CI finds an empty location and the plan
486
+ proposes every resource anew. Nothing in the system refuses this: the
487
+ location record matches (same `local://...`), so the move guard is
488
+ silent; only the plan's size and the `gate-plan` step (which refuses
489
+ destroys, not creates) stand between the operator and a duplicate
490
+ deployment. Where to look: `<workspace>.tfstate` under the declared
491
+ directory on the machine that last applied; the `# state:` header of
492
+ the runner script names the directory. What to do: copy the file, or
493
+ bind the root to an S3 backend and migrate. This is the reason the
494
+ live configuration keeps every root on S3.
495
+ - `Error: Unable to find remote state` / `No stored state was found for
496
+ the given workspace in the given backend.` -- from `plan` in a CONSUMER
497
+ root (an S3-bound storage or instance root reading a `local`-bound
498
+ producer through its `terraform_remote_state` data source) when the
499
+ producer's file is absent on the machine running the plan (probed
500
+ 2026-09-23 with OpenTofu 1.12.6). The producer root has never been
501
+ applied here, or was applied elsewhere. Apply the producer first, on
502
+ this machine, or bring its state file.
503
+ - `Backend configuration changed` -- would follow from a changed `path`
504
+ on a root initialised against the old one, but every init the system
505
+ emits carries `-reconfigure`, so it does not occur under the system's
506
+ own commands; a hand-run `tofu init` without the flag can hit it.
507
+ - A permission error from `apply` or `state push` writing under `path` --
508
+ the directory (or a parent tofu must create) is not writable by the
509
+ operator. Fix the directory; nothing in the system pre-checks it.
510
+
511
+ ## Related
512
+
513
+ - [tf-s3-state-plugin](../tf-s3-state-plugin/README.md): the S3 type, and
514
+ the recipe for adding a type.
515
+ - [hashicorp-utils](../hashicorp-utils/README.md): the collector and the
516
+ `BackendRegistration` / `StateLocation` contract.
517
+ - [docs/CONFIGURATION.md](../../docs/CONFIGURATION.md) section 10 and
518
+ [docs/OPERATIONS.md](../../docs/OPERATIONS.md) "Where state lives".
@@ -0,0 +1,9 @@
1
+ cs_image_system/local_state_plugin/__init__.py,sha256=GAFRNaGWQxXquHOlCzxan4LkPZ9_NsiyswknxdPcX0c,122
2
+ cs_image_system/local_state_plugin/local_state_builder.py,sha256=WWZrCcN87lZbcbIOC0nmoGaMO2IdBE8mKcveBQ3RWwo,608
3
+ cs_image_system/local_state_plugin/local_state_models.py,sha256=_HA1kD0-zzbea0cPUSn7PtFuYsqN9cNLb4lbi3x1L0Q,5730
4
+ cs_image_system/local_state_plugin/main.py,sha256=SeJW6NiDGdcyEXvy3g9QnI1XJF6IQ-5eNfDiG5QNedw,861
5
+ cs_image_system_local_state_plugin-0.1.1.dist-info/METADATA,sha256=QBkXKMcHMoetMH7DQ0mXMm1rY8R-PaST0RA_BxDm7tk,32549
6
+ cs_image_system_local_state_plugin-0.1.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
7
+ cs_image_system_local_state_plugin-0.1.1.dist-info/entry_points.txt,sha256=iufMtMkKJNdEDkeZrzTex8YV8-7x3LgCPXcNqmJD6cU,104
8
+ cs_image_system_local_state_plugin-0.1.1.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
9
+ cs_image_system_local_state_plugin-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [cs_image_system.plugins.state]
2
+ local_state_plugin = cs_image_system.local_state_plugin.main:initialize
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.