spaceforge 0.0.3__tar.gz → 0.0.4__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.
Files changed (54) hide show
  1. {spaceforge-0.0.3 → spaceforge-0.0.4}/PKG-INFO +11 -1
  2. {spaceforge-0.0.3 → spaceforge-0.0.4}/README.md +9 -0
  3. {spaceforge-0.0.3 → spaceforge-0.0.4}/plugins/sops/plugin.yaml +53 -4
  4. {spaceforge-0.0.3 → spaceforge-0.0.4}/plugins/wiz/plugin.py +1 -1
  5. {spaceforge-0.0.3 → spaceforge-0.0.4}/plugins/wiz/plugin.yaml +55 -5
  6. {spaceforge-0.0.3 → spaceforge-0.0.4}/pyproject.toml +1 -0
  7. {spaceforge-0.0.3 → spaceforge-0.0.4}/setup.py +1 -0
  8. {spaceforge-0.0.3 → spaceforge-0.0.4}/spaceforge/_version_scm.py +3 -3
  9. {spaceforge-0.0.3 → spaceforge-0.0.4}/spaceforge/cls.py +8 -8
  10. {spaceforge-0.0.3 → spaceforge-0.0.4}/spaceforge/generator.py +53 -40
  11. {spaceforge-0.0.3 → spaceforge-0.0.4}/spaceforge/plugin.py +2 -1
  12. {spaceforge-0.0.3 → spaceforge-0.0.4}/spaceforge/schema.json +15 -24
  13. spaceforge-0.0.4/spaceforge/templates/binary_install.sh.j2 +23 -0
  14. spaceforge-0.0.4/spaceforge/templates/ensure_spaceforge_and_run.sh.j2 +22 -0
  15. {spaceforge-0.0.3 → spaceforge-0.0.4}/spaceforge/test_generator.py +159 -58
  16. {spaceforge-0.0.3 → spaceforge-0.0.4}/spaceforge/test_generator_binaries.py +40 -13
  17. {spaceforge-0.0.3 → spaceforge-0.0.4}/spaceforge.egg-info/PKG-INFO +11 -1
  18. {spaceforge-0.0.3 → spaceforge-0.0.4}/spaceforge.egg-info/SOURCES.txt +3 -1
  19. {spaceforge-0.0.3 → spaceforge-0.0.4}/spaceforge.egg-info/requires.txt +1 -0
  20. {spaceforge-0.0.3 → spaceforge-0.0.4}/test.sh +1 -1
  21. {spaceforge-0.0.3 → spaceforge-0.0.4}/.github/workflows/ci.yml +0 -0
  22. {spaceforge-0.0.3 → spaceforge-0.0.4}/.github/workflows/release.yml +0 -0
  23. {spaceforge-0.0.3 → spaceforge-0.0.4}/.gitignore +0 -0
  24. {spaceforge-0.0.3 → spaceforge-0.0.4}/LICENSE +0 -0
  25. {spaceforge-0.0.3 → spaceforge-0.0.4}/MANIFEST.in +0 -0
  26. {spaceforge-0.0.3 → spaceforge-0.0.4}/go.mod +0 -0
  27. {spaceforge-0.0.3 → spaceforge-0.0.4}/plugins/infracost/plugin.py +0 -0
  28. {spaceforge-0.0.3 → spaceforge-0.0.4}/plugins/infracost/plugin.yaml +0 -0
  29. {spaceforge-0.0.3 → spaceforge-0.0.4}/plugins/sops/plugin.py +0 -0
  30. {spaceforge-0.0.3 → spaceforge-0.0.4}/plugins/sops/requirements.txt +0 -0
  31. {spaceforge-0.0.3 → spaceforge-0.0.4}/setup.cfg +0 -0
  32. {spaceforge-0.0.3 → spaceforge-0.0.4}/spaceforge/README.md +0 -0
  33. {spaceforge-0.0.3 → spaceforge-0.0.4}/spaceforge/__init__.py +0 -0
  34. {spaceforge-0.0.3 → spaceforge-0.0.4}/spaceforge/__main__.py +0 -0
  35. {spaceforge-0.0.3 → spaceforge-0.0.4}/spaceforge/_version.py +0 -0
  36. {spaceforge-0.0.3 → spaceforge-0.0.4}/spaceforge/conftest.py +0 -0
  37. {spaceforge-0.0.3 → spaceforge-0.0.4}/spaceforge/runner.py +0 -0
  38. {spaceforge-0.0.3 → spaceforge-0.0.4}/spaceforge/test_cls.py +0 -0
  39. {spaceforge-0.0.3 → spaceforge-0.0.4}/spaceforge/test_generator_core.py +0 -0
  40. {spaceforge-0.0.3 → spaceforge-0.0.4}/spaceforge/test_generator_hooks.py +0 -0
  41. {spaceforge-0.0.3 → spaceforge-0.0.4}/spaceforge/test_generator_parameters.py +0 -0
  42. {spaceforge-0.0.3 → spaceforge-0.0.4}/spaceforge/test_plugin.py +0 -0
  43. {spaceforge-0.0.3 → spaceforge-0.0.4}/spaceforge/test_plugin_file_operations.py +0 -0
  44. {spaceforge-0.0.3 → spaceforge-0.0.4}/spaceforge/test_plugin_hooks.py +0 -0
  45. {spaceforge-0.0.3 → spaceforge-0.0.4}/spaceforge/test_plugin_inheritance.py +0 -0
  46. {spaceforge-0.0.3 → spaceforge-0.0.4}/spaceforge/test_runner.py +0 -0
  47. {spaceforge-0.0.3 → spaceforge-0.0.4}/spaceforge/test_runner_cli.py +0 -0
  48. {spaceforge-0.0.3 → spaceforge-0.0.4}/spaceforge/test_runner_core.py +0 -0
  49. {spaceforge-0.0.3 → spaceforge-0.0.4}/spaceforge/test_runner_execution.py +0 -0
  50. {spaceforge-0.0.3 → spaceforge-0.0.4}/spaceforge.egg-info/dependency_links.txt +0 -0
  51. {spaceforge-0.0.3 → spaceforge-0.0.4}/spaceforge.egg-info/entry_points.txt +0 -0
  52. {spaceforge-0.0.3 → spaceforge-0.0.4}/spaceforge.egg-info/not-zip-safe +0 -0
  53. {spaceforge-0.0.3 → spaceforge-0.0.4}/spaceforge.egg-info/top_level.txt +0 -0
  54. {spaceforge-0.0.3 → spaceforge-0.0.4}/templates.go +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spaceforge
3
- Version: 0.0.3
3
+ Version: 0.0.4
4
4
  Summary: A Python framework for building Spacelift plugins
5
5
  Home-page: https://github.com/spacelift-io/plugins
6
6
  Author: Spacelift
@@ -28,6 +28,7 @@ License-File: LICENSE
28
28
  Requires-Dist: PyYAML>=6.0
29
29
  Requires-Dist: click>=8.0.0
30
30
  Requires-Dist: pydantic>=2.11.7
31
+ Requires-Dist: Jinja2>=3.1.0
31
32
  Provides-Extra: dev
32
33
  Requires-Dist: pytest>=6.0; extra == "dev"
33
34
  Requires-Dist: pytest-cov; extra == "dev"
