spaceforge 0.0.2__py3-none-any.whl → 0.0.3__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/__init__.py +12 -4
- spaceforge/__main__.py +3 -3
- spaceforge/_version.py +0 -1
- spaceforge/_version_scm.py +2 -2
- spaceforge/cls.py +8 -4
- spaceforge/conftest.py +89 -0
- spaceforge/generator.py +92 -40
- spaceforge/plugin.py +105 -12
- spaceforge/runner.py +0 -12
- spaceforge/schema.json +23 -5
- spaceforge/{generator_test.py → test_generator.py} +121 -10
- spaceforge/test_generator_binaries.py +167 -0
- spaceforge/test_generator_core.py +180 -0
- spaceforge/test_generator_hooks.py +90 -0
- spaceforge/test_generator_parameters.py +59 -0
- spaceforge/test_plugin.py +357 -0
- spaceforge/test_plugin_file_operations.py +118 -0
- spaceforge/test_plugin_hooks.py +100 -0
- spaceforge/test_plugin_inheritance.py +102 -0
- spaceforge/{runner_test.py → test_runner.py} +2 -65
- spaceforge/test_runner_cli.py +69 -0
- spaceforge/test_runner_core.py +124 -0
- spaceforge/test_runner_execution.py +169 -0
- spaceforge-0.0.3.dist-info/METADATA +595 -0
- spaceforge-0.0.3.dist-info/RECORD +31 -0
- spaceforge/plugin_test.py +0 -621
- spaceforge-0.0.2.dist-info/METADATA +0 -163
- spaceforge-0.0.2.dist-info/RECORD +0 -20
- /spaceforge/{cls_test.py → test_cls.py} +0 -0
- {spaceforge-0.0.2.dist-info → spaceforge-0.0.3.dist-info}/WHEEL +0 -0
- {spaceforge-0.0.2.dist-info → spaceforge-0.0.3.dist-info}/entry_points.txt +0 -0
- {spaceforge-0.0.2.dist-info → spaceforge-0.0.3.dist-info}/licenses/LICENSE +0 -0
- {spaceforge-0.0.2.dist-info → spaceforge-0.0.3.dist-info}/top_level.txt +0 -0
spaceforge/__init__.py
CHANGED
|
@@ -4,10 +4,18 @@ Spaceforge - Spacelift Plugin Framework
|
|
|
4
4
|
A Python framework for building Spacelift plugins with hook-based functionality.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
from ._version import get_version
|
|
8
|
-
from .cls import
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
from spaceforge._version import get_version
|
|
8
|
+
from spaceforge.cls import (
|
|
9
|
+
Binary,
|
|
10
|
+
Context,
|
|
11
|
+
MountedFile,
|
|
12
|
+
Parameter,
|
|
13
|
+
Policy,
|
|
14
|
+
Variable,
|
|
15
|
+
Webhook,
|
|
16
|
+
)
|
|
17
|
+
from spaceforge.plugin import SpaceforgePlugin
|
|
18
|
+
from spaceforge.runner import PluginRunner
|
|
11
19
|
|
|
12
20
|
__version__ = get_version()
|
|
13
21
|
__all__ = [
|
spaceforge/__main__.py
CHANGED
|
@@ -4,9 +4,9 @@ Main entry point for spaceforge module.
|
|
|
4
4
|
|
|
5
5
|
import click
|
|
6
6
|
|
|
7
|
-
from ._version import get_version
|
|
8
|
-
from .generator import generate_command
|
|
9
|
-
from .runner import runner_command
|
|
7
|
+
from spaceforge._version import get_version
|
|
8
|
+
from spaceforge.generator import generate_command
|
|
9
|
+
from spaceforge.runner import runner_command
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
@click.group()
|
spaceforge/_version.py
CHANGED
spaceforge/_version_scm.py
CHANGED
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '0.0.
|
|
32
|
-
__version_tuple__ = version_tuple = (0, 0,
|
|
31
|
+
__version__ = version = '0.0.3'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 0, 3)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
spaceforge/cls.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import uuid
|
|
1
2
|
from typing import Dict, List, Literal, Optional
|
|
2
3
|
|
|
3
4
|
from pydantic import Field
|
|
@@ -19,8 +20,7 @@ class Binary:
|
|
|
19
20
|
|
|
20
21
|
Attributes:
|
|
21
22
|
name (str): The name of the binary file.
|
|
22
|
-
|
|
23
|
-
sensitive (bool): Whether the binary file is sensitive.
|
|
23
|
+
download_urls (Dict[BinaryType, str]): A dictionary mapping binary types to their download URLs.
|
|
24
24
|
"""
|
|
25
25
|
|
|
26
26
|
name: str
|
|
@@ -38,6 +38,7 @@ class Parameter:
|
|
|
38
38
|
sensitive (bool): Whether the parameter contains sensitive information.
|
|
39
39
|
required (bool): Whether the parameter is required.
|
|
40
40
|
default (Optional[str]): The default value of the parameter, if any. (required if sensitive is False)
|
|
41
|
+
id (str): Unique identifier for the parameter.
|
|
41
42
|
"""
|
|
42
43
|
|
|
43
44
|
name: str
|
|
@@ -45,6 +46,7 @@ class Parameter:
|
|
|
45
46
|
sensitive: bool = False
|
|
46
47
|
required: bool = False
|
|
47
48
|
default: Optional[str] = None
|
|
49
|
+
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
48
50
|
|
|
49
51
|
def __post_init__(self) -> None:
|
|
50
52
|
if not self.required and self.default is None:
|
|
@@ -171,19 +173,21 @@ class PluginManifest:
|
|
|
171
173
|
A class to represent the manifest of a Spacelift plugin.
|
|
172
174
|
|
|
173
175
|
Attributes:
|
|
174
|
-
|
|
176
|
+
name (str): The name of the plugin, will be appended with a unique ID.
|
|
175
177
|
description (str): A description of the plugin.
|
|
176
178
|
author (str): The author of the plugin.
|
|
179
|
+
labels (list[str]): List of labels for the plugin.
|
|
177
180
|
parameters (list[Parameter]): List of parameters for the plugin.
|
|
178
181
|
contexts (list[Context]): List of contexts for the plugin.
|
|
179
182
|
webhooks (list[Webhook]): List of webhooks for the plugin.
|
|
180
183
|
policies (list[Policy]): List of policies for the plugin.
|
|
181
184
|
"""
|
|
182
185
|
|
|
183
|
-
|
|
186
|
+
name: str
|
|
184
187
|
version: str
|
|
185
188
|
description: str
|
|
186
189
|
author: str
|
|
190
|
+
labels: Optional[List[str]] = optional_field
|
|
187
191
|
parameters: Optional[List[Parameter]] = optional_field
|
|
188
192
|
contexts: Optional[List[Context]] = optional_field
|
|
189
193
|
webhooks: Optional[List[Webhook]] = optional_field
|
spaceforge/conftest.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Shared test fixtures for spaceforge tests."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
from typing import Dict, Generator, List
|
|
6
|
+
from unittest.mock import Mock
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from spaceforge.plugin import SpaceforgePlugin
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
def temp_dir() -> Generator[str, None, None]:
|
|
15
|
+
"""Provide a temporary directory for test files."""
|
|
16
|
+
temp_dir = tempfile.mkdtemp()
|
|
17
|
+
yield temp_dir
|
|
18
|
+
import shutil
|
|
19
|
+
|
|
20
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture
|
|
24
|
+
def test_plugin_content() -> str:
|
|
25
|
+
"""Basic test plugin content."""
|
|
26
|
+
return """
|
|
27
|
+
from spaceforge import SpaceforgePlugin, Parameter
|
|
28
|
+
|
|
29
|
+
class TestPlugin(SpaceforgePlugin):
|
|
30
|
+
__plugin_name__ = "test"
|
|
31
|
+
__version__ = "1.0.0"
|
|
32
|
+
__author__ = "Test Author"
|
|
33
|
+
|
|
34
|
+
__parameters__ = [
|
|
35
|
+
Parameter(
|
|
36
|
+
name="test_param",
|
|
37
|
+
description="Test parameter",
|
|
38
|
+
required=False,
|
|
39
|
+
default="default_value"
|
|
40
|
+
)
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
def after_plan(self) -> None:
|
|
44
|
+
pass
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.fixture
|
|
49
|
+
def test_plugin_file(temp_dir: str, test_plugin_content: str) -> str:
|
|
50
|
+
"""Create a test plugin file."""
|
|
51
|
+
plugin_path = os.path.join(temp_dir, "plugin.py")
|
|
52
|
+
with open(plugin_path, "w") as f:
|
|
53
|
+
f.write(test_plugin_content)
|
|
54
|
+
return plugin_path
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.fixture
|
|
58
|
+
def mock_env() -> Dict[str, str]:
|
|
59
|
+
"""Common test environment variables."""
|
|
60
|
+
return {
|
|
61
|
+
"SPACELIFT_API_TOKEN": "test_token",
|
|
62
|
+
"TF_VAR_spacelift_graphql_endpoint": "https://test.spacelift.io",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.fixture
|
|
67
|
+
def mock_api_response() -> Mock:
|
|
68
|
+
"""Mock API response for testing."""
|
|
69
|
+
mock_response = Mock()
|
|
70
|
+
mock_response.read.return_value = b'{"data": {"test": "result"}}'
|
|
71
|
+
return mock_response
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ExampleTestPlugin(SpaceforgePlugin):
|
|
75
|
+
"""Reusable test plugin class."""
|
|
76
|
+
|
|
77
|
+
__plugin_name__ = "example"
|
|
78
|
+
__version__ = "1.0.0"
|
|
79
|
+
__author__ = "Test"
|
|
80
|
+
|
|
81
|
+
def __init__(self) -> None:
|
|
82
|
+
super().__init__()
|
|
83
|
+
self.hook_calls: List[str] = []
|
|
84
|
+
|
|
85
|
+
def after_plan(self) -> None:
|
|
86
|
+
self.hook_calls.append("after_plan")
|
|
87
|
+
|
|
88
|
+
def before_apply(self) -> None:
|
|
89
|
+
self.hook_calls.append("before_apply")
|
spaceforge/generator.py
CHANGED
|
@@ -4,7 +4,7 @@ YAML generator for Spacelift plugins.
|
|
|
4
4
|
|
|
5
5
|
import importlib.util
|
|
6
6
|
import os
|
|
7
|
-
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type, Union
|
|
8
8
|
|
|
9
9
|
import yaml
|
|
10
10
|
|
|
@@ -46,6 +46,7 @@ class PluginGenerator:
|
|
|
46
46
|
self.plugin_class: Optional[Type[SpaceforgePlugin]] = None
|
|
47
47
|
self.plugin_instance: Optional[SpaceforgePlugin] = None
|
|
48
48
|
self.plugin_working_directory: Optional[str] = None
|
|
49
|
+
self.config: Optional[Dict[str, Any]] = None
|
|
49
50
|
|
|
50
51
|
def load_plugin(self) -> None:
|
|
51
52
|
"""Load the plugin class from the specified path."""
|
|
@@ -80,8 +81,15 @@ class PluginGenerator:
|
|
|
80
81
|
self.plugin_working_directory = (
|
|
81
82
|
"/mnt/workspace/plugins/" + plugin_class.__plugin_name__.lower()
|
|
82
83
|
)
|
|
84
|
+
self.config = {
|
|
85
|
+
"setup_virtual_env": (
|
|
86
|
+
f"cd {self.plugin_working_directory} && python -m venv ./venv && "
|
|
87
|
+
+ "source venv/bin/activate && pip install spaceforge"
|
|
88
|
+
),
|
|
89
|
+
"plugin_mounted_path": f"{self.plugin_working_directory}/{os.path.basename(self.plugin_path)}",
|
|
90
|
+
}
|
|
83
91
|
|
|
84
|
-
def get_plugin_metadata(self) -> Dict[str, str]:
|
|
92
|
+
def get_plugin_metadata(self) -> Dict[str, Union[str, List[str]]]:
|
|
85
93
|
"""Extract metadata from the plugin class."""
|
|
86
94
|
if self.plugin_class is None:
|
|
87
95
|
raise ValueError("Plugin class not loaded. Call load_plugin() first.")
|
|
@@ -97,6 +105,7 @@ class PluginGenerator:
|
|
|
97
105
|
self.plugin_class.__name__.lower().replace("plugin", ""),
|
|
98
106
|
),
|
|
99
107
|
"version": getattr(self.plugin_class, "__version__", "1.0.0"),
|
|
108
|
+
"labels": getattr(self.plugin_class, "__labels__", []),
|
|
100
109
|
"description": doc
|
|
101
110
|
or f"A Spacelift plugin built with {self.plugin_class.__name__}",
|
|
102
111
|
"author": getattr(self.plugin_class, "__author__", "Unknown"),
|
|
@@ -128,22 +137,14 @@ class PluginGenerator:
|
|
|
128
137
|
|
|
129
138
|
return hook_methods
|
|
130
139
|
|
|
131
|
-
def
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
"before_init":
|
|
137
|
-
|
|
138
|
-
]
|
|
139
|
-
}
|
|
140
|
-
mounted_files = []
|
|
141
|
-
|
|
142
|
-
# Add a virtual environment before_init hook if requirements.txt exists
|
|
143
|
-
if os.path.exists("requirements.txt"):
|
|
144
|
-
hooks["before_init"].append(
|
|
145
|
-
f"{change_to_working_directory} && python -m venv ./venv && source venv/bin/activate && pip install -r requirements.txt"
|
|
146
|
-
)
|
|
140
|
+
def _update_with_requirements(
|
|
141
|
+
self, hooks: Dict[str, List[str]], mounted_files: List[MountedFile]
|
|
142
|
+
) -> None:
|
|
143
|
+
"""Update the plugin hooks if there is a requirements.txt"""
|
|
144
|
+
if os.path.exists("requirements.txt") and self.config is not None:
|
|
145
|
+
if self.config["setup_virtual_env"] not in hooks["before_init"]:
|
|
146
|
+
hooks["before_init"].append(self.config["setup_virtual_env"])
|
|
147
|
+
hooks["before_init"].append(f"pip install -r requirements.txt")
|
|
147
148
|
# read the requirements.txt file
|
|
148
149
|
with open("requirements.txt", "r") as f:
|
|
149
150
|
mounted_files.append(
|
|
@@ -154,34 +155,77 @@ class PluginGenerator:
|
|
|
154
155
|
)
|
|
155
156
|
)
|
|
156
157
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
)
|
|
161
|
-
if os.path.exists(self.plugin_path):
|
|
158
|
+
def _update_with_python_file(self, mounted_files: List[MountedFile]) -> None:
|
|
159
|
+
"""Ensure the plugin file itself is mounted."""
|
|
160
|
+
if os.path.exists(self.plugin_path) and self.config is not None:
|
|
162
161
|
with open(self.plugin_path, "r") as f:
|
|
163
162
|
mounted_files.append(
|
|
164
163
|
MountedFile(
|
|
165
|
-
path=plugin_mounted_path,
|
|
164
|
+
path=self.config["plugin_mounted_path"],
|
|
166
165
|
content=f.read(),
|
|
167
166
|
sensitive=False,
|
|
168
167
|
)
|
|
169
168
|
)
|
|
170
169
|
|
|
171
|
-
|
|
172
|
-
if binary_cmd != "":
|
|
173
|
-
hooks["before_init"].append(binary_cmd)
|
|
174
|
-
|
|
170
|
+
def _add_spaceforge_hooks(self, hooks: Dict[str, List[str]]) -> None:
|
|
175
171
|
# Add the spaceforge hook to actually run the plugin
|
|
172
|
+
if self.config is None:
|
|
173
|
+
raise ValueError("Plugin config not set. Call load_plugin() first.")
|
|
174
|
+
|
|
176
175
|
available_hooks = self.get_available_hooks()
|
|
177
176
|
for hook in available_hooks:
|
|
178
177
|
# Ensure the hook exists in the first context
|
|
179
178
|
if hook not in hooks:
|
|
180
179
|
hooks[hook] = []
|
|
180
|
+
|
|
181
|
+
if self.config["setup_virtual_env"] not in hooks[hook]:
|
|
182
|
+
hooks[hook].append(self.config["setup_virtual_env"])
|
|
183
|
+
|
|
181
184
|
hooks[hook].append(
|
|
182
|
-
f"
|
|
185
|
+
f"cd /mnt/workspace/source/$TF_VAR_spacelift_project_root && python -m spaceforge runner --plugin-file {self.config['plugin_mounted_path']} {hook}"
|
|
183
186
|
)
|
|
184
187
|
|
|
188
|
+
def _map_variables_to_parameters(self, contexts: List[Context]) -> None:
|
|
189
|
+
for context in contexts:
|
|
190
|
+
# Get the variables from the plugin and change the value_from_parameter to the ID of the parameter
|
|
191
|
+
# based on its name.
|
|
192
|
+
if context.env is None:
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
for variable in context.env:
|
|
196
|
+
if variable.value_from_parameter:
|
|
197
|
+
parameter_name = variable.value_from_parameter
|
|
198
|
+
parameters = self.get_plugin_parameters()
|
|
199
|
+
if parameters:
|
|
200
|
+
parameter = next(
|
|
201
|
+
(
|
|
202
|
+
p
|
|
203
|
+
for p in parameters
|
|
204
|
+
if p.name == parameter_name or p.id == parameter_name
|
|
205
|
+
),
|
|
206
|
+
None,
|
|
207
|
+
)
|
|
208
|
+
if parameter:
|
|
209
|
+
variable.value_from_parameter = parameter.id
|
|
210
|
+
else:
|
|
211
|
+
raise ValueError(
|
|
212
|
+
f"Parameter {parameter_name} not found for variable {variable.key}"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def get_plugin_contexts(self) -> List[Context]:
|
|
216
|
+
"""Get context definitions from the plugin class."""
|
|
217
|
+
|
|
218
|
+
f"cd {self.plugin_working_directory}"
|
|
219
|
+
hooks: Dict[str, List[str]] = {
|
|
220
|
+
"before_init": [f"mkdir -p {self.plugin_working_directory}"]
|
|
221
|
+
}
|
|
222
|
+
mounted_files: List[MountedFile] = []
|
|
223
|
+
|
|
224
|
+
self._update_with_requirements(hooks, mounted_files)
|
|
225
|
+
self._update_with_python_file(mounted_files)
|
|
226
|
+
self._generate_binary_install_command(hooks)
|
|
227
|
+
self._add_spaceforge_hooks(hooks)
|
|
228
|
+
|
|
185
229
|
# Get the contexts and append the hooks and mounted files to it.
|
|
186
230
|
if self.plugin_class is None:
|
|
187
231
|
raise ValueError("Plugin class not loaded. Call load_plugin() first.")
|
|
@@ -208,12 +252,14 @@ class PluginGenerator:
|
|
|
208
252
|
contexts[0].hooks.update(hooks)
|
|
209
253
|
contexts[0].mounted_files.extend(mounted_files)
|
|
210
254
|
|
|
255
|
+
self._map_variables_to_parameters(contexts)
|
|
256
|
+
|
|
211
257
|
return contexts
|
|
212
258
|
|
|
213
|
-
def
|
|
259
|
+
def _generate_binary_install_command(self, hooks: Dict[str, List[str]]) -> None:
|
|
214
260
|
binaries = self.get_plugin_binaries()
|
|
215
261
|
if binaries is None:
|
|
216
|
-
return
|
|
262
|
+
return None
|
|
217
263
|
|
|
218
264
|
binary_cmd = ""
|
|
219
265
|
if len(binaries) > 0:
|
|
@@ -238,12 +284,17 @@ class PluginGenerator:
|
|
|
238
284
|
else "echo 'arm64 binary not available' && exit 1"
|
|
239
285
|
)
|
|
240
286
|
|
|
241
|
-
binary_cmd +=
|
|
242
|
-
|
|
287
|
+
binary_cmd += (
|
|
288
|
+
'([[ "$(echo "$(arch)")" == "x86_64" ]] && {} || {}) && '.format(
|
|
289
|
+
amd64_download_command, arm64_download_command
|
|
290
|
+
)
|
|
243
291
|
)
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
292
|
+
|
|
293
|
+
if binary_cmd != "":
|
|
294
|
+
binary_cmd += "cd /mnt/workspace/source/$TF_VAR_spacelift_project_root"
|
|
295
|
+
|
|
296
|
+
hooks["before_init"].append(binary_cmd)
|
|
297
|
+
return None
|
|
247
298
|
|
|
248
299
|
def get_plugin_binaries(self) -> Optional[List[Binary]]:
|
|
249
300
|
"""Get binary definitions from the plugin class."""
|
|
@@ -265,10 +316,11 @@ class PluginGenerator:
|
|
|
265
316
|
metadata = self.get_plugin_metadata()
|
|
266
317
|
|
|
267
318
|
return PluginManifest(
|
|
268
|
-
|
|
269
|
-
version=metadata.get("version", "1.0.0"),
|
|
270
|
-
description=metadata.get("description", ""),
|
|
271
|
-
author=metadata.get("author", "Unknown"),
|
|
319
|
+
name=str(metadata.get("name_prefix", "unknown")),
|
|
320
|
+
version=str(metadata.get("version", "1.0.0")),
|
|
321
|
+
description=str(metadata.get("description", "")),
|
|
322
|
+
author=str(metadata.get("author", "Unknown")),
|
|
323
|
+
labels=list(metadata.get("labels", [])),
|
|
272
324
|
parameters=self.get_plugin_parameters(),
|
|
273
325
|
contexts=self.get_plugin_contexts(),
|
|
274
326
|
webhooks=self.get_plugin_webhooks(),
|
spaceforge/plugin.py
CHANGED
|
@@ -9,7 +9,7 @@ import os
|
|
|
9
9
|
import subprocess
|
|
10
10
|
import urllib.request
|
|
11
11
|
from abc import ABC
|
|
12
|
-
from typing import Any, Dict, List, Optional, Tuple
|
|
12
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
class SpaceforgePlugin(ABC):
|
|
@@ -31,14 +31,17 @@ class SpaceforgePlugin(ABC):
|
|
|
31
31
|
__author__ = "Spacelift Team"
|
|
32
32
|
|
|
33
33
|
def __init__(self) -> None:
|
|
34
|
+
self._run_id = os.environ.get("TF_VAR_spacelift_run_id", "local")
|
|
35
|
+
self._is_local = self._run_id == "local"
|
|
34
36
|
self.logger = self._setup_logger()
|
|
35
37
|
|
|
36
|
-
self._api_token = os.environ.get("SPACELIFT_API_TOKEN"
|
|
37
|
-
self._spacelift_domain =
|
|
38
|
-
"TF_VAR_spacelift_graphql_endpoint"
|
|
38
|
+
self._api_token = os.environ.get("SPACELIFT_API_TOKEN") or False
|
|
39
|
+
self._spacelift_domain = (
|
|
40
|
+
os.environ.get("TF_VAR_spacelift_graphql_endpoint") or False
|
|
39
41
|
)
|
|
40
|
-
self._api_enabled = self._api_token
|
|
41
|
-
self._workspace_root = os.
|
|
42
|
+
self._api_enabled = bool(self._api_token and self._spacelift_domain)
|
|
43
|
+
self._workspace_root = os.getcwd()
|
|
44
|
+
self._spacelift_markdown_endpoint = None
|
|
42
45
|
|
|
43
46
|
# This should be the last thing we do in the constructor
|
|
44
47
|
# because we set api_enabled to false if the domain is set up incorrectly.
|
|
@@ -54,6 +57,11 @@ class SpaceforgePlugin(ABC):
|
|
|
54
57
|
)
|
|
55
58
|
self._api_enabled = False
|
|
56
59
|
|
|
60
|
+
if self._api_enabled:
|
|
61
|
+
self._spacelift_markdown_endpoint = self._spacelift_domain.replace(
|
|
62
|
+
"/graphql", "/worker/plugin_logs_url"
|
|
63
|
+
)
|
|
64
|
+
|
|
57
65
|
def _setup_logger(self) -> logging.Logger:
|
|
58
66
|
"""Set up logging for the plugin."""
|
|
59
67
|
|
|
@@ -62,7 +70,7 @@ class SpaceforgePlugin(ABC):
|
|
|
62
70
|
warn_color = "\033[33m"
|
|
63
71
|
error_color = "\033[31m"
|
|
64
72
|
end_color = "\033[0m"
|
|
65
|
-
run_id =
|
|
73
|
+
run_id = self._run_id
|
|
66
74
|
plugin_name = self.__plugin_name__
|
|
67
75
|
|
|
68
76
|
class ColorFormatter(logging.Formatter):
|
|
@@ -93,7 +101,7 @@ class SpaceforgePlugin(ABC):
|
|
|
93
101
|
handler.setFormatter(ColorFormatter())
|
|
94
102
|
|
|
95
103
|
# Always check for debug mode spacelift variable
|
|
96
|
-
if os.environ.get("SPACELIFT_DEBUG"):
|
|
104
|
+
if os.environ.get("SPACELIFT_DEBUG") or self._is_local:
|
|
97
105
|
logger.setLevel(logging.DEBUG)
|
|
98
106
|
else:
|
|
99
107
|
logger.setLevel(logging.INFO)
|
|
@@ -175,7 +183,7 @@ class SpaceforgePlugin(ABC):
|
|
|
175
183
|
) -> Dict[str, Any]:
|
|
176
184
|
if not self._api_enabled:
|
|
177
185
|
self.logger.error(
|
|
178
|
-
'API is not enabled, please export "SPACELIFT_API_TOKEN" and "
|
|
186
|
+
'API is not enabled, please export "SPACELIFT_API_TOKEN" and "TF_VAR_spacelift_graphql_endpoint".'
|
|
179
187
|
)
|
|
180
188
|
exit(1)
|
|
181
189
|
|
|
@@ -192,7 +200,7 @@ class SpaceforgePlugin(ABC):
|
|
|
192
200
|
data["variables"] = variables
|
|
193
201
|
|
|
194
202
|
req = urllib.request.Request(
|
|
195
|
-
|
|
203
|
+
self._spacelift_domain, # type: ignore[arg-type]
|
|
196
204
|
json.dumps(data).encode("utf-8"),
|
|
197
205
|
headers,
|
|
198
206
|
)
|
|
@@ -226,8 +234,93 @@ class SpaceforgePlugin(ABC):
|
|
|
226
234
|
return data
|
|
227
235
|
|
|
228
236
|
def send_markdown(self, markdown: str) -> None:
|
|
229
|
-
|
|
230
|
-
|
|
237
|
+
"""
|
|
238
|
+
Send a markdown message to the Spacelift run.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
markdown: The markdown content to send
|
|
242
|
+
"""
|
|
243
|
+
if self._is_local:
|
|
244
|
+
self.logger.info(
|
|
245
|
+
"Spacelift run is local. Not uploading markdown. Below is a preview of what would be sent"
|
|
246
|
+
)
|
|
247
|
+
self.logger.info(markdown)
|
|
248
|
+
return
|
|
249
|
+
|
|
250
|
+
if self._spacelift_markdown_endpoint is None:
|
|
251
|
+
self.logger.error(
|
|
252
|
+
'API is not enabled, please export "SPACELIFT_API_TOKEN" and "TF_VAR_spacelift_graphql_endpoint".'
|
|
253
|
+
)
|
|
254
|
+
exit(1)
|
|
255
|
+
|
|
256
|
+
headers = {"Authorization": f"Bearer {self._api_token}"}
|
|
257
|
+
body = {
|
|
258
|
+
"plugin_name": self.__plugin_name__,
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
# First we get the signed url for uploading
|
|
262
|
+
req = urllib.request.Request(
|
|
263
|
+
self._spacelift_markdown_endpoint,
|
|
264
|
+
json.dumps(body).encode("utf-8"),
|
|
265
|
+
headers,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
with urllib.request.urlopen(req) as response:
|
|
269
|
+
if response.status != 200:
|
|
270
|
+
self.logger.error(
|
|
271
|
+
f"Error getting signed URL for markdown upload: {response.status}"
|
|
272
|
+
)
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
raw_response = response.read().decode("utf-8")
|
|
276
|
+
self.logger.debug(raw_response)
|
|
277
|
+
resp: Dict[str, Any] = json.loads(raw_response)
|
|
278
|
+
if "url" not in resp or "headers" not in resp:
|
|
279
|
+
self.logger.error(
|
|
280
|
+
"Markdown signed url response does not contain 'url' or 'headers' key."
|
|
281
|
+
)
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
signed_url = resp["url"]
|
|
285
|
+
headers = resp["headers"]
|
|
286
|
+
headers["Content-Type"] = "text/markdown"
|
|
287
|
+
headers["Content-Length"] = str(len(markdown))
|
|
288
|
+
|
|
289
|
+
# Now we upload the markdown content to the signed URL
|
|
290
|
+
req = urllib.request.Request(
|
|
291
|
+
signed_url,
|
|
292
|
+
data=markdown.encode("utf-8"),
|
|
293
|
+
headers=headers,
|
|
294
|
+
method="PUT",
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
with urllib.request.urlopen(req) as put_response:
|
|
298
|
+
if put_response.status != 200:
|
|
299
|
+
self.logger.error(
|
|
300
|
+
f"Error uploading markdown content: {put_response.status}"
|
|
301
|
+
)
|
|
302
|
+
return
|
|
303
|
+
self.logger.debug("Markdown content uploaded successfully.")
|
|
304
|
+
|
|
305
|
+
def add_to_policy_input(self, input_name: str, data: Dict[str, Any]) -> None:
|
|
306
|
+
"""
|
|
307
|
+
Add data to the policy input for the current Spacelift run.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
input_name: The name of the input to add (will be available as input.third_party_metadata.custom.{input_name} to the policy).
|
|
311
|
+
data: Dictionary containing data to add to the policy input
|
|
312
|
+
"""
|
|
313
|
+
if self._is_local:
|
|
314
|
+
self.logger.info(
|
|
315
|
+
"Spacelift run is local. Not writing custom policy input. Below is a preview of what would be written"
|
|
316
|
+
)
|
|
317
|
+
self.logger.info(json.dumps(data, indent=2))
|
|
318
|
+
return
|
|
319
|
+
|
|
320
|
+
with open(
|
|
321
|
+
f"{self._workspace_root}/{input_name}.custom.spacelift.json", "w"
|
|
322
|
+
) as f:
|
|
323
|
+
f.write(json.dumps(data))
|
|
231
324
|
|
|
232
325
|
# Hook methods - override these in your plugin
|
|
233
326
|
def before_init(self) -> None:
|
spaceforge/runner.py
CHANGED
|
@@ -4,7 +4,6 @@ Plugin runner for executing hook methods.
|
|
|
4
4
|
|
|
5
5
|
import importlib.util
|
|
6
6
|
import os
|
|
7
|
-
import sys
|
|
8
7
|
from typing import Optional
|
|
9
8
|
|
|
10
9
|
|
|
@@ -102,14 +101,3 @@ def runner_command(hook_name: str, plugin_file: str) -> None:
|
|
|
102
101
|
"""
|
|
103
102
|
runner = PluginRunner(plugin_file)
|
|
104
103
|
runner.run_hook(hook_name)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def main() -> None:
|
|
108
|
-
"""Legacy main entry point for backward compatibility."""
|
|
109
|
-
if len(sys.argv) != 2:
|
|
110
|
-
print("Usage: python -m spaceforge.runner <hook_name>")
|
|
111
|
-
sys.exit(1)
|
|
112
|
-
|
|
113
|
-
hook_name = sys.argv[1]
|
|
114
|
-
runner = PluginRunner()
|
|
115
|
-
runner.run_hook(hook_name)
|
spaceforge/schema.json
CHANGED
|
@@ -118,7 +118,7 @@
|
|
|
118
118
|
"type": "object"
|
|
119
119
|
},
|
|
120
120
|
"Parameter": {
|
|
121
|
-
"description": "A class to represent a parameter with a name and value.\n\nAttributes:\n name (str): The name of the parameter.\n description (str): A description of the parameter.\n sensitive (bool): Whether the parameter contains sensitive information.\n required (bool): Whether the parameter is required.\n default (Optional[str]): The default value of the parameter, if any. (required if sensitive is False)",
|
|
121
|
+
"description": "A class to represent a parameter with a name and value.\n\nAttributes:\n name (str): The name of the parameter.\n description (str): A description of the parameter.\n sensitive (bool): Whether the parameter contains sensitive information.\n required (bool): Whether the parameter is required.\n default (Optional[str]): The default value of the parameter, if any. (required if sensitive is False)\n id (str): Unique identifier for the parameter.",
|
|
122
122
|
"properties": {
|
|
123
123
|
"name": {
|
|
124
124
|
"title": "Name",
|
|
@@ -149,6 +149,10 @@
|
|
|
149
149
|
],
|
|
150
150
|
"default": null,
|
|
151
151
|
"title": "Default"
|
|
152
|
+
},
|
|
153
|
+
"id": {
|
|
154
|
+
"title": "Id",
|
|
155
|
+
"type": "string"
|
|
152
156
|
}
|
|
153
157
|
},
|
|
154
158
|
"required": [
|
|
@@ -285,10 +289,10 @@
|
|
|
285
289
|
"type": "object"
|
|
286
290
|
}
|
|
287
291
|
},
|
|
288
|
-
"description": "A class to represent the manifest of a Spacelift plugin.\n\nAttributes:\n
|
|
292
|
+
"description": "A class to represent the manifest of a Spacelift plugin.\n\nAttributes:\n name (str): The name of the plugin, will be appended with a unique ID.\n description (str): A description of the plugin.\n author (str): The author of the plugin.\n labels (list[str]): List of labels for the plugin.\n parameters (list[Parameter]): List of parameters for the plugin.\n contexts (list[Context]): List of contexts for the plugin.\n webhooks (list[Webhook]): List of webhooks for the plugin.\n policies (list[Policy]): List of policies for the plugin.",
|
|
289
293
|
"properties": {
|
|
290
|
-
"
|
|
291
|
-
"title": "Name
|
|
294
|
+
"name": {
|
|
295
|
+
"title": "Name",
|
|
292
296
|
"type": "string"
|
|
293
297
|
},
|
|
294
298
|
"version": {
|
|
@@ -303,6 +307,20 @@
|
|
|
303
307
|
"title": "Author",
|
|
304
308
|
"type": "string"
|
|
305
309
|
},
|
|
310
|
+
"labels": {
|
|
311
|
+
"anyOf": [
|
|
312
|
+
{
|
|
313
|
+
"items": {
|
|
314
|
+
"type": "string"
|
|
315
|
+
},
|
|
316
|
+
"type": "array"
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
"type": "null"
|
|
320
|
+
}
|
|
321
|
+
],
|
|
322
|
+
"title": "Labels"
|
|
323
|
+
},
|
|
306
324
|
"parameters": {
|
|
307
325
|
"anyOf": [
|
|
308
326
|
{
|
|
@@ -361,7 +379,7 @@
|
|
|
361
379
|
}
|
|
362
380
|
},
|
|
363
381
|
"required": [
|
|
364
|
-
"
|
|
382
|
+
"name",
|
|
365
383
|
"version",
|
|
366
384
|
"description",
|
|
367
385
|
"author"
|