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/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