@@ -584,6 +585,15 @@ export SPACEFORGE_PARAM_SEVERITY_THRESHOLD="high"
584
585
  spaceforge runner after_plan
585
586
  ```
586
587
 
588
+ ## Speeding up plugin execution
589
+
590
+ There are a few things you can do to speed up plugin execution.
591
+
592
+ 1. Ensure your runner has `spaceforge` preinstalled. This will avoid the overhead of installing it during the run. (15-30 seconds)
593
+ 2. If youre using binaries, we will only install the binary if its not found. You can gain a few seconds by ensuring its already on the runner.
594
+ 3. If your plugin has a lot of dependencies, consider using a prebuilt runner image with your plugin and its dependencies installed. This avoids the overhead of installing them during each run.
595
+ 4. Ensure your runner has enough core resources (CPU, memory) to handle the plugin execution efficiently. If your plugin is resource-intensive, consider using a more powerful runner.
596
+
587
597
  ## Next Steps
588
598
 
589
599
  1. **Install spaceforge:** `pip install spaceforge`
@@ -540,6 +540,15 @@ export SPACEFORGE_PARAM_SEVERITY_THRESHOLD="high"
540
540
  spaceforge runner after_plan
541
541
  ```
542
542
 
543
+ ## Speeding up plugin execution
544
+
545
+ There are a few things you can do to speed up plugin execution.
546
+
547
+ 1. Ensure your runner has `spaceforge` preinstalled. This will avoid the overhead of installing it during the run. (15-30 seconds)
548
+ 2. If youre using binaries, we will only install the binary if its not found. You can gain a few seconds by ensuring its already on the runner.
549
+ 3. If your plugin has a lot of dependencies, consider using a prebuilt runner image with your plugin and its dependencies installed. This avoids the overhead of installing them during each run.
550
+ 4. Ensure your runner has enough core resources (CPU, memory) to handle the plugin execution efficiently. If your plugin is resource-intensive, consider using a more powerful runner.
551
+
543
552
  ## Next Steps
544
553
 
545
554
  1. **Install spaceforge:** `pip install spaceforge`
@@ -131,10 +131,59 @@ contexts:
131
131
  except Exception as e:
132
132
  self.logger.error(f"An unexpected error occurred: {e}")
133
133
  sensitive: false
134
+ - path: /mnt/workspace/plugins/sops/binary_install_sops.sh
135
+ content: |-
136
+ #!/bin/sh
137
+
138
+ set -e
139
+
140
+ if command -v sops; then
141
+ echo "sops is already installed."
142
+ return
143
+ fi
144
+
145
+ mkdir -p /mnt/workspace/plugins/plugin_binaries
146
+
147
+ echo "Installing sops..."
148
+ mkdir -p /mnt/workspace/plugins/plugin_binaries
149
+ cd /mnt/workspace/plugins/plugin_binaries
150
+
151
+ if [ "$(arch)" = "x86_64" ]; then
152
+ curl https://github.com/getsops/sops/releases/download/v3.9.1/sops-v3.9.1.linux.amd64 -o /mnt/workspace/plugins/plugin_binaries/sops -L
153
+ else
154
+ curl https://github.com/getsops/sops/releases/download/v3.9.1/sops-v3.9.1.linux.arm64 -o /mnt/workspace/plugins/plugin_binaries/sops -L
155
+ fi
156
+
157
+ chmod +x /mnt/workspace/plugins/plugin_binaries/sops
158
+ cd /mnt/workspace/source/$TF_VAR_spacelift_project_root
159
+ sensitive: false
160
+ - path: /mnt/workspace/plugins/sops/before_init.sh
161
+ content: |-
162
+ #!/bin/sh
163
+
164
+ set -e
165
+
166
+ cd /mnt/workspace/plugins/sops
167
+
168
+ if [ ! -d "./venv" ]; then
169
+ python -m venv ./venv
170
+ fi
171
+ . venv/bin/activate
172
+
173
+ if ! command -v spaceforge; then
174
+ pip install spaceforge
175
+ fi
176
+
177
+ if [ -f requirements.txt ] && [ ! -f .spaceforge_installed_requirements ]; then
178
+ pip install -r requirements.txt
179
+ touch .spaceforge_installed_requirements
180
+ fi
181
+
182
+ cd /mnt/workspace/source/$TF_VAR_spacelift_project_root
183
+ spaceforge runner --plugin-file /mnt/workspace/plugins/sops/plugin.py before_init
184
+ sensitive: false
134
185
  hooks:
135
186
  before_init:
136
187
  - mkdir -p /mnt/workspace/plugins/sops
137
- - cd /mnt/workspace/plugins/sops && python -m venv ./venv && source venv/bin/activate && pip install spaceforge
138
- - pip install -r requirements.txt
139
- - mkdir -p /mnt/workspace/plugins/plugin_binaries && cd /mnt/workspace/plugins/plugin_binaries && ([[ "$(echo "$(arch)")" == "x86_64" ]] && curl https://github.com/getsops/sops/releases/download/v3.9.1/sops-v3.9.1.linux.amd64 -o /mnt/workspace/plugins/plugin_binaries/sops -L && chmod +x /mnt/workspace/plugins/plugin_binaries/sops || curl https://github.com/getsops/sops/releases/download/v3.9.1/sops-v3.9.1.linux.arm64 -o /mnt/workspace/plugins/plugin_binaries/sops -L && chmod +x /mnt/workspace/plugins/plugin_binaries/sops) && cd /mnt/workspace/source/$TF_VAR_spacelift_project_root
140
- - cd /mnt/workspace/source/$TF_VAR_spacelift_project_root && python -m spaceforge runner --plugin-file /mnt/workspace/plugins/sops/plugin.py before_init
188
+ - chmod +x /mnt/workspace/plugins/sops/binary_install_sops.sh && /mnt/workspace/plugins/sops/binary_install_sops.sh
189
+ - chmod +x /mnt/workspace/plugins/sops/before_init.sh && /mnt/workspace/plugins/sops/before_init.sh
@@ -89,7 +89,7 @@ webhook[{"endpoint_id": "wiz-alert-endpoint"}] {
89
89
  }
90
90
  """,
91
91
  labels={
92
- "policy_type": "security"
92
+ "wiz-plugin"
93
93
  }
94
94
  )
95
95
  ]
@@ -135,7 +135,7 @@ contexts:
135
135
  }
136
136
  """,
137
137
  labels={
138
- "policy_type": "security"
138
+ "wiz-plugin"
139
139
  }
140
140
  )
141
141
  ]
@@ -227,13 +227,63 @@ contexts:
227
227
  markdown += f"<a href=\"{stdout_json['reportUrl']}\" rel=\"noopener noreferrer\">View Report</a>\n"
228
228
  self.send_markdown(markdown)
229
229
  sensitive: false
