tf 0.1.0__tar.gz
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.
- tf-0.1.0/LICENSE +21 -0
- tf-0.1.0/PKG-INFO +284 -0
- tf-0.1.0/README.md +257 -0
- tf-0.1.0/pyproject.toml +64 -0
- tf-0.1.0/tf/__init__.py +0 -0
- tf-0.1.0/tf/blocks.py +66 -0
- tf-0.1.0/tf/checker.py +37 -0
- tf-0.1.0/tf/gen/__init__.py +1 -0
- tf-0.1.0/tf/gen/tfplugin_pb2.py +203 -0
- tf-0.1.0/tf/gen/tfplugin_pb2.pyi +597 -0
- tf-0.1.0/tf/gen/tfplugin_pb2_grpc.py +760 -0
- tf-0.1.0/tf/iface.py +179 -0
- tf-0.1.0/tf/provider.py +444 -0
- tf-0.1.0/tf/runner.py +222 -0
- tf-0.1.0/tf/schema.py +158 -0
- tf-0.1.0/tf/tests/__init__.py +0 -0
- tf-0.1.0/tf/tests/test_blocks.py +64 -0
- tf-0.1.0/tf/tests/test_checker.py +38 -0
- tf-0.1.0/tf/tests/test_provider.py +1527 -0
- tf-0.1.0/tf/tests/test_runner.py +204 -0
- tf-0.1.0/tf/tests/test_schema.py +23 -0
- tf-0.1.0/tf/tests/test_types.py +105 -0
- tf-0.1.0/tf/tests/test_utils.py +107 -0
- tf-0.1.0/tf/types.py +161 -0
- tf-0.1.0/tf/utils.py +130 -0
tf-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Hunter Fernandes
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
tf-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: tf
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python Terraform Provider framework
|
|
5
|
+
Home-page: https://github.com/hfern/tf
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: opentofu,terraform,provider,python
|
|
8
|
+
Author: Hunter Fernandes
|
|
9
|
+
Author-email: hunter@hfernandes.com
|
|
10
|
+
Requires-Python: >=3.11,<4.0
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Requires-Dist: cryptography (>43)
|
|
23
|
+
Requires-Dist: grpcio (>=1.67.1,<2.0.0)
|
|
24
|
+
Requires-Dist: msgpack (>=1.1.0,<2.0.0)
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# Python TF Plugin Framework
|
|
28
|
+
|
|
29
|
+
This package acts as an interface for writing a Terraform/OpenTofu ("TF")
|
|
30
|
+
provider in Python.
|
|
31
|
+
This package frees you of the toil of interfacing with the TF type system,
|
|
32
|
+
implementing the Go Plugin Protocol, implementing the TF Plugin Protocol, and
|
|
33
|
+
unbundling compound API calls.
|
|
34
|
+
|
|
35
|
+
Instead, you can simply implement Create, Read, Update, and Delete operations
|
|
36
|
+
using idiomatic Python for each of the resource types you want to support.
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
This package is available on PyPI, and can be installed using pip.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install tf
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Using the Framework
|
|
47
|
+
|
|
48
|
+
There are three primary interfaces in this framework:
|
|
49
|
+
|
|
50
|
+
1. **Provider** - By implementing this interface, you can define
|
|
51
|
+
a new provider. This defines its own schema, and supplies
|
|
52
|
+
resource and data source classes to the framework.
|
|
53
|
+
1. **Data Source** - This interface is used to define a data source, which
|
|
54
|
+
is a read-only object that can be used to query information
|
|
55
|
+
from the provider or backing service.
|
|
56
|
+
1. **Resource** - This interface is used to define a resource, which
|
|
57
|
+
is a read-write object that can be used to create, update,
|
|
58
|
+
and delete resources in the provider or backing service.
|
|
59
|
+
Resources represent full "ownership" of the underlying object.
|
|
60
|
+
This is the primary type you will use to interact with the system.
|
|
61
|
+
|
|
62
|
+
To use this interface, create one class implemented `Provider`, and any number
|
|
63
|
+
of classes implementing `Resource` and `DataSource`.
|
|
64
|
+
|
|
65
|
+
Then, call `run_provider` with an instance of your provider class. A basic
|
|
66
|
+
main function might look like:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
import sys
|
|
70
|
+
|
|
71
|
+
from tf import runner
|
|
72
|
+
from mypackage import MyProvider
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main():
|
|
76
|
+
provider = MyProvider()
|
|
77
|
+
runner.run_provider(provider, sys.argv)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Entry Point Name
|
|
81
|
+
|
|
82
|
+
TF requires a specific naming convention for the provider. Your executable
|
|
83
|
+
must be named in the form of `terraform-provider-<providername>`.
|
|
84
|
+
This means that you must your [entrypoint](https://setuptools.pypa.io/en/latest/userguide/entry_point.html)
|
|
85
|
+
similarly.
|
|
86
|
+
|
|
87
|
+
```toml filename="pyproject.toml"
|
|
88
|
+
[project.scripts]
|
|
89
|
+
terraform-provider-myprovider = "mypackage.main:main"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### TF Developer Overrides
|
|
93
|
+
|
|
94
|
+
In order to get TF to use your provider, you must tell TF to run your provider from a custom path.
|
|
95
|
+
|
|
96
|
+
This is done by editing the `~/.terraformrc` or `~/.tofurc` file,
|
|
97
|
+
and setting the path to your virtual environment's `bin` directory (which contains the `terraform-provider-myprovider` script).
|
|
98
|
+
|
|
99
|
+
```hcl filename="~/.terraformrc"
|
|
100
|
+
provider_installation {
|
|
101
|
+
dev_overrides {
|
|
102
|
+
"tf.mydomain.com/mypackage" = "/path/to/your/.venv/bin"
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
direct {}
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Using the Provider
|
|
110
|
+
|
|
111
|
+
Now you can use your provider in Terraform by specifying it in the `provider` block.
|
|
112
|
+
|
|
113
|
+
```hcl filename="main.tf"
|
|
114
|
+
terraform {
|
|
115
|
+
required_providers {
|
|
116
|
+
myprovider = { source = "tf.mydomain.com/mypackage"}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
provider "myprovider" {}
|
|
121
|
+
|
|
122
|
+
resource "myprovider_myresource" "myresource" {
|
|
123
|
+
# ...
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Attributes
|
|
128
|
+
|
|
129
|
+
Attributes are the fields that an element exposes to the user to either set or read.
|
|
130
|
+
They take a name, a type, and a set of flags.
|
|
131
|
+
|
|
132
|
+
Attributes can be a combination of `required`, `computed`, and `optional`.
|
|
133
|
+
The values of these flags determine how the attribute is treated by TF and the framework.
|
|
134
|
+
|
|
135
|
+
| Required | Computed | Optional | Behavior |
|
|
136
|
+
|:--------:|:--------:|:--------:|------------------------------------------------------------------------------------------|
|
|
137
|
+
| | | | _Invalid combination._ You must have at least one flag set. |
|
|
138
|
+
| | | X | Fields may be set. TODO: Have default values. |
|
|
139
|
+
| | X | | Computed fields are read-only, value is set by the server and cannot be set by the user. |
|
|
140
|
+
| | X | X | Field may be set. If not, uses value from server. |
|
|
141
|
+
| X | | | Required fields must be present in the configuration. | |
|
|
142
|
+
| X | | X | _Invalid combination._ |
|
|
143
|
+
| X | X | | _Invalid combination._ |
|
|
144
|
+
| X | X | X | _Invalid combination._ |
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
## Types
|
|
148
|
+
|
|
149
|
+
This framework takes care to map Python types to TF types as closely as possible.
|
|
150
|
+
When you are writing element CRUD operations, you can consume and emit normal Python types
|
|
151
|
+
in the State dictionaries.
|
|
152
|
+
|
|
153
|
+
This framework handles the conversion to and from TF types and semantic equivalents.
|
|
154
|
+
|
|
155
|
+
| Python Type | TF Type | Framework Type | Notes |
|
|
156
|
+
|------------------|----------|-----------------------|-----------------------------------------------------------|
|
|
157
|
+
| `str` | `string` | `String` | |
|
|
158
|
+
| `int`, `float` | `number` | `Integer` | |
|
|
159
|
+
| `bool` | `bool` | `Bool` | |
|
|
160
|
+
| `Dict[str, Any]` | `string` | `NormalizedJson` | Key order and whitespace are ignored for diff comparison. |
|
|
161
|
+
|
|
162
|
+
For `NormalizedJson` in particular, the framework will pass in `dict` and expect `dict` back.
|
|
163
|
+
That being said, if you are heavily editing a prettified JSON file and using that as
|
|
164
|
+
attribute input, you should wrap it in `jsonencode(jsondecode(file("myfile.json")))`
|
|
165
|
+
to allow Terraform to strip the file before it is passed to your provider.
|
|
166
|
+
Otherwise, the state will be ugly and will change every time you make whitespace
|
|
167
|
+
changes to the file.
|
|
168
|
+
|
|
169
|
+
## Errors
|
|
170
|
+
|
|
171
|
+
All errors are reporting using `Diagnostics`.
|
|
172
|
+
This parameter is passed into most operations, and you can
|
|
173
|
+
add warnings or errors.
|
|
174
|
+
|
|
175
|
+
Be aware: Operations that add error diagnostics will be considered
|
|
176
|
+
failed by Terraform. Warnings are not, however.
|
|
177
|
+
|
|
178
|
+
You can add path information to your diagnostics.
|
|
179
|
+
This allows TF to display which specific field led to the error.
|
|
180
|
+
It's very helpful to the user.
|
|
181
|
+
|
|
182
|
+
## Examples
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
from typing import Optional, Type
|
|
186
|
+
import hashlib
|
|
187
|
+
|
|
188
|
+
from tf import schema, types
|
|
189
|
+
from tf.schema import Attribute, Schema
|
|
190
|
+
from tf.iface import Config, DataSource, Resource, State, CreateContext, ReadContext, UpdateContext, DeleteContext
|
|
191
|
+
from tf.provider import Provider
|
|
192
|
+
from tf.runner import run_provider
|
|
193
|
+
from tf.utils import Diagnostics
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class HasherProvider(Provider):
|
|
197
|
+
def __init__(self):
|
|
198
|
+
self.salt = b""
|
|
199
|
+
|
|
200
|
+
def get_model_prefix(self) -> str:
|
|
201
|
+
return "hasher_"
|
|
202
|
+
|
|
203
|
+
def full_name(self) -> str:
|
|
204
|
+
return "tf.example.com/hasher/hasher"
|
|
205
|
+
|
|
206
|
+
def get_provider_schema(self, diags: Diagnostics) -> schema.Schema:
|
|
207
|
+
return schema.Schema(
|
|
208
|
+
version=1,
|
|
209
|
+
attributes=[
|
|
210
|
+
Attribute("salt", types.String(), required=True),
|
|
211
|
+
]
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def validate_config(self, diags: Diagnostics, config: Config):
|
|
215
|
+
if len(config["salt"]) < 8:
|
|
216
|
+
diags.add_error("salt", "Salt must be at least 8 characters long")
|
|
217
|
+
|
|
218
|
+
def configure_provider(self, diags: Diagnostics, config: Config):
|
|
219
|
+
self.salt = config["salt"].encode()
|
|
220
|
+
|
|
221
|
+
def get_data_sources(self) -> list[Type[DataSource]]:
|
|
222
|
+
return []
|
|
223
|
+
|
|
224
|
+
def get_resources(self) -> list[Type[Resource]]:
|
|
225
|
+
return [Md5HashResource]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class Md5HashResource(Resource):
|
|
229
|
+
def __init__(self, provider: HasherProvider):
|
|
230
|
+
self.provider = provider
|
|
231
|
+
|
|
232
|
+
@classmethod
|
|
233
|
+
def get_name(cls) -> str:
|
|
234
|
+
return "md5_hash"
|
|
235
|
+
|
|
236
|
+
@classmethod
|
|
237
|
+
def get_schema(cls) -> Schema:
|
|
238
|
+
return Schema(
|
|
239
|
+
attributes=[
|
|
240
|
+
Attribute("input", types.String(), required=True),
|
|
241
|
+
Attribute("output", types.String(), computed=True),
|
|
242
|
+
]
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
def create(self, ctx: CreateContext, planned_state: State) -> State:
|
|
246
|
+
return {
|
|
247
|
+
"input": planned_state["input"],
|
|
248
|
+
"output": hashlib.md5(self.provider.salt + planned_state["input"].encode()).hexdigest()
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
def read(self, ctx: ReadContext, current_state: State) -> State:
|
|
252
|
+
# Normally we would have to talk to a remove server, but this is local
|
|
253
|
+
return {"input": current_state["input"], "output": current_state["output"]}
|
|
254
|
+
|
|
255
|
+
def update(self, ctx: UpdateContext, current_state: State, planned_state: State) -> State:
|
|
256
|
+
return {
|
|
257
|
+
"input": planned_state["input"],
|
|
258
|
+
"output": hashlib.md5(self.provider.salt + planned_state["input"].encode()).hexdigest()
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
def delete(self, ctx: DeleteContext, current_state: State) -> Optional[State]:
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
if __name__ == "__main__":
|
|
265
|
+
provider = HasherProvider()
|
|
266
|
+
run_provider(provider)
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Then we could consume this in Terraform like so:
|
|
270
|
+
|
|
271
|
+
```hcl
|
|
272
|
+
provider "hasher" {
|
|
273
|
+
salt = "123456789"
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
resource "hasher_md5_hash" "myhash" {
|
|
277
|
+
input = "hello"
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
output "hash" {
|
|
281
|
+
value = hasher_md5_hash.myhash.output
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
tf-0.1.0/README.md
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# Python TF Plugin Framework
|
|
2
|
+
|
|
3
|
+
This package acts as an interface for writing a Terraform/OpenTofu ("TF")
|
|
4
|
+
provider in Python.
|
|
5
|
+
This package frees you of the toil of interfacing with the TF type system,
|
|
6
|
+
implementing the Go Plugin Protocol, implementing the TF Plugin Protocol, and
|
|
7
|
+
unbundling compound API calls.
|
|
8
|
+
|
|
9
|
+
Instead, you can simply implement Create, Read, Update, and Delete operations
|
|
10
|
+
using idiomatic Python for each of the resource types you want to support.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
This package is available on PyPI, and can be installed using pip.
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install tf
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Using the Framework
|
|
21
|
+
|
|
22
|
+
There are three primary interfaces in this framework:
|
|
23
|
+
|
|
24
|
+
1. **Provider** - By implementing this interface, you can define
|
|
25
|
+
a new provider. This defines its own schema, and supplies
|
|
26
|
+
resource and data source classes to the framework.
|
|
27
|
+
1. **Data Source** - This interface is used to define a data source, which
|
|
28
|
+
is a read-only object that can be used to query information
|
|
29
|
+
from the provider or backing service.
|
|
30
|
+
1. **Resource** - This interface is used to define a resource, which
|
|
31
|
+
is a read-write object that can be used to create, update,
|
|
32
|
+
and delete resources in the provider or backing service.
|
|
33
|
+
Resources represent full "ownership" of the underlying object.
|
|
34
|
+
This is the primary type you will use to interact with the system.
|
|
35
|
+
|
|
36
|
+
To use this interface, create one class implemented `Provider`, and any number
|
|
37
|
+
of classes implementing `Resource` and `DataSource`.
|
|
38
|
+
|
|
39
|
+
Then, call `run_provider` with an instance of your provider class. A basic
|
|
40
|
+
main function might look like:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import sys
|
|
44
|
+
|
|
45
|
+
from tf import runner
|
|
46
|
+
from mypackage import MyProvider
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main():
|
|
50
|
+
provider = MyProvider()
|
|
51
|
+
runner.run_provider(provider, sys.argv)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Entry Point Name
|
|
55
|
+
|
|
56
|
+
TF requires a specific naming convention for the provider. Your executable
|
|
57
|
+
must be named in the form of `terraform-provider-<providername>`.
|
|
58
|
+
This means that you must your [entrypoint](https://setuptools.pypa.io/en/latest/userguide/entry_point.html)
|
|
59
|
+
similarly.
|
|
60
|
+
|
|
61
|
+
```toml filename="pyproject.toml"
|
|
62
|
+
[project.scripts]
|
|
63
|
+
terraform-provider-myprovider = "mypackage.main:main"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### TF Developer Overrides
|
|
67
|
+
|
|
68
|
+
In order to get TF to use your provider, you must tell TF to run your provider from a custom path.
|
|
69
|
+
|
|
70
|
+
This is done by editing the `~/.terraformrc` or `~/.tofurc` file,
|
|
71
|
+
and setting the path to your virtual environment's `bin` directory (which contains the `terraform-provider-myprovider` script).
|
|
72
|
+
|
|
73
|
+
```hcl filename="~/.terraformrc"
|
|
74
|
+
provider_installation {
|
|
75
|
+
dev_overrides {
|
|
76
|
+
"tf.mydomain.com/mypackage" = "/path/to/your/.venv/bin"
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
direct {}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Using the Provider
|
|
84
|
+
|
|
85
|
+
Now you can use your provider in Terraform by specifying it in the `provider` block.
|
|
86
|
+
|
|
87
|
+
```hcl filename="main.tf"
|
|
88
|
+
terraform {
|
|
89
|
+
required_providers {
|
|
90
|
+
myprovider = { source = "tf.mydomain.com/mypackage"}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
provider "myprovider" {}
|
|
95
|
+
|
|
96
|
+
resource "myprovider_myresource" "myresource" {
|
|
97
|
+
# ...
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Attributes
|
|
102
|
+
|
|
103
|
+
Attributes are the fields that an element exposes to the user to either set or read.
|
|
104
|
+
They take a name, a type, and a set of flags.
|
|
105
|
+
|
|
106
|
+
Attributes can be a combination of `required`, `computed`, and `optional`.
|
|
107
|
+
The values of these flags determine how the attribute is treated by TF and the framework.
|
|
108
|
+
|
|
109
|
+
| Required | Computed | Optional | Behavior |
|
|
110
|
+
|:--------:|:--------:|:--------:|------------------------------------------------------------------------------------------|
|
|
111
|
+
| | | | _Invalid combination._ You must have at least one flag set. |
|
|
112
|
+
| | | X | Fields may be set. TODO: Have default values. |
|
|
113
|
+
| | X | | Computed fields are read-only, value is set by the server and cannot be set by the user. |
|
|
114
|
+
| | X | X | Field may be set. If not, uses value from server. |
|
|
115
|
+
| X | | | Required fields must be present in the configuration. | |
|
|
116
|
+
| X | | X | _Invalid combination._ |
|
|
117
|
+
| X | X | | _Invalid combination._ |
|
|
118
|
+
| X | X | X | _Invalid combination._ |
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
## Types
|
|
122
|
+
|
|
123
|
+
This framework takes care to map Python types to TF types as closely as possible.
|
|
124
|
+
When you are writing element CRUD operations, you can consume and emit normal Python types
|
|
125
|
+
in the State dictionaries.
|
|
126
|
+
|
|
127
|
+
This framework handles the conversion to and from TF types and semantic equivalents.
|
|
128
|
+
|
|
129
|
+
| Python Type | TF Type | Framework Type | Notes |
|
|
130
|
+
|------------------|----------|-----------------------|-----------------------------------------------------------|
|
|
131
|
+
| `str` | `string` | `String` | |
|
|
132
|
+
| `int`, `float` | `number` | `Integer` | |
|
|
133
|
+
| `bool` | `bool` | `Bool` | |
|
|
134
|
+
| `Dict[str, Any]` | `string` | `NormalizedJson` | Key order and whitespace are ignored for diff comparison. |
|
|
135
|
+
|
|
136
|
+
For `NormalizedJson` in particular, the framework will pass in `dict` and expect `dict` back.
|
|
137
|
+
That being said, if you are heavily editing a prettified JSON file and using that as
|
|
138
|
+
attribute input, you should wrap it in `jsonencode(jsondecode(file("myfile.json")))`
|
|
139
|
+
to allow Terraform to strip the file before it is passed to your provider.
|
|
140
|
+
Otherwise, the state will be ugly and will change every time you make whitespace
|
|
141
|
+
changes to the file.
|
|
142
|
+
|
|
143
|
+
## Errors
|
|
144
|
+
|
|
145
|
+
All errors are reporting using `Diagnostics`.
|
|
146
|
+
This parameter is passed into most operations, and you can
|
|
147
|
+
add warnings or errors.
|
|
148
|
+
|
|
149
|
+
Be aware: Operations that add error diagnostics will be considered
|
|
150
|
+
failed by Terraform. Warnings are not, however.
|
|
151
|
+
|
|
152
|
+
You can add path information to your diagnostics.
|
|
153
|
+
This allows TF to display which specific field led to the error.
|
|
154
|
+
It's very helpful to the user.
|
|
155
|
+
|
|
156
|
+
## Examples
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
from typing import Optional, Type
|
|
160
|
+
import hashlib
|
|
161
|
+
|
|
162
|
+
from tf import schema, types
|
|
163
|
+
from tf.schema import Attribute, Schema
|
|
164
|
+
from tf.iface import Config, DataSource, Resource, State, CreateContext, ReadContext, UpdateContext, DeleteContext
|
|
165
|
+
from tf.provider import Provider
|
|
166
|
+
from tf.runner import run_provider
|
|
167
|
+
from tf.utils import Diagnostics
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class HasherProvider(Provider):
|
|
171
|
+
def __init__(self):
|
|
172
|
+
self.salt = b""
|
|
173
|
+
|
|
174
|
+
def get_model_prefix(self) -> str:
|
|
175
|
+
return "hasher_"
|
|
176
|
+
|
|
177
|
+
def full_name(self) -> str:
|
|
178
|
+
return "tf.example.com/hasher/hasher"
|
|
179
|
+
|
|
180
|
+
def get_provider_schema(self, diags: Diagnostics) -> schema.Schema:
|
|
181
|
+
return schema.Schema(
|
|
182
|
+
version=1,
|
|
183
|
+
attributes=[
|
|
184
|
+
Attribute("salt", types.String(), required=True),
|
|
185
|
+
]
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def validate_config(self, diags: Diagnostics, config: Config):
|
|
189
|
+
if len(config["salt"]) < 8:
|
|
190
|
+
diags.add_error("salt", "Salt must be at least 8 characters long")
|
|
191
|
+
|
|
192
|
+
def configure_provider(self, diags: Diagnostics, config: Config):
|
|
193
|
+
self.salt = config["salt"].encode()
|
|
194
|
+
|
|
195
|
+
def get_data_sources(self) -> list[Type[DataSource]]:
|
|
196
|
+
return []
|
|
197
|
+
|
|
198
|
+
def get_resources(self) -> list[Type[Resource]]:
|
|
199
|
+
return [Md5HashResource]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class Md5HashResource(Resource):
|
|
203
|
+
def __init__(self, provider: HasherProvider):
|
|
204
|
+
self.provider = provider
|
|
205
|
+
|
|
206
|
+
@classmethod
|
|
207
|
+
def get_name(cls) -> str:
|
|
208
|
+
return "md5_hash"
|
|
209
|
+
|
|
210
|
+
@classmethod
|
|
211
|
+
def get_schema(cls) -> Schema:
|
|
212
|
+
return Schema(
|
|
213
|
+
attributes=[
|
|
214
|
+
Attribute("input", types.String(), required=True),
|
|
215
|
+
Attribute("output", types.String(), computed=True),
|
|
216
|
+
]
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def create(self, ctx: CreateContext, planned_state: State) -> State:
|
|
220
|
+
return {
|
|
221
|
+
"input": planned_state["input"],
|
|
222
|
+
"output": hashlib.md5(self.provider.salt + planned_state["input"].encode()).hexdigest()
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
def read(self, ctx: ReadContext, current_state: State) -> State:
|
|
226
|
+
# Normally we would have to talk to a remove server, but this is local
|
|
227
|
+
return {"input": current_state["input"], "output": current_state["output"]}
|
|
228
|
+
|
|
229
|
+
def update(self, ctx: UpdateContext, current_state: State, planned_state: State) -> State:
|
|
230
|
+
return {
|
|
231
|
+
"input": planned_state["input"],
|
|
232
|
+
"output": hashlib.md5(self.provider.salt + planned_state["input"].encode()).hexdigest()
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
def delete(self, ctx: DeleteContext, current_state: State) -> Optional[State]:
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
if __name__ == "__main__":
|
|
239
|
+
provider = HasherProvider()
|
|
240
|
+
run_provider(provider)
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Then we could consume this in Terraform like so:
|
|
244
|
+
|
|
245
|
+
```hcl
|
|
246
|
+
provider "hasher" {
|
|
247
|
+
salt = "123456789"
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
resource "hasher_md5_hash" "myhash" {
|
|
251
|
+
input = "hello"
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
output "hash" {
|
|
255
|
+
value = hasher_md5_hash.myhash.output
|
|
256
|
+
}
|
|
257
|
+
```
|
tf-0.1.0/pyproject.toml
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "tf"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python Terraform Provider framework"
|
|
5
|
+
authors = ["Hunter Fernandes <hunter@hfernandes.com>"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
keywords = ["opentofu", "terraform", "provider", "python"]
|
|
9
|
+
homepage = "https://github.com/hfern/tf"
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Operating System :: OS Independent",
|
|
15
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Programming Language :: Python",
|
|
20
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
[tool.poetry.dependencies]
|
|
25
|
+
python = "^3.11"
|
|
26
|
+
msgpack = "^1.1.0"
|
|
27
|
+
cryptography = ">43"
|
|
28
|
+
grpcio = "^1.67.1"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
[tool.poetry.group.dev.dependencies]
|
|
32
|
+
grpcio-tools = "^1.67.1"
|
|
33
|
+
ruff = "^0.7.2"
|
|
34
|
+
coverage = "^7.6.4"
|
|
35
|
+
pyre-check = "^0.9.23"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["poetry-core"]
|
|
40
|
+
build-backend = "poetry.core.masonry.api"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
[tool.ruff]
|
|
44
|
+
line-length = 120
|
|
45
|
+
target-version = "py311"
|
|
46
|
+
exclude = ["tf/gen"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
[tool.ruff.lint]
|
|
50
|
+
extend-select = ["I"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
[tool.coverage.run]
|
|
54
|
+
branch = true
|
|
55
|
+
omit = [
|
|
56
|
+
"tf/gen/*",
|
|
57
|
+
"*/tests/*",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
[tool.coverage.report]
|
|
61
|
+
show_missing = true
|
|
62
|
+
include = [
|
|
63
|
+
"tf/*",
|
|
64
|
+
]
|
tf-0.1.0/tf/__init__.py
ADDED
|
File without changes
|
tf-0.1.0/tf/blocks.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from tf.schema import Block, NestedBlock, NestMode
|
|
4
|
+
|
|
5
|
+
# class SingleNestedBlock(NestedBlock):
|
|
6
|
+
# def __init__(self, type_name: str, block: Block, required: Optional[bool] = False):
|
|
7
|
+
# more = {"min_items": 1, "max_items": 1} if required else {}
|
|
8
|
+
# super().__init__(type_name, NestMode.Single, block, **more)
|
|
9
|
+
#
|
|
10
|
+
# def encode(self, value: Any) -> Any:
|
|
11
|
+
# from tf.provider import _encode_state_d
|
|
12
|
+
#
|
|
13
|
+
# return _encode_state_d(self._amap(), self._bmap(), value, None)
|
|
14
|
+
#
|
|
15
|
+
# def decode(self, value: Any) -> Any:
|
|
16
|
+
# from tf.provider import _decode_state
|
|
17
|
+
#
|
|
18
|
+
# return _decode_state(self._amap(), self._bmap(), value)[1]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SetNestedBlock(NestedBlock):
|
|
22
|
+
# State is encoded as a list of dicts, where the dicts are the substates
|
|
23
|
+
|
|
24
|
+
def __init__(self, type_name: str, block: Block):
|
|
25
|
+
super().__init__(type_name, NestMode.Set, block)
|
|
26
|
+
|
|
27
|
+
def encode(self, value: Any) -> Any:
|
|
28
|
+
from tf.provider import _encode_state_d
|
|
29
|
+
|
|
30
|
+
return [_encode_state_d(self._amap(), self._bmap(), v, None) for v in value]
|
|
31
|
+
|
|
32
|
+
def decode(self, value: Any) -> Any:
|
|
33
|
+
from tf.provider import _decode_state
|
|
34
|
+
|
|
35
|
+
return [_decode_state(self._amap(), self._bmap(), v)[1] for v in value]
|
|
36
|
+
|
|
37
|
+
def semantically_equal(self, a_decoded, b_decoded) -> bool:
|
|
38
|
+
# Since this is a set, we turn the block into a tuple
|
|
39
|
+
# and then compare tuples
|
|
40
|
+
# Kinda gross?
|
|
41
|
+
# TODO(Hunter): Support nested nested blocks instead of just attrs
|
|
42
|
+
# You know, doesn't this kind of have a bug in it?
|
|
43
|
+
# Two elements should be equal if they have the same SEMANTIC equality
|
|
44
|
+
|
|
45
|
+
if len(a_decoded) != len(b_decoded):
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
if len(a_decoded) == 0:
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
def to_tuple(d):
|
|
52
|
+
return tuple([d.get(attr.name, None) for attr in self.block.attributes])
|
|
53
|
+
|
|
54
|
+
a_tuples = [to_tuple(d) for d in a_decoded]
|
|
55
|
+
b_tuples = [to_tuple(d) for d in b_decoded]
|
|
56
|
+
|
|
57
|
+
# Each tuple of a is in b and vice versa
|
|
58
|
+
for a in a_tuples:
|
|
59
|
+
if a not in b_tuples:
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
for b in b_tuples:
|
|
63
|
+
if b not in a_tuples:
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
return True
|