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.
- cs_image_system/local_state_plugin/__init__.py +3 -0
- cs_image_system/local_state_plugin/local_state_builder.py +20 -0
- cs_image_system/local_state_plugin/local_state_models.py +122 -0
- cs_image_system/local_state_plugin/main.py +25 -0
- cs_image_system_local_state_plugin-0.1.1.dist-info/METADATA +518 -0
- cs_image_system_local_state_plugin-0.1.1.dist-info/RECORD +9 -0
- cs_image_system_local_state_plugin-0.1.1.dist-info/WHEEL +4 -0
- cs_image_system_local_state_plugin-0.1.1.dist-info/entry_points.txt +2 -0
- cs_image_system_local_state_plugin-0.1.1.dist-info/licenses/LICENSE +202 -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,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.
|