230
+ - path: /mnt/workspace/plugins/wiz/binary_install_wizcli.sh
231
+ content: |-
232
+ #!/bin/sh
233
+
234
+ set -e
235
+
236
+ if command -v wizcli; then
237
+ echo "wizcli is already installed."
238
+ return
239
+ fi
240
+
241
+ mkdir -p /mnt/workspace/plugins/plugin_binaries
242
+
243
+ echo "Installing wizcli..."
244
+ mkdir -p /mnt/workspace/plugins/plugin_binaries
245
+ cd /mnt/workspace/plugins/plugin_binaries
246
+
247
+ if [ "$(arch)" = "x86_64" ]; then
248
+ curl https://downloads.wiz.io/wizcli/0.94.0/wizcli-linux-amd64 -o /mnt/workspace/plugins/plugin_binaries/wizcli -L
249
+ else
250
+ curl https://downloads.wiz.io/wizcli/0.94.0/wizcli-linux-arm64 -o /mnt/workspace/plugins/plugin_binaries/wizcli -L
251
+ fi
252
+
253
+ chmod +x /mnt/workspace/plugins/plugin_binaries/wizcli
254
+ cd /mnt/workspace/source/$TF_VAR_spacelift_project_root
255
+ sensitive: false
256
+ - path: /mnt/workspace/plugins/wiz/before_plan.sh
257
+ content: |-
258
+ #!/bin/sh
259
+
260
+ set -e
261
+
262
+ cd /mnt/workspace/plugins/wiz
263
+
264
+ if [ ! -d "./venv" ]; then
265
+ python -m venv ./venv
266
+ fi
267
+ . venv/bin/activate
268
+
269
+ if command -v spaceforge; then
270
+ pip install spaceforge
271
+ fi
272
+
273
+ if [ -f requirements.txt ] && [ ! -f .spaceforge_installed_requirements ]; then
274
+ pip install -r requirements.txt
275
+ touch .spaceforge_installed_requirements
276
+ fi
277
+
278
+ cd /mnt/workspace/source/$TF_VAR_spacelift_project_root
279
+ spaceforge runner --plugin-file /mnt/workspace/plugins/wiz/plugin.py before_plan
280
+ sensitive: false
230
281
  hooks:
231
282
  before_init:
232
283
  - mkdir -p /mnt/workspace/plugins/wiz
233
- - mkdir -p /mnt/workspace/plugins/plugin_binaries && cd /mnt/workspace/plugins/plugin_binaries && ([[ "$(echo "$(arch)")" == "x86_64" ]] && curl https://downloads.wiz.io/wizcli/0.94.0/wizcli-linux-amd64 -o /mnt/workspace/plugins/plugin_binaries/wizcli -L && chmod +x /mnt/workspace/plugins/plugin_binaries/wizcli || curl https://downloads.wiz.io/wizcli/0.94.0/wizcli-linux-arm64 -o /mnt/workspace/plugins/plugin_binaries/wizcli -L && chmod +x /mnt/workspace/plugins/plugin_binaries/wizcli) && cd /mnt/workspace/source/$TF_VAR_spacelift_project_root
284
+ - chmod +x /mnt/workspace/plugins/wiz/binary_install_wizcli.sh && /mnt/workspace/plugins/wiz/binary_install_wizcli.sh
234
285
  before_plan:
235
- - cd /mnt/workspace/plugins/wiz && python -m venv ./venv && source venv/bin/activate && pip install spaceforge
236
- - cd /mnt/workspace/source/$TF_VAR_spacelift_project_root && python -m spaceforge runner --plugin-file /mnt/workspace/plugins/wiz/plugin.py before_plan
286
+ - chmod +x /mnt/workspace/plugins/wiz/before_plan.sh && /mnt/workspace/plugins/wiz/before_plan.sh
237
287
  policies:
238
288
  - name_prefix: wiz_policy
239
289
  type: notification
@@ -245,4 +295,4 @@ policies:
245
295
  input.run_updated.run.marked_unsafe == true
246
296
  }
247
297
  labels:
248
- policy_type: security
298
+ - wiz-plugin
@@ -32,6 +32,7 @@ dependencies = [
32
32
  "PyYAML>=6.0",
33
33
  "click>=8.0.0",
34
34
  "pydantic>=2.11.7",
35
+ "Jinja2>=3.1.0",
35
36
  ]
36
37
 
37
38
  [project.optional-dependencies]
@@ -43,6 +43,7 @@ setup(
43
43
  "PyYAML>=6.0",
44
44
  "click>=8.0.0",
45
45
  "pydantic>=2.11.7",
46
+ "Jinja2>=3.1.0",
46
47
  ],
