spaceforge 0.1.0.dev0__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.
- spaceforge/README.md +279 -0
- spaceforge/__init__.py +23 -0
- spaceforge/__main__.py +33 -0
- spaceforge/_version.py +81 -0
- spaceforge/cls.py +198 -0
- spaceforge/cls_test.py +17 -0
- spaceforge/generator.py +362 -0
- spaceforge/generator_test.py +671 -0
- spaceforge/plugin.py +275 -0
- spaceforge/plugin_test.py +621 -0
- spaceforge/runner.py +115 -0
- spaceforge/runner_test.py +605 -0
- spaceforge/schema.json +371 -0
- spaceforge-0.1.0.dev0.dist-info/METADATA +163 -0
- spaceforge-0.1.0.dev0.dist-info/RECORD +19 -0
- spaceforge-0.1.0.dev0.dist-info/WHEEL +5 -0
- spaceforge-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- spaceforge-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
- spaceforge-0.1.0.dev0.dist-info/top_level.txt +1 -0
spaceforge/plugin.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base plugin class for Spaceforge framework.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
import urllib.request
|
|
11
|
+
from abc import ABC
|
|
12
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SpaceforgePlugin(ABC):
|
|
16
|
+
"""
|
|
17
|
+
Base class for Spacelift plugins.
|
|
18
|
+
|
|
19
|
+
Inherit from this class and implement hook methods like:
|
|
20
|
+
- after_plan()
|
|
21
|
+
- before_apply()
|
|
22
|
+
- after_apply()
|
|
23
|
+
- before_init()
|
|
24
|
+
- after_init()
|
|
25
|
+
- before_perform()
|
|
26
|
+
- after_perform()
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
__plugin_name__ = "SpaceforgePlugin"
|
|
30
|
+
__version__ = "1.0.0"
|
|
31
|
+
__author__ = "Spacelift Team"
|
|
32
|
+
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
self.logger = self._setup_logger()
|
|
35
|
+
|
|
36
|
+
self._api_token = os.environ.get("SPACELIFT_API_TOKEN", False)
|
|
37
|
+
self._spacelift_domain = os.environ.get(
|
|
38
|
+
"TF_VAR_spacelift_graphql_endpoint", False
|
|
39
|
+
)
|
|
40
|
+
self._api_enabled = self._api_token != False and self._spacelift_domain != False
|
|
41
|
+
self._workspace_root = os.environ.get("WORKSPACE_ROOT", os.getcwd())
|
|
42
|
+
|
|
43
|
+
# This should be the last thing we do in the constructor
|
|
44
|
+
# because we set api_enabled to false if the domain is set up incorrectly.
|
|
45
|
+
if self._spacelift_domain and isinstance(self._spacelift_domain, str):
|
|
46
|
+
# this must occur after we check if spacelift domain is false
|
|
47
|
+
# because the domain could be set but not start with https://
|
|
48
|
+
if self._spacelift_domain.startswith("https://"):
|
|
49
|
+
if self._spacelift_domain.endswith("/"):
|
|
50
|
+
self._spacelift_domain = self._spacelift_domain[:-1]
|
|
51
|
+
else:
|
|
52
|
+
self.logger.warning(
|
|
53
|
+
"SPACELIFT_DOMAIN does not start with https://, api calls will fail."
|
|
54
|
+
)
|
|
55
|
+
self._api_enabled = False
|
|
56
|
+
|
|
57
|
+
def _setup_logger(self) -> logging.Logger:
|
|
58
|
+
"""Set up logging for the plugin."""
|
|
59
|
+
|
|
60
|
+
info_color = "\033[36m"
|
|
61
|
+
debug_color = "\033[35m"
|
|
62
|
+
warn_color = "\033[33m"
|
|
63
|
+
error_color = "\033[31m"
|
|
64
|
+
end_color = "\033[0m"
|
|
65
|
+
run_id = os.environ.get("TF_VAR_spacelift_run_id", "local")
|
|
66
|
+
plugin_name = self.__plugin_name__
|
|
67
|
+
|
|
68
|
+
class ColorFormatter(logging.Formatter):
|
|
69
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
70
|
+
message = super().format(record)
|
|
71
|
+
if record.levelname == "INFO":
|
|
72
|
+
return (
|
|
73
|
+
f"{info_color}[{run_id}]{end_color} ({plugin_name}) {message}"
|
|
74
|
+
)
|
|
75
|
+
elif record.levelname == "DEBUG":
|
|
76
|
+
return (
|
|
77
|
+
f"{debug_color}[{run_id}]{end_color} ({plugin_name}) {message}"
|
|
78
|
+
)
|
|
79
|
+
elif record.levelname == "WARNING":
|
|
80
|
+
return (
|
|
81
|
+
f"{warn_color}[{run_id}]{end_color} ({plugin_name}) {message}"
|
|
82
|
+
)
|
|
83
|
+
elif record.levelname == "ERROR":
|
|
84
|
+
return (
|
|
85
|
+
f"{error_color}[{run_id}]{end_color} ({plugin_name}) {message}"
|
|
86
|
+
)
|
|
87
|
+
return message
|
|
88
|
+
|
|
89
|
+
logger = logging.getLogger(f"spaceforge.{self.__class__.__name__}")
|
|
90
|
+
if not logger.handlers:
|
|
91
|
+
handler = logging.StreamHandler()
|
|
92
|
+
logger.addHandler(handler)
|
|
93
|
+
handler.setFormatter(ColorFormatter())
|
|
94
|
+
|
|
95
|
+
# Always check for debug mode spacelift variable
|
|
96
|
+
if os.environ.get("SPACELIFT_DEBUG"):
|
|
97
|
+
logger.setLevel(logging.DEBUG)
|
|
98
|
+
else:
|
|
99
|
+
logger.setLevel(logging.INFO)
|
|
100
|
+
|
|
101
|
+
return logger
|
|
102
|
+
|
|
103
|
+
def get_available_hooks(self) -> List[str]:
|
|
104
|
+
"""
|
|
105
|
+
Get list of hook methods available in this plugin.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
List of hook method names that are implemented
|
|
109
|
+
"""
|
|
110
|
+
hook_methods = []
|
|
111
|
+
for method_name in dir(self):
|
|
112
|
+
if not method_name.startswith("_") and method_name != "get_available_hooks":
|
|
113
|
+
method = getattr(self, method_name)
|
|
114
|
+
if callable(method) and not inspect.isbuiltin(method):
|
|
115
|
+
# Check if it's a hook method (not inherited from base class)
|
|
116
|
+
if method_name in [
|
|
117
|
+
"before_init",
|
|
118
|
+
"after_init",
|
|
119
|
+
"before_plan",
|
|
120
|
+
"after_plan",
|
|
121
|
+
"before_apply",
|
|
122
|
+
"after_apply",
|
|
123
|
+
"before_perform",
|
|
124
|
+
"after_perform",
|
|
125
|
+
"before_destroy",
|
|
126
|
+
"after_destroy",
|
|
127
|
+
"after_run",
|
|
128
|
+
]:
|
|
129
|
+
hook_methods.append(method_name)
|
|
130
|
+
return hook_methods
|
|
131
|
+
|
|
132
|
+
def run_cli(
|
|
133
|
+
self, *command: str, expect_code: int = 0, print_output: bool = True
|
|
134
|
+
) -> Tuple[int, List[str], List[str]]:
|
|
135
|
+
"""
|
|
136
|
+
Run a CLI command with the given arguments.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
command: The command to run
|
|
140
|
+
*args: Positional arguments for the command
|
|
141
|
+
**kwargs: Keyword arguments for the command
|
|
142
|
+
expect_code: Expected return code
|
|
143
|
+
print_output: Whether to print the output to the logger
|
|
144
|
+
"""
|
|
145
|
+
self.logger.debug(f"Running CLI command: {' '.join(map(str, command))}")
|
|
146
|
+
|
|
147
|
+
process = subprocess.Popen(
|
|
148
|
+
command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
|
|
149
|
+
)
|
|
150
|
+
stdout, stderr = process.communicate()
|
|
151
|
+
|
|
152
|
+
stdout_lines: List[str] = []
|
|
153
|
+
stderr_lines: List[str] = []
|
|
154
|
+
if stdout is not None:
|
|
155
|
+
stdout_lines = stdout.decode("utf-8").splitlines()
|
|
156
|
+
if stderr is not None:
|
|
157
|
+
stderr_lines = stderr.decode("utf-8").splitlines()
|
|
158
|
+
|
|
159
|
+
if process.returncode != expect_code:
|
|
160
|
+
self.logger.error(f"Command failed with return code {process.returncode}")
|
|
161
|
+
if print_output:
|
|
162
|
+
for line in stdout_lines:
|
|
163
|
+
self.logger.info(line)
|
|
164
|
+
for err in stderr_lines:
|
|
165
|
+
self.logger.error(err)
|
|
166
|
+
else:
|
|
167
|
+
if print_output:
|
|
168
|
+
for line in stdout_lines:
|
|
169
|
+
self.logger.info(line)
|
|
170
|
+
|
|
171
|
+
return process.returncode, stdout_lines, stderr_lines
|
|
172
|
+
|
|
173
|
+
def query_api(
|
|
174
|
+
self, query: str, variables: Optional[Dict[str, Any]] = None
|
|
175
|
+
) -> Dict[str, Any]:
|
|
176
|
+
if not self._api_enabled:
|
|
177
|
+
self.logger.error(
|
|
178
|
+
'API is not enabled, please export "SPACELIFT_API_TOKEN" and "SPACELIFT_DOMAIN".'
|
|
179
|
+
)
|
|
180
|
+
exit(1)
|
|
181
|
+
|
|
182
|
+
headers = {
|
|
183
|
+
"Content-Type": "application/json",
|
|
184
|
+
"Authorization": f"Bearer {self._api_token}",
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
data: Dict[str, Any] = {
|
|
188
|
+
"query": query,
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if variables is not None:
|
|
192
|
+
data["variables"] = variables
|
|
193
|
+
|
|
194
|
+
req = urllib.request.Request(
|
|
195
|
+
f"{self._spacelift_domain}/graphql",
|
|
196
|
+
json.dumps(data).encode("utf-8"),
|
|
197
|
+
headers,
|
|
198
|
+
)
|
|
199
|
+
with urllib.request.urlopen(req) as response:
|
|
200
|
+
resp: Dict[str, Any] = json.loads(response.read().decode("utf-8"))
|
|
201
|
+
|
|
202
|
+
if "errors" in resp:
|
|
203
|
+
self.logger.error(f"Error: {resp['errors']}")
|
|
204
|
+
return resp
|
|
205
|
+
else:
|
|
206
|
+
return resp
|
|
207
|
+
|
|
208
|
+
def get_plan_json(self) -> Optional[Dict[str, Any]]:
|
|
209
|
+
plan_json = f"{self._workspace_root}/spacelift.plan.json"
|
|
210
|
+
if not os.path.exists(plan_json):
|
|
211
|
+
self.logger.error("spacelift.plan.json does not exist.")
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
with open(plan_json) as f:
|
|
215
|
+
data: Dict[str, Any] = json.load(f)
|
|
216
|
+
return data
|
|
217
|
+
|
|
218
|
+
def get_state_before_json(self) -> Optional[Dict[str, Any]]:
|
|
219
|
+
plan_json = f"{self._workspace_root}/spacelift.state.before.json"
|
|
220
|
+
if not os.path.exists(plan_json):
|
|
221
|
+
self.logger.error("spacelift.state.before.json does not exist.")
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
with open(plan_json) as f:
|
|
225
|
+
data: Dict[str, Any] = json.load(f)
|
|
226
|
+
return data
|
|
227
|
+
|
|
228
|
+
def send_markdown(self, markdown: str) -> None:
|
|
229
|
+
# TODO
|
|
230
|
+
print(markdown)
|
|
231
|
+
|
|
232
|
+
# Hook methods - override these in your plugin
|
|
233
|
+
def before_init(self) -> None:
|
|
234
|
+
"""Override this method to run code before Terraform init."""
|
|
235
|
+
pass
|
|
236
|
+
|
|
237
|
+
def after_init(self) -> None:
|
|
238
|
+
"""Override this method to run code after Terraform init."""
|
|
239
|
+
pass
|
|
240
|
+
|
|
241
|
+
def before_plan(self) -> None:
|
|
242
|
+
"""Override this method to run code before Terraform plan."""
|
|
243
|
+
pass
|
|
244
|
+
|
|
245
|
+
def after_plan(self) -> None:
|
|
246
|
+
"""Override this method to run code after Terraform plan."""
|
|
247
|
+
pass
|
|
248
|
+
|
|
249
|
+
def before_apply(self) -> None:
|
|
250
|
+
"""Override this method to run code before Terraform apply."""
|
|
251
|
+
pass
|
|
252
|
+
|
|
253
|
+
def after_apply(self) -> None:
|
|
254
|
+
"""Override this method to run code after Terraform apply."""
|
|
255
|
+
pass
|
|
256
|
+
|
|
257
|
+
def before_perform(self) -> None:
|
|
258
|
+
"""Override this method to run code before the run performs."""
|
|
259
|
+
pass
|
|
260
|
+
|
|
261
|
+
def after_perform(self) -> None:
|
|
262
|
+
"""Override this method to run code after the run performs."""
|
|
263
|
+
pass
|
|
264
|
+
|
|
265
|
+
def before_destroy(self) -> None:
|
|
266
|
+
"""Override this method to run code before Terraform destroy."""
|
|
267
|
+
pass
|
|
268
|
+
|
|
269
|
+
def after_destroy(self) -> None:
|
|
270
|
+
"""Override this method to run code after Terraform destroy."""
|
|
271
|
+
pass
|
|
272
|
+
|
|
273
|
+
def after_run(self) -> None:
|
|
274
|
+
"""Override this method to run code after the run completes."""
|
|
275
|
+
pass
|