47
48
  extras_require={
48
49
  "dev": [
@@ -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.3'
32
- __version_tuple__ = version_tuple = (0, 0, 3)
31
+ __version__ = version = '0.0.4'
32
+ __version_tuple__ = version_tuple = (0, 0, 4)
33
33
 
34
- __commit_id__ = commit_id = 'ge171bdb88'
34
+ __commit_id__ = commit_id = 'g46df965db'
@@ -118,7 +118,7 @@ class Context:
118
118
  Attributes:
119
119
  name_prefix (str): The name of the context, will be appended with a unique ID.
120
120
  description (str): A description of the context.
121
- labels (dict): Labels associated with the context.
121
+ labels (Optional[List[str]]): Labels associated with the context.
122
122
  env (list): List of variables associated with the context.
123
123
  hooks (dict): Hooks associated with the context.
124
124
  """
@@ -128,7 +128,7 @@ class Context:
128
128
  env: Optional[List[Variable]] = optional_field
129
129
  mounted_files: Optional[List[MountedFile]] = optional_field
130
130
  hooks: Optional[Dict[HookType, List[str]]] = optional_field
131
- labels: Optional[Dict[str, str]] = optional_field
131
+ labels: Optional[List[str]] = optional_field
132
132
 
133
133
 
134
134
  @pydantic_dataclass
@@ -139,14 +139,14 @@ class Webhook:
139
139
  Attributes:
140
140
  name_prefix (str): The name of the webhook, will be appended with a unique ID.
141
141
  endpoint (str): The URL endpoint for the webhook.
142
- labels (Optional[dict]): Labels associated with the webhook.
143
- secrets (Optional[list[Variable]]): List of secrets associated with the webhook.
142
+ labels (Optional[List[str]]): Labels associated with the webhook.
143
+ secret (str): the ID of the parameter where the webhook secret is retrieved from
144
144
  """
145
145
 
146
146
  name_prefix: str
147
147
  endpoint: str
148
- labels: Optional[Dict[str, str]] = optional_field
149
- secrets: Optional[List[Variable]] = optional_field
148
+ secretFromParameter: str
149
+ labels: Optional[List[str]] = optional_field
150
150
 
151
151
 
152
152
  @pydantic_dataclass
@@ -158,13 +158,13 @@ class Policy:
158
158
  name_prefix (str): The name of the policy, will be appended with a unique ID.
159
159
  type (str): The type of the policy (e.g., "terraform", "kubernetes").
160
160
  body (str): The body of the policy, typically a configuration or script.
161
- labels (Optional[dict[str, str]]): Labels associated with the policy.
161
+ labels (Optional[List[str]]): Labels associated with the policy.
162
162
  """
163
163
 
164
164
  name_prefix: str
165
165
  type: str
166
166
  body: str
167
- labels: Optional[Dict[str, str]] = optional_field
167
+ labels: Optional[List[str]] = optional_field
168
168
 
169
169
 
170
170
  @pydantic_dataclass
@@ -7,6 +7,7 @@ import os
7
7
  from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type, Union
8
8
 
9
9
  import yaml
10
+ from jinja2 import Environment, PackageLoader, select_autoescape
10
11
 
11
12
  if TYPE_CHECKING:
12
13
  from .plugin import SpaceforgePlugin
@@ -47,6 +48,9 @@ class PluginGenerator:
47
48
  self.plugin_instance: Optional[SpaceforgePlugin] = None
48
49
  self.plugin_working_directory: Optional[str] = None
49
50
  self.config: Optional[Dict[str, Any]] = None
51
+ self.jinja = Environment(
52
+ loader=PackageLoader("spaceforge"), autoescape=select_autoescape()
53
+ )
50
54
 
51
55
  def load_plugin(self) -> None:
52
56
  """Load the plugin class from the specified path."""
@@ -84,7 +88,7 @@ class PluginGenerator:
84
88
  self.config = {
85
89
  "setup_virtual_env": (
86
90
  f"cd {self.plugin_working_directory} && python -m venv ./venv && "
87
- + "source venv/bin/activate && pip install spaceforge"
91
+ + "source venv/bin/activate && (command -v spaceforge &> /dev/null || pip install spaceforge)"
88
92
  ),
89
93
  "plugin_mounted_path": f"{self.plugin_working_directory}/{os.path.basename(self.plugin_path)}",
90
94
  }
@@ -137,14 +141,27 @@ class PluginGenerator:
137
141
 
138
142
  return hook_methods
139
143
 
140
- def _update_with_requirements(
141
- self, hooks: Dict[str, List[str]], mounted_files: List[MountedFile]
144
+ def _add_to_mounted_files(
145
+ self,
146
+ hooks: Dict[str, List[str]],
147
+ mounted_files: List[MountedFile],
148
+ phase: str,
149
+ filepath: str,
150
+ filecontent: str,
142
151
  ) -> None:
152
+ file = f"{self.plugin_working_directory}/{filepath}"
153
+ hooks[phase].append(f"chmod +x {file} && {file}")
154
+ mounted_files.append(
155
+ MountedFile(
156
+ path=f"{self.plugin_working_directory}/{filepath}",
157
+ content=filecontent,
158
+ sensitive=False,
159
+ )
160
+ )
161
+
162
+ def _update_with_requirements(self, mounted_files: List[MountedFile]) -> None:
143
163
  """Update the plugin hooks if there is a requirements.txt"""
144
164
  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")
148
165
  # read the requirements.txt file
149
166
  with open("requirements.txt", "r") as f:
150
167
  mounted_files.append(
@@ -167,7 +184,9 @@ class PluginGenerator:
167
184
  )
168
185
  )
169
186
 
170
- def _add_spaceforge_hooks(self, hooks: Dict[str, List[str]]) -> None:
187
+ def _add_spaceforge_hooks(
188
+ self, hooks: Dict[str, List[str]], mounted_files: List[MountedFile]
189
+ ) -> None:
171
190
  # Add the spaceforge hook to actually run the plugin
172
191
  if self.config is None:
173
192
  raise ValueError("Plugin config not set. Call load_plugin() first.")
@@ -178,12 +197,14 @@ class PluginGenerator:
178
197
  if hook not in hooks:
179
198
  hooks[hook] = []
180
199
 
181
- if self.config["setup_virtual_env"] not in hooks[hook]:
182
- hooks[hook].append(self.config["setup_virtual_env"])
183
-
184
- hooks[hook].append(
185
- f"cd /mnt/workspace/source/$TF_VAR_spacelift_project_root && python -m spaceforge runner --plugin-file {self.config['plugin_mounted_path']} {hook}"
200
+ directory = os.path.dirname(self.config["plugin_mounted_path"])
201
+ template = self.jinja.get_template("ensure_spaceforge_and_run.sh.j2")
202
+ render = template.render(
203
+ plugin_path=directory,
204
+ plugin_file=self.config["plugin_mounted_path"],
205
+ phase=hook,
186
206
  )
207
+ self._add_to_mounted_files(hooks, mounted_files, hook, f"{hook}.sh", render)
187
208
 
188
209
  def _map_variables_to_parameters(self, contexts: List[Context]) -> None:
189
210
  for context in contexts:
@@ -221,10 +242,10 @@ class PluginGenerator:
221
242
  }
222
243
  mounted_files: List[MountedFile] = []
223
244
 
224
- self._update_with_requirements(hooks, mounted_files)
245
+ self._update_with_requirements(mounted_files)
225
246
  self._update_with_python_file(mounted_files)
226
- self._generate_binary_install_command(hooks)
227
- self._add_spaceforge_hooks(hooks)
247
+ self._generate_binary_install_command(hooks, mounted_files)
248
+ self._add_spaceforge_hooks(hooks, mounted_files)
228
249
 
229
250
  # Get the contexts and append the hooks and mounted files to it.
230
251
  if self.plugin_class is None:
@@ -256,46 +277,38 @@ class PluginGenerator:
256
277
 
257
278
  return contexts
258
279
 
259
- def _generate_binary_install_command(self, hooks: Dict[str, List[str]]) -> None:
280
+ def _generate_binary_install_command(
281
+ self, hooks: Dict[str, List[str]], mounted_files: List[MountedFile]
282
+ ) -> None:
260
283
  binaries = self.get_plugin_binaries()
261
284
  if binaries is None:
262
285
  return None
263
286
 
264
- binary_cmd = ""
265
- if len(binaries) > 0:
266
- binary_cmd = f"mkdir -p {static_binary_directory} && cd {static_binary_directory} && "
267
287
  for i, binary in enumerate(binaries):
268
288
  amd64_url = binary.download_urls.get("amd64", None)
269
289
  arm64_url = binary.download_urls.get("arm64", None)
290
+ binary_path = f"{static_binary_directory}/{binary.name}"
270
291
  if amd64_url is None and arm64_url is None:
271
292
  raise ValueError(
272
293
  f"Binary {binary.name} must have at least one download URL defined (amd64 or arm64)"
273
294
  )
274
295
 
275
- binary_path = f"{static_binary_directory}/{binary.name}"
276
- amd64_download_command = (
277
- f"curl {amd64_url} -o {binary_path} -L && chmod +x {binary_path}"
278
- if amd64_url is not None
279
- else "echo 'amd64 binary not available' && exit 1"
280
- )
281
- arm64_download_command = (
282
- f"curl {arm64_url} -o {binary_path} -L && chmod +x {binary_path}"
283
- if arm64_url is not None
284
- else "echo 'arm64 binary not available' && exit 1"
296
+ template = self.jinja.get_template("binary_install.sh.j2")
297
+ render = template.render(
298
+ binary=binary,
299
+ amd64_url=amd64_url,
300
+ arm64_url=arm64_url,
301
+ binary_path=binary_path,
302
+ static_binary_directory=static_binary_directory,
285
303
  )
286
-
287
- binary_cmd += (
288
- '([[ "$(echo "$(arch)")" == "x86_64" ]] && {} || {}) && '.format(
289
- amd64_download_command, arm64_download_command
290
- )
304
+ self._add_to_mounted_files(
305
+ hooks,
306
+ mounted_files,
307
+ "before_init",
308
+ f"binary_install_{binary.name}.sh",
309
+ render,
291
310
  )
292
311
 
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
298
-
299
312
  def get_plugin_binaries(self) -> Optional[List[Binary]]:
300
313
  """Get binary definitions from the plugin class."""
301
314
  return getattr(self.plugin_class, "__binaries__", None)
@@ -263,12 +263,13 @@ class SpaceforgePlugin(ABC):
263
263
  self._spacelift_markdown_endpoint,
264
264
  json.dumps(body).encode("utf-8"),
265
265
  headers,
266
+ method="POST",
266
267
  )
267
268
 
268
269
  with urllib.request.urlopen(req) as response:
269
270
  if response.status != 200:
270
271
  self.logger.error(
271
- f"Error getting signed URL for markdown upload: {response.status}"
272
+ f"Error getting signed URL for markdown upload: {response}"
272
273
  )
273
274
  return
274
275
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$defs": {
3
3
  "Context": {
4
- "description": "A class to represent a context for a plugin.\n\nAttributes:\n name_prefix (str): The name of the context, will be appended with a unique ID.\n description (str): A description of the context.\n labels (dict): Labels associated with the context.\n env (list): List of variables associated with the context.\n hooks (dict): Hooks associated with the context.",
4
+ "description": "A class to represent a context for a plugin.\n\nAttributes:\n name_prefix (str): The name of the context, will be appended with a unique ID.\n description (str): A description of the context.\n labels (Optional[List[str]]): Labels associated with the context.\n env (list): List of variables associated with the context.\n hooks (dict): Hooks associated with the context.",
5
5
  "properties": {
6
6
  "name_prefix": {
7
7
  "title": "Name Prefix",
@@ -74,10 +74,10 @@
74
74
  "labels": {
75
75
  "anyOf": [
76
76
  {
77
- "additionalProperties": {
77
+ "items": {
78
78
  "type": "string"
79
79
  },
80
- "type": "object"
80
+ "type": "array"
81
81
  },
82
82
  {
83
83
  "type": "null"
@@ -163,7 +163,7 @@
163
163
  "type": "object"
164
164
  },
165
165
  "Policy": {
166
- "description": "A class to represent a policy configuration.\n\nAttributes:\n name_prefix (str): The name of the policy, will be appended with a unique ID.\n type (str): The type of the policy (e.g., \"terraform\", \"kubernetes\").\n body (str): The body of the policy, typically a configuration or script.\n labels (Optional[dict[str, str]]): Labels associated with the policy.",
166
+ "description": "A class to represent a policy configuration.\n\nAttributes:\n name_prefix (str): The name of the policy, will be appended with a unique ID.\n type (str): The type of the policy (e.g., \"terraform\", \"kubernetes\").\n body (str): The body of the policy, typically a configuration or script.\n labels (Optional[List[str]]): Labels associated with the policy.",
167
167
  "properties": {
168
168
  "name_prefix": {
169
169
  "title": "Name Prefix",
@@ -180,10 +180,10 @@
180
180
  "labels": {
181
181
  "anyOf": [
182
182
  {
183
- "additionalProperties": {
183
+ "items": {
184
184
  "type": "string"
185
185
  },
186
- "type": "object"
186
+ "type": "array"
187
187
  },
188
188
  {
189
189
  "type": "null"
@@ -242,7 +242,7 @@
242
242
  "type": "object"
243
243
  },
244
244
  "Webhook": {
245
- "description": "A class to represent a webhook configuration.\n\nAttributes:\n name_prefix (str): The name of the webhook, will be appended with a unique ID.\n endpoint (str): The URL endpoint for the webhook.\n labels (Optional[dict]): Labels associated with the webhook.\n secrets (Optional[list[Variable]]): List of secrets associated with the webhook.",
245
+ "description": "A class to represent a webhook configuration.\n\nAttributes:\n name_prefix (str): The name of the webhook, will be appended with a unique ID.\n endpoint (str): The URL endpoint for the webhook.\n labels (Optional[List[str]]): Labels associated with the webhook.\n secret (str): the ID of the parameter where the webhook secret is retrieved from",
246
246
  "properties": {
247
247
  "name_prefix": {
248
248
  "title": "Name Prefix",
@@ -252,25 +252,15 @@
252
252
  "title": "Endpoint",
253
253
  "type": "string"
254
254
  },
255
- "labels": {
256
- "anyOf": [
257
- {
258
- "additionalProperties": {
259
- "type": "string"
260
- },
261
- "type": "object"
262
- },
263
- {
264
- "type": "null"
265
- }
266
- ],
267
- "title": "Labels"
255
+ "secretFromParameter": {
256
+ "title": "Secretfromparameter",
257
+ "type": "string"
268
258
  },
269
- "secrets": {
259
+ "labels": {
270
260
  "anyOf": [
271
261
  {
272
262
  "items": {
273
- "$ref": "#/$defs/Variable"
263
+ "type": "string"
274
264
  },
275
265
  "type": "array"
276
266
  },
@@ -278,12 +268,13 @@
278
268
  "type": "null"
279
269
  }
280
270
  ],
281
- "title": "Secrets"
271
+ "title": "Labels"
282
272
  }
283
273
  },
284
274
  "required": [
285
275
  "name_prefix",
286
- "endpoint"
276
+ "endpoint",
277
+ "secretFromParameter"
287
278
  ],
288
279
  "title": "Webhook",
289
280
  "type": "object"
@@ -0,0 +1,23 @@
1
+ #!/bin/sh
2
+
3
+ set -e
4
+
5
+ if command -v {{binary.name}}; then
6
+ echo "{{binary.name}} is already installed."
7
+ return
8
+ fi
9
+
10
+ mkdir -p {{static_binary_directory}}
11
+
12
+ echo "Installing {{binary.name}}..."
13
+ mkdir -p {{static_binary_directory}}
14
+ cd {{static_binary_directory}}
15
+
16
+ if [ "$(arch)" = "x86_64" ]; then
17
+ curl {{amd64_url}} -o {{binary_path}} -L
18
+ else
19
+ curl {{arm64_url}} -o {{binary_path}} -L
20
+ fi
21
+
22
+ chmod +x {{binary_path}}
23
+ cd /mnt/workspace/source/$TF_VAR_spacelift_project_root
@@ -0,0 +1,22 @@
1
+ #!/bin/sh
2
+
3
+ set -e
4
+
5
+ cd {{plugin_path}}
6
+
7
+ if [ ! -d "./venv" ]; then
8
+ python -m venv ./venv
9
+ fi
10
+ . venv/bin/activate
11
+
12
+ if ! command -v spaceforge; then
13
+ pip install spaceforge
14
+ fi
15
+
16
+ if [ -f requirements.txt ] && [ ! -f .spaceforge_installed_requirements ]; then
17
+ pip install -r requirements.txt
18
+ touch .spaceforge_installed_requirements
19
+ fi
20
+
21
+ cd /mnt/workspace/source/$TF_VAR_spacelift_project_root
22
+ spaceforge runner --plugin-file {{plugin_file}} {{phase}}
@@ -1,6 +1,6 @@
1
1
  import os
2
2
  import tempfile
3
- from typing import Dict, List
3
+ from typing import Any, Dict, List
4
4
  from unittest.mock import Mock, mock_open, patch
5
5
 
6
6
  import pytest
@@ -55,7 +55,7 @@ class PluginExample(SpaceforgePlugin):
55
55
  Context(
56
56
  name_prefix="test_context",
57
57
  description="Test context",
58
- labels={"env": "test"},
58
+ labels=["env:test"],
59
59
  env=[Variable(key="TEST_VAR", value="test_value")],
60
60
  )
61
61
  ]
@@ -64,7 +64,8 @@ class PluginExample(SpaceforgePlugin):
64
64
  Webhook(
65
65
  name_prefix="test_webhook",
66
66
  endpoint="https://webhook.example.com",
67
- secrets=[Variable(key="SECRET_KEY", value_from_parameter="api_key")],
67
+ secretFromParameter="api_key",
68
+ labels=["type:notification"],
68
69
  )
69
70
  ]
70
71
 
@@ -73,7 +74,7 @@ class PluginExample(SpaceforgePlugin):
73
74
  name_prefix="test_policy",
74
75
  type="notification",
75
76
  body="package test",
76
- labels={"type": "security"},
77
+ labels=["type:security"],
77
78
  )
78
79
  ]
79
80
 
@@ -325,14 +326,25 @@ class NotAPlugin:
325
326
  }
326
327
 
327
328
  hooks: Dict[str, List[str]] = {"before_init": []}
328
- generator._generate_binary_install_command(hooks)
329
- command = hooks["before_init"][-1]
329
+ mounted_files: List = []
330
+ generator._generate_binary_install_command(hooks, mounted_files)
330
331
 
331
- assert "mkdir -p /mnt/workspace/plugins/plugin_binaries" in command
332
- assert "curl https://example.com/test-cli-amd64" in command
333
- assert "curl https://example.com/test-cli-arm64" in command
334
- assert "arch" in command
335
- assert "x86_64" in command
332
+ # Should have added a script execution to hooks
333
+ assert len(hooks["before_init"]) == 1
334
+ command = hooks["before_init"][0]
335
+
336
+ # Should have added a mounted file with script content
337
+ assert len(mounted_files) == 1
338
+ script_content = mounted_files[0].content
339
+
340
+ # Check that hook command runs the script
341
+ assert "chmod +x" in command
342
+ assert "binary_install_test-cli.sh" in command
343
+
344
+ # Check script content contains binary installation logic
345
+ assert "test-cli" in script_content
346
+ assert "https://example.com/test-cli-amd64" in script_content
347
+ assert "https://example.com/test-cli-arm64" in script_content
336
348
 
337
349
  def test_generate_binary_install_command_no_binaries(self) -> None:
338
350
  """Test binary command generation when no binaries."""
@@ -349,9 +361,11 @@ class NotAPlugin:
349
361
  }
350
362
 
351
363
  hooks: Dict[str, List[str]] = {"before_init": []}
352
- generator._generate_binary_install_command(hooks)
353
- # No binaries should mean no new commands added
364
+ mounted_files: List = []
365
+ generator._generate_binary_install_command(hooks, mounted_files)
366
+ # No binaries should mean no new commands or files added
354
367
  assert len(hooks["before_init"]) == 0
368
+ assert len(mounted_files) == 0
355
369
 
356
370
  def test_generate_binary_install_command_missing_urls(self) -> None:
357
371
  """Test binary command generation with missing URLs."""
@@ -368,8 +382,9 @@ class NotAPlugin:
368
382
  }
369
383
 
370
384
  hooks: Dict[str, List[str]] = {"before_init": []}
385
+ mounted_files: List = []
371
386
  with pytest.raises(ValueError, match="must have at least one download URL"):
372
- generator._generate_binary_install_command(hooks)
387
+ generator._generate_binary_install_command(hooks, mounted_files)
373
388
 
374
389
  def test_get_plugin_policies(self) -> None:
375
390
  """Test policy extraction."""
@@ -397,37 +412,50 @@ class NotAPlugin:
397
412
  assert webhooks[0].endpoint == "https://webhook.example.com"
398
413
 
399
414
  @patch("os.path.exists")
400
- @patch("builtins.open", new_callable=mock_open, read_data="requirements content")
401
- def test_get_plugin_contexts_with_requirements(
402
- self, mock_file: Mock, mock_exists: Mock
403
- ) -> None:
415
+ def test_get_plugin_contexts_with_requirements(self, mock_exists: Mock) -> None:
404
416
  """Test context generation with requirements.txt."""
405
417
  mock_exists.side_effect = (
406
418
  lambda path: path == "requirements.txt" or "plugin.py" in path
407
419
  )
408
420
 
409
- generator = PluginGenerator(self.test_plugin_path)
410
- generator.plugin_class = PluginExample
411
- generator.plugin_working_directory = "/mnt/workspace/plugins/test_plugin"
412
- generator.config = {
413
- "setup_virtual_env": "cd /mnt/workspace/plugins/test_plugin && python -m venv ./venv && source venv/bin/activate && pip install spaceforge",
414
- "plugin_mounted_path": "/mnt/workspace/plugins/test_plugin/plugin.py",
415
- }
421
+ # Mock specific file contents with a custom open function
422
+ original_open = open
423
+
424
+ def mock_open_func(filename: str, *args: Any, **kwargs: Any) -> Any:
425
+ if filename == "requirements.txt":
426
+ from io import StringIO
427
+
428
+ return StringIO("requirements content")
429
+ elif "plugin.py" in filename:
430
+ from io import StringIO
416
431
 
417
- contexts = generator.get_plugin_contexts()
432
+ return StringIO("plugin content")
433
+ else:
434
+ return original_open(filename, *args, **kwargs)
435
+
436
+ with patch("builtins.open", side_effect=mock_open_func):
437
+ generator = PluginGenerator(self.test_plugin_path)
438
+ generator.plugin_class = PluginExample
439
+ generator.plugin_working_directory = "/mnt/workspace/plugins/test_plugin"
440
+ generator.config = {
441
+ "setup_virtual_env": "cd /mnt/workspace/plugins/test_plugin && python -m venv ./venv && source venv/bin/activate && pip install spaceforge",
442
+ "plugin_mounted_path": "/mnt/workspace/plugins/test_plugin/plugin.py",
443
+ }
444
+
445
+ contexts = generator.get_plugin_contexts()
418
446
 
419
447
  assert len(contexts) == 1
420
448
  context = contexts[0]
421
449
 
422
- # Should have before_init hooks for venv setup
450
+ # Should have before_init hooks with mkdir command
423
451
  assert context.hooks is not None
424
452
  assert "before_init" in context.hooks
425
- venv_command = None
453
+ mkdir_command = None
426
454
  for cmd in context.hooks["before_init"]:
427
- if "python -m venv" in cmd:
428
- venv_command = cmd
455
+ if "mkdir -p" in cmd:
456
+ mkdir_command = cmd
429
457
  break
430
- assert venv_command is not None
458
+ assert mkdir_command is not None
431
459
 
432
460
  # Should have requirements.txt as mounted file
433
461
  assert context.mounted_files is not None
@@ -439,26 +467,44 @@ class NotAPlugin:
439
467
  assert req_file is not None
440
468
  assert req_file.content == "requirements content"
441
469
 
470
+ # Should have plugin.py as mounted file
471
+ plugin_file = None
472
+ for mf in context.mounted_files:
473
+ if "plugin.py" in mf.path:
474
+ plugin_file = mf
475
+ break
476
+ assert plugin_file is not None
477
+ assert plugin_file.content == "plugin content"
478
+
442
479
  @patch("os.path.exists")
443
- @patch("builtins.open", new_callable=mock_open, read_data="plugin content")
444
- def test_get_plugin_contexts_basic(
445
- self, mock_file: Mock, mock_exists: Mock
446
- ) -> None:
480
+ def test_get_plugin_contexts_basic(self, mock_exists: Mock) -> None:
447
481
  """Test basic context generation."""
448
482
  mock_exists.side_effect = lambda path: "plugin.py" in path
449
483
 
450
- generator = PluginGenerator(self.test_plugin_path)
451
- generator.plugin_class = PluginExample
452
- generator.plugin_working_directory = "/mnt/workspace/plugins/test_plugin"
453
- generator.config = {
454
- "setup_virtual_env": "cd /mnt/workspace/plugins/test_plugin && python -m venv ./venv && source venv/bin/activate && pip install spaceforge",
455
- "plugin_mounted_path": "/mnt/workspace/plugins/test_plugin/plugin.py",
456
- }
457
-
458
- with patch.object(
459
- generator, "get_available_hooks", return_value=["after_plan"]
460
- ):
461
- contexts = generator.get_plugin_contexts()
484
+ # Mock specific file contents with a custom open function
485
+ original_open = open
486
+
487
+ def mock_open_func(filename: str, *args: Any, **kwargs: Any) -> Any:
488
+ if "plugin.py" in filename:
489
+ from io import StringIO
490
+
491
+ return StringIO("plugin content")
492
+ else:
493
+ return original_open(filename, *args, **kwargs)
494
+
495
+ with patch("builtins.open", side_effect=mock_open_func):
496
+ generator = PluginGenerator(self.test_plugin_path)
497
+ generator.plugin_class = PluginExample
498
+ generator.plugin_working_directory = "/mnt/workspace/plugins/test_plugin"
499
+ generator.config = {
500
+ "setup_virtual_env": "cd /mnt/workspace/plugins/test_plugin && python -m venv ./venv && source venv/bin/activate && pip install spaceforge",
501
+ "plugin_mounted_path": "/mnt/workspace/plugins/test_plugin/plugin.py",
502
+ }
503
+
504
+ with patch.object(
505
+ generator, "get_available_hooks", return_value=["after_plan"]
506
+ ):
507
+ contexts = generator.get_plugin_contexts()
462
508
 
463
509
  assert len(contexts) == 1
464
510
  context = contexts[0]
@@ -472,12 +518,24 @@ class NotAPlugin:
472
518
  break
473
519
  assert plugin_file is not None
474
520
 
475
- # Should have spacepy runner hooks
521
+ # Should have hooks for after_plan (the hook we mocked as available)
476
522
  assert context.hooks is not None
477
523
  assert "after_plan" in context.hooks
478
- runner_command = context.hooks["after_plan"][1]
479
- assert "python -m spaceforge runner" in runner_command
480
- assert "after_plan" in runner_command
524
+
525
+ # Should have a script execution command
526
+ hook_command = context.hooks["after_plan"][0]
527
+ assert "chmod +x" in hook_command
528
+ assert "after_plan.sh" in hook_command
529
+
530
+ # Should have a mounted script file for after_plan
531
+ after_plan_script = None
532
+ for mf in context.mounted_files:
533
+ if "after_plan.sh" in mf.path:
534
+ after_plan_script = mf
535
+ break
536
+ assert after_plan_script is not None
537
+ assert "spaceforge runner" in after_plan_script.content
538
+ assert "after_plan" in after_plan_script.content
481
539
 
482
540
  def test_generate_manifest(self) -> None:
483
541
  """Test complete manifest generation."""
@@ -646,11 +704,23 @@ class TestPluginGeneratorEdgeCases:
646
704
  }
647
705
 
648
706
  hooks: Dict[str, List[str]] = {"before_init": []}
649
- generator._generate_binary_install_command(hooks)
650
- command = hooks["before_init"][-1]
707
+ mounted_files: List = []
708
+ generator._generate_binary_install_command(hooks, mounted_files)
709
+
710
+ # Should have added a script execution to hooks
711
+ assert len(hooks["before_init"]) == 1
712
+ command = hooks["before_init"][0]
651
713
 
652
- assert "https://example.com/binary-amd64" in command
653
- assert "arm64 binary not available" in command
714
+ # Should have added a mounted file with script content
715
+ assert len(mounted_files) == 1
716
+ script_content = mounted_files[0].content
717
+
718
+ # Check that hook command runs the script
719
+ assert "chmod +x" in command
720
+ assert "binary_install_single-arch.sh" in command
721
+
722
+ # Check script content contains the binary URL
723
+ assert "https://example.com/binary-amd64" in script_content
654
724
 
655
725
  def test_context_merging_with_existing(self) -> None:
656
726
  """Test that generated hooks are merged with existing context hooks."""
@@ -683,10 +753,21 @@ class TestPluginGeneratorEdgeCases:
683
753
  "plugin_mounted_path": "/mnt/workspace/plugins/existing_hooks/plugin.py",
684
754
  }
685
755
 
756
+ # Mock specific file contents with a custom open function
757
+ original_open = open
758
+
759
+ def mock_open_func(filename: str, *args: Any, **kwargs: Any) -> Any:
760
+ if filename == "/fake/path":
761
+ from io import StringIO
762
+
763
+ return StringIO("fake content")
764
+ else:
765
+ return original_open(filename, *args, **kwargs)
766
+
686
767
  with patch("os.path.exists") as mock_exists:
687
768
  # Only plugin file exists, not requirements.txt
688
769
  mock_exists.side_effect = lambda path: path == "/fake/path"
689
- with patch("builtins.open", mock_open(read_data="fake content")):
770
+ with patch("builtins.open", side_effect=mock_open_func):
690
771
  contexts = generator.get_plugin_contexts()
691
772
 
692
773
  context = contexts[0]
@@ -702,11 +783,20 @@ class TestPluginGeneratorEdgeCases:
702
783
  assert len(existing_files) == 1
703
784
 
704
785
  # Plugin file should be mounted (path contains the working directory)
705
- plugin_files = [
786
+ # The new implementation creates both plugin.py and hook scripts
787
+ working_dir_files = [
706
788
  mf
707
789
  for mf in context.mounted_files
708
790
  if "/mnt/workspace/plugins/existing_hooks" in mf.path
709
791
  ]
792
+ assert (
793
+ len(working_dir_files) >= 1
794
+ ) # At least plugin.py, possibly hook scripts too
795
+
796
+ # Specifically check for plugin.py
797
+ plugin_files = [
798
+ mf for mf in context.mounted_files if mf.path.endswith("plugin.py")
799
+ ]
710
800
  assert len(plugin_files) == 1
711
801
 
712
802
  # Should have existing env vars
@@ -767,10 +857,21 @@ class TestPluginGeneratorEdgeCases:
767
857
  "plugin_mounted_path": "/mnt/workspace/plugins/parametersandvariables/plugin.py",
768
858
  }
769
859
 
860
+ # Mock specific file contents with a custom open function
861
+ original_open = open
862
+
863
+ def mock_open_func(filename: str, *args: Any, **kwargs: Any) -> Any:
864
+ if filename == "/fake/path":
865
+ from io import StringIO
866
+
867
+ return StringIO("fake content")
868
+ else:
869
+ return original_open(filename, *args, **kwargs)
870
+
770
871
  with patch("os.path.exists") as mock_exists:
771
872
  # Only plugin file exists, not requirements.txt
772
873
  mock_exists.side_effect = lambda path: path == "/fake/path"
773
- with patch("builtins.open", mock_open(read_data="fake content")):
874
+ with patch("builtins.open", side_effect=mock_open_func):
774
875
  contexts = generator.get_plugin_contexts()
775
876
  parameters = generator.get_plugin_parameters()
776
877
 
@@ -80,17 +80,28 @@ class TestPluginGeneratorBinaries:
80
80
  }
81
81
 
82
82
  hooks: Dict[str, List[str]] = {"before_init": []}
83
+ mounted_files: List = []
83
84
 
84
85
  # Act
85
- generator._generate_binary_install_command(hooks)
86
- command = hooks["before_init"][-1]
86
+ generator._generate_binary_install_command(hooks, mounted_files)
87
87
 
88
88
  # Assert
89
- assert "mkdir -p /mnt/workspace/plugins/plugin_binaries" in command
90
- assert "curl https://example.com/multi-cli-amd64" in command
91
- assert "curl https://example.com/multi-cli-arm64" in command
92
- assert "arch" in command
93
- assert "x86_64" in command
89
+ # Should have added a script execution to hooks
90
+ assert len(hooks["before_init"]) == 1
91
+ command = hooks["before_init"][0]
92
+
93
+ # Should have added a mounted file with script content
94
+ assert len(mounted_files) == 1
95
+ script_content = mounted_files[0].content
96
+
97
+ # Check that hook command runs the script
98
+ assert "chmod +x" in command
99
+ assert "binary_install_multi-cli.sh" in command
100
+
101
+ # Check script content contains binary installation logic
102
+ assert "multi-cli" in script_content
103
+ assert "https://example.com/multi-cli-amd64" in script_content
104
+ assert "https://example.com/multi-cli-arm64" in script_content
94
105
 
95
106
  def test_should_not_add_commands_when_no_binaries(self) -> None:
96
107
  """Should not add installation commands when plugin has no binaries."""
@@ -108,12 +119,15 @@ class TestPluginGeneratorBinaries:
108
119
  }
109
120
 
110
121
  hooks: Dict[str, List[str]] = {"before_init": []}
122
+ mounted_files: List = []
111
123
 
112
124
  # Act
113
- generator._generate_binary_install_command(hooks)
125
+ generator._generate_binary_install_command(hooks, mounted_files)
114
126
 
115
127
  # Assert
128
+ # No binaries should mean no new commands or files added
116
129
  assert len(hooks["before_init"]) == 0
130
+ assert len(mounted_files) == 0
117
131
 
118
132
  def test_should_raise_error_when_binary_has_no_download_urls(self) -> None:
119
133
  """Should raise ValueError when binary has empty download URLs."""
@@ -131,10 +145,11 @@ class TestPluginGeneratorBinaries:
131
145
  }
132
146
 
133
147
  hooks: Dict[str, List[str]] = {"before_init": []}
148
+ mounted_files: List = []
134
149
 
135
150
  # Act & Assert
136
151
  with pytest.raises(ValueError, match="must have at least one download URL"):
137
- generator._generate_binary_install_command(hooks)
152
+ generator._generate_binary_install_command(hooks, mounted_files)
138
153
 
139
154
  def test_should_handle_single_architecture_binary(self) -> None:
140
155
  """Should generate appropriate commands for single architecture binary."""
@@ -157,11 +172,23 @@ class TestPluginGeneratorBinaries:
157
172
  }
158
173
 
159
174
  hooks: Dict[str, List[str]] = {"before_init": []}
175
+ mounted_files: List = []
160
176
 
161
177
  # Act
162
- generator._generate_binary_install_command(hooks)
163
- command = hooks["before_init"][-1]
178
+ generator._generate_binary_install_command(hooks, mounted_files)
164
179
 
165
180
  # Assert
166
- assert "https://example.com/binary-amd64" in command
167
- assert "arm64 binary not available" in command
181
+ # Should have added a script execution to hooks
182
+ assert len(hooks["before_init"]) == 1
183
+ command = hooks["before_init"][0]
184
+
185
+ # Should have added a mounted file with script content
186
+ assert len(mounted_files) == 1
187
+ script_content = mounted_files[0].content
188
+
189
+ # Check that hook command runs the script
190
+ assert "chmod +x" in command
191
+ assert "binary_install_single-arch.sh" in command
192
+
193
+ # Check script content contains the binary URL
194
+ assert "https://example.com/binary-amd64" in script_content
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spaceforge
3
- Version: 0.0.3
3
+ Version: 0.0.4
4
4
  Summary: A Python framework for building Spacelift plugins
5
5
  Home-page: https://github.com/spacelift-io/plugins
6
6
  Author: Spacelift
@@ -28,6 +28,7 @@ License-File: LICENSE
28
28
  Requires-Dist: PyYAML>=6.0
29
29
  Requires-Dist: click>=8.0.0
30
30
  Requires-Dist: pydantic>=2.11.7
31
+ Requires-Dist: Jinja2>=3.1.0
31
32
  Provides-Extra: dev
32
33
  Requires-Dist: pytest>=6.0; extra == "dev"
33
34
  Requires-Dist: pytest-cov; extra == "dev"
@@ -584,6 +585,15 @@ export SPACEFORGE_PARAM_SEVERITY_THRESHOLD="high"
584
585
  spaceforge runner after_plan
585
586
  ```
586
587
 
588
+ ## Speeding up plugin execution
589
+
590
+ There are a few things you can do to speed up plugin execution.
591
+
592
+ 1. Ensure your runner has `spaceforge` preinstalled. This will avoid the overhead of installing it during the run. (15-30 seconds)
593
+ 2. If youre using binaries, we will only install the binary if its not found. You can gain a few seconds by ensuring its already on the runner.
594
+ 3. If your plugin has a lot of dependencies, consider using a prebuilt runner image with your plugin and its dependencies installed. This avoids the overhead of installing them during each run.
595
+ 4. Ensure your runner has enough core resources (CPU, memory) to handle the plugin execution efficiently. If your plugin is resource-intensive, consider using a more powerful runner.
596
+
587
597
  ## Next Steps
588
598
 
589
599
  1. **Install spaceforge:** `pip install spaceforge`
@@ -47,4 +47,6 @@ spaceforge.egg-info/dependency_links.txt
47
47
  spaceforge.egg-info/entry_points.txt
48
48
  spaceforge.egg-info/not-zip-safe
49
49
  spaceforge.egg-info/requires.txt
50
- spaceforge.egg-info/top_level.txt
50
+ spaceforge.egg-info/top_level.txt
51
+ spaceforge/templates/binary_install.sh.j2
52
+ spaceforge/templates/ensure_spaceforge_and_run.sh.j2
@@ -1,6 +1,7 @@
1
1
  PyYAML>=6.0
2
2
  click>=8.0.0
3
3
  pydantic>=2.11.7
4
+ Jinja2>=3.1.0
4
5
 
5
6
  [dev]
6
7
  pytest>=6.0
@@ -3,7 +3,7 @@
3
3
  set -e
4
4
 
5
5
  echo "Setting up dev dependencies..."
6
- pip install ".[dev]"
6
+ pip install -e ".[dev]"
7
7
 
8
8
  echo "Running pytests..."
9
9
  python -m pytest spaceforge/ -v
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes