spaceforge 1.1.0__tar.gz → 1.1.1__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.
- {spaceforge-1.1.0 → spaceforge-1.1.1}/PKG-INFO +168 -33
- {spaceforge-1.1.0 → spaceforge-1.1.1}/README.md +167 -32
- spaceforge-1.1.1/plugins/envsubst/plugin.py +63 -0
- spaceforge-1.1.1/plugins/envsubst/plugin.yaml +133 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/plugins/sops/plugin.yaml +0 -2
- {spaceforge-1.1.0 → spaceforge-1.1.1}/plugins/wiz/plugin.yaml +0 -2
- {spaceforge-1.1.0 → spaceforge-1.1.1}/spaceforge/_version_scm.py +3 -3
- {spaceforge-1.1.0 → spaceforge-1.1.1}/spaceforge/plugin.py +0 -30
- {spaceforge-1.1.0 → spaceforge-1.1.1}/spaceforge/templates/binary_install.sh.j2 +0 -2
- spaceforge-1.1.1/spaceforge/test_plugin_hooks.py +33 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/spaceforge.egg-info/PKG-INFO +168 -33
- {spaceforge-1.1.0 → spaceforge-1.1.1}/spaceforge.egg-info/SOURCES.txt +2 -0
- spaceforge-1.1.0/spaceforge/test_plugin_hooks.py +0 -100
- {spaceforge-1.1.0 → spaceforge-1.1.1}/.github/workflows/ci.yml +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/.github/workflows/release.yml +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/.gitignore +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/LICENSE +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/MANIFEST.in +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/go.mod +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/plugins/enviroment_manager/plugin.py +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/plugins/enviroment_manager/plugin.yaml +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/plugins/enviroment_manager/requirements.txt +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/plugins/infracost/plugin.py +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/plugins/infracost/plugin.yaml +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/plugins/sops/plugin.py +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/plugins/sops/requirements.txt +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/plugins/wiz/plugin.py +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/pyproject.toml +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/setup.cfg +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/setup.py +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/spaceforge/README.md +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/spaceforge/__init__.py +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/spaceforge/__main__.py +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/spaceforge/_version.py +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/spaceforge/cls.py +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/spaceforge/conftest.py +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/spaceforge/generator.py +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/spaceforge/runner.py +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/spaceforge/schema.json +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/spaceforge/templates/ensure_spaceforge_and_run.sh.j2 +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/spaceforge/test_cls.py +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/spaceforge/test_generator.py +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/spaceforge/test_generator_binaries.py +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/spaceforge/test_generator_core.py +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/spaceforge/test_generator_hooks.py +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/spaceforge/test_generator_parameters.py +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/spaceforge/test_plugin.py +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/spaceforge/test_plugin_file_operations.py +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/spaceforge/test_plugin_inheritance.py +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/spaceforge/test_runner.py +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/spaceforge/test_runner_cli.py +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/spaceforge/test_runner_core.py +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/spaceforge/test_runner_execution.py +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/spaceforge.egg-info/dependency_links.txt +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/spaceforge.egg-info/entry_points.txt +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/spaceforge.egg-info/not-zip-safe +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/spaceforge.egg-info/requires.txt +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/spaceforge.egg-info/top_level.txt +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/templates.go +0 -0
- {spaceforge-1.1.0 → spaceforge-1.1.1}/test.sh +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: spaceforge
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.1
|
|
4
4
|
Summary: A Python framework for building Spacelift plugins
|
|
5
5
|
Home-page: https://github.com/spacelift-io/plugins
|
|
6
6
|
Author: Spacelift
|
|
@@ -63,7 +63,7 @@ pip install spaceforge
|
|
|
63
63
|
Create a Python file (e.g., `my_plugin.py`) and inherit from `SpaceforgePlugin`:
|
|
64
64
|
|
|
65
65
|
```python
|
|
66
|
-
from spaceforge import SpaceforgePlugin, Parameter, Variable, Context
|
|
66
|
+
from spaceforge import SpaceforgePlugin, Parameter, Variable, Context, Binary, Policy, Webhook, MountedFile
|
|
67
67
|
import os
|
|
68
68
|
|
|
69
69
|
class MyPlugin(SpaceforgePlugin):
|
|
@@ -71,17 +71,20 @@ class MyPlugin(SpaceforgePlugin):
|
|
|
71
71
|
__plugin_name__ = "my-awesome-plugin"
|
|
72
72
|
__version__ = "1.0.0"
|
|
73
73
|
__author__ = "Your Name"
|
|
74
|
+
__labels__ = ["security", "monitoring"] # Optional labels for categorization
|
|
74
75
|
|
|
75
76
|
# Define plugin parameters
|
|
76
77
|
__parameters__ = [
|
|
77
78
|
Parameter(
|
|
78
|
-
name="
|
|
79
|
+
name="API Key",
|
|
80
|
+
id="api_key", # Optional ID for parameter reference
|
|
79
81
|
description="API key for external service",
|
|
80
82
|
required=True,
|
|
81
83
|
sensitive=True
|
|
82
84
|
),
|
|
83
85
|
Parameter(
|
|
84
|
-
name="
|
|
86
|
+
name="Environment",
|
|
87
|
+
id="environment",
|
|
85
88
|
description="Target environment",
|
|
86
89
|
required=False,
|
|
87
90
|
default="production"
|
|
@@ -96,12 +99,12 @@ class MyPlugin(SpaceforgePlugin):
|
|
|
96
99
|
env=[
|
|
97
100
|
Variable(
|
|
98
101
|
key="API_KEY",
|
|
99
|
-
value_from_parameter="api_key",
|
|
102
|
+
value_from_parameter="api_key", # Matches parameter id or name
|
|
100
103
|
sensitive=True
|
|
101
104
|
),
|
|
102
105
|
Variable(
|
|
103
106
|
key="ENVIRONMENT",
|
|
104
|
-
value_from_parameter="environment"
|
|
107
|
+
value_from_parameter="environment" # Matches parameter id or name
|
|
105
108
|
)
|
|
106
109
|
]
|
|
107
110
|
)
|
|
@@ -160,6 +163,15 @@ Override these methods in your plugin to add custom logic:
|
|
|
160
163
|
|
|
161
164
|
## Plugin Components
|
|
162
165
|
|
|
166
|
+
### Labels
|
|
167
|
+
|
|
168
|
+
Add optional labels to categorize your plugin:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
class MyPlugin(SpaceforgePlugin):
|
|
172
|
+
__labels__ = ["security", "monitoring", "compliance"]
|
|
173
|
+
```
|
|
174
|
+
|
|
163
175
|
### Parameters
|
|
164
176
|
|
|
165
177
|
Define user-configurable parameters:
|
|
@@ -167,21 +179,30 @@ Define user-configurable parameters:
|
|
|
167
179
|
```python
|
|
168
180
|
__parameters__ = [
|
|
169
181
|
Parameter(
|
|
170
|
-
name="
|
|
182
|
+
name="Database URL",
|
|
183
|
+
id="database_url", # Optional: used for parameter reference
|
|
171
184
|
description="Database connection URL",
|
|
172
185
|
required=True,
|
|
173
|
-
sensitive=True
|
|
174
|
-
default="postgresql://localhost:5432/mydb"
|
|
186
|
+
sensitive=True
|
|
175
187
|
),
|
|
176
188
|
Parameter(
|
|
177
|
-
name="
|
|
189
|
+
name="Timeout",
|
|
190
|
+
id="timeout",
|
|
178
191
|
description="Timeout in seconds",
|
|
179
192
|
required=False,
|
|
180
|
-
default=30
|
|
193
|
+
default="30" # Default values should be strings
|
|
181
194
|
)
|
|
182
195
|
]
|
|
183
196
|
```
|
|
184
197
|
|
|
198
|
+
**Parameter Notes:**
|
|
199
|
+
- Parameter `name` is displayed in the Spacelift UI
|
|
200
|
+
- Parameter `id` (optional) is used for programmatic reference
|
|
201
|
+
- `value_from_parameter` can reference either the `id` (if present) or the `name`
|
|
202
|
+
- Parameters are made available as environment variables through Variable definitions
|
|
203
|
+
- Default values must be strings
|
|
204
|
+
- Required parameters cannot have default values
|
|
205
|
+
|
|
185
206
|
### Contexts
|
|
186
207
|
|
|
187
208
|
Define Spacelift contexts with environment variables and custom hooks:
|
|
@@ -191,11 +212,11 @@ __contexts__ = [
|
|
|
191
212
|
Context(
|
|
192
213
|
name_prefix="production",
|
|
193
214
|
description="Production environment context",
|
|
194
|
-
labels=
|
|
215
|
+
labels=["env:prod"],
|
|
195
216
|
env=[
|
|
196
217
|
Variable(
|
|
197
218
|
key="DATABASE_URL",
|
|
198
|
-
value_from_parameter="database_url",
|
|
219
|
+
value_from_parameter="database_url", # Matches parameter id
|
|
199
220
|
sensitive=True
|
|
200
221
|
),
|
|
201
222
|
Variable(
|
|
@@ -229,6 +250,88 @@ __binaries__ = [
|
|
|
229
250
|
]
|
|
230
251
|
```
|
|
231
252
|
|
|
253
|
+
**Context Priority System:**
|
|
254
|
+
|
|
255
|
+
Control the execution order of contexts using the `priority` field:
|
|
256
|
+
|
|
257
|
+
```python
|
|
258
|
+
__contexts__ = [
|
|
259
|
+
Context(
|
|
260
|
+
name_prefix="setup",
|
|
261
|
+
description="Setup context (runs first)",
|
|
262
|
+
priority=0, # Lower numbers run first
|
|
263
|
+
hooks={
|
|
264
|
+
"before_init": ["echo 'Setting up environment'"]
|
|
265
|
+
}
|
|
266
|
+
),
|
|
267
|
+
Context(
|
|
268
|
+
name_prefix="main",
|
|
269
|
+
description="Main context (runs second)",
|
|
270
|
+
priority=1, # Higher numbers run after lower ones
|
|
271
|
+
hooks={
|
|
272
|
+
"before_init": ["echo 'Main execution'"]
|
|
273
|
+
}
|
|
274
|
+
)
|
|
275
|
+
]
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
**Priority Notes:**
|
|
279
|
+
- Default priority is `0`
|
|
280
|
+
- Lower numbers execute first (0, then 1, then 2, etc.)
|
|
281
|
+
- Useful for ensuring setup contexts run before main execution contexts
|
|
282
|
+
|
|
283
|
+
**Binary PATH Management:**
|
|
284
|
+
- When using Python hook methods (e.g., `def before_apply()`), binaries are automatically available in PATH
|
|
285
|
+
- When using raw context hooks, you must manually export the PATH:
|
|
286
|
+
|
|
287
|
+
```python
|
|
288
|
+
__contexts__ = [
|
|
289
|
+
Context(
|
|
290
|
+
name_prefix="kubectl-setup",
|
|
291
|
+
description="Setup kubectl binary for raw hooks",
|
|
292
|
+
hooks={
|
|
293
|
+
"before_init": [
|
|
294
|
+
'export PATH="/mnt/workspace/plugins/plugin_binaries:$PATH"',
|
|
295
|
+
"kubectl version"
|
|
296
|
+
]
|
|
297
|
+
}
|
|
298
|
+
)
|
|
299
|
+
]
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
### Mounted Files
|
|
303
|
+
|
|
304
|
+
Mount file content directly into contexts:
|
|
305
|
+
|
|
306
|
+
```python
|
|
307
|
+
from spaceforge import MountedFile
|
|
308
|
+
|
|
309
|
+
__contexts__ = [
|
|
310
|
+
Context(
|
|
311
|
+
name_prefix="config",
|
|
312
|
+
description="Context with mounted configuration files",
|
|
313
|
+
mounted_files=[
|
|
314
|
+
MountedFile(
|
|
315
|
+
path="tmp/config.json",
|
|
316
|
+
content='{"environment": "production", "debug": false}',
|
|
317
|
+
sensitive=False
|
|
318
|
+
),
|
|
319
|
+
MountedFile(
|
|
320
|
+
path="tmp/secret-config.yaml",
|
|
321
|
+
content="api_key: secret-value\nendpoint: https://api.example.com",
|
|
322
|
+
sensitive=True # Marks content as sensitive
|
|
323
|
+
)
|
|
324
|
+
]
|
|
325
|
+
)
|
|
326
|
+
]
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
**MountedFile Notes:**
|
|
330
|
+
- Files are created at the specified path when the context is applied
|
|
331
|
+
- Content is written exactly as provided
|
|
332
|
+
- Use `sensitive=True` for files containing secrets or sensitive data
|
|
333
|
+
- path is from `/mnt/workspace/`. An example would be `tmp/config.json` which would be mounted at `/mnt/workspace/tmp/config.json`
|
|
334
|
+
|
|
232
335
|
### Policies
|
|
233
336
|
|
|
234
337
|
Define OPA policies for your plugin:
|
|
@@ -237,7 +340,7 @@ Define OPA policies for your plugin:
|
|
|
237
340
|
__policies__ = [
|
|
238
341
|
Policy(
|
|
239
342
|
name_prefix="security-check",
|
|
240
|
-
type="
|
|
343
|
+
type="NOTIFICATION",
|
|
241
344
|
body="""
|
|
242
345
|
package spacelift
|
|
243
346
|
|
|
@@ -245,7 +348,7 @@ webhook[{"endpoint_id": "security-alerts"}] {
|
|
|
245
348
|
input.run_updated.run.marked_unsafe == true
|
|
246
349
|
}
|
|
247
350
|
""",
|
|
248
|
-
labels=
|
|
351
|
+
labels=["security"]
|
|
249
352
|
)
|
|
250
353
|
]
|
|
251
354
|
```
|
|
@@ -258,11 +361,9 @@ Define webhooks to trigger external actions:
|
|
|
258
361
|
__webhooks__ = [
|
|
259
362
|
Webhook(
|
|
260
363
|
name_prefix="security-alerts",
|
|
261
|
-
description="Send security alerts to external service",
|
|
262
364
|
endpoint="https://alerts.example.com/webhook",
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
],
|
|
365
|
+
secretFromParameter="webhook_secret", # Parameter id/name for webhook secret
|
|
366
|
+
labels=["security"]
|
|
266
367
|
)
|
|
267
368
|
]
|
|
268
369
|
```
|
|
@@ -297,7 +398,7 @@ def before_apply(self):
|
|
|
297
398
|
|
|
298
399
|
### Spacelift API Integration
|
|
299
400
|
|
|
300
|
-
Query the Spacelift GraphQL API (requires `SPACELIFT_API_TOKEN` and `
|
|
401
|
+
Query the Spacelift GraphQL API (requires `SPACELIFT_API_TOKEN` and `TF_VAR_spacelift_graphql_endpoint`):
|
|
301
402
|
|
|
302
403
|
```python
|
|
303
404
|
def after_plan(self):
|
|
@@ -317,6 +418,38 @@ def after_plan(self):
|
|
|
317
418
|
self.logger.info(f"Stack state: {result['stack']['state']}")
|
|
318
419
|
```
|
|
319
420
|
|
|
421
|
+
### User Token Authentication
|
|
422
|
+
|
|
423
|
+
Use user API tokens instead of service tokens for Spacelift API access. This is useful because the token on the run may not have sufficient permissions for certain operations.
|
|
424
|
+
|
|
425
|
+
```python
|
|
426
|
+
def before_plan(self):
|
|
427
|
+
# Use user API token for authentication
|
|
428
|
+
user_id = os.environ.get('SPACELIFT_USER_ID')
|
|
429
|
+
user_secret = os.environ.get('SPACELIFT_USER_SECRET')
|
|
430
|
+
|
|
431
|
+
if user_id and user_secret:
|
|
432
|
+
self.use_user_token(user_id, user_secret)
|
|
433
|
+
|
|
434
|
+
# Now you can use the API with user permissions
|
|
435
|
+
result = self.query_api("""
|
|
436
|
+
query {
|
|
437
|
+
viewer {
|
|
438
|
+
id
|
|
439
|
+
login
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
""")
|
|
443
|
+
|
|
444
|
+
self.logger.info(f"Authenticated as: {result['viewer']['login']}")
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
**User Token Notes:**
|
|
448
|
+
- Allows plugins to act on behalf of a specific user
|
|
449
|
+
- Useful for operations requiring user-specific permissions
|
|
450
|
+
- User tokens may have different access levels than service tokens
|
|
451
|
+
- Call `use_user_token()` before making API requests
|
|
452
|
+
|
|
320
453
|
### Access Plan and State
|
|
321
454
|
|
|
322
455
|
Access Terraform plan and state data:
|
|
@@ -353,14 +486,14 @@ def after_plan(self):
|
|
|
353
486
|
self.send_markdown(markdown)
|
|
354
487
|
```
|
|
355
488
|
|
|
356
|
-
###
|
|
489
|
+
### Add to Policy Input
|
|
357
490
|
|
|
358
|
-
|
|
491
|
+
Add custom data to the OPA policy input:
|
|
359
492
|
|
|
360
493
|
The following example will create input available via `input.third_party_metadata.custom.my_custom_data` in your OPA policies:
|
|
361
494
|
```python
|
|
362
495
|
def after_plan(self):
|
|
363
|
-
self.
|
|
496
|
+
self.add_to_policy_input("my_custom_data", {
|
|
364
497
|
"scan_results": {
|
|
365
498
|
"passed": True,
|
|
366
499
|
"issues": []
|
|
@@ -389,9 +522,9 @@ spaceforge generate --help
|
|
|
389
522
|
### Test Plugin Hooks
|
|
390
523
|
|
|
391
524
|
```bash
|
|
392
|
-
# Set parameters
|
|
393
|
-
export
|
|
394
|
-
export
|
|
525
|
+
# Set parameters for local testing (parameters are normally provided by Spacelift)
|
|
526
|
+
export API_KEY="test-key"
|
|
527
|
+
export TIMEOUT="60"
|
|
395
528
|
|
|
396
529
|
# Test specific hook
|
|
397
530
|
spaceforge runner after_plan
|
|
@@ -420,8 +553,8 @@ Access Spacelift environment variables in your hooks:
|
|
|
420
553
|
|
|
421
554
|
```python
|
|
422
555
|
def after_plan(self):
|
|
423
|
-
run_id = os.environ.get('
|
|
424
|
-
stack_id = os.environ.get('
|
|
556
|
+
run_id = os.environ.get('TF_VAR_spacelift_run_id')
|
|
557
|
+
stack_id = os.environ.get('TF_VAR_spacelift_stack_id')
|
|
425
558
|
self.logger.info(f"Processing run {run_id} for stack {stack_id}")
|
|
426
559
|
```
|
|
427
560
|
|
|
@@ -455,7 +588,7 @@ Here's a complete example of a security scanning plugin:
|
|
|
455
588
|
```python
|
|
456
589
|
import os
|
|
457
590
|
import json
|
|
458
|
-
from spaceforge import SpaceforgePlugin, Parameter, Variable, Context, Binary
|
|
591
|
+
from spaceforge import SpaceforgePlugin, Parameter, Variable, Context, Binary, Policy, MountedFile
|
|
459
592
|
|
|
460
593
|
class SecurityScannerPlugin(SpaceforgePlugin):
|
|
461
594
|
__plugin_name__ = "security-scanner"
|
|
@@ -474,13 +607,15 @@ class SecurityScannerPlugin(SpaceforgePlugin):
|
|
|
474
607
|
|
|
475
608
|
__parameters__ = [
|
|
476
609
|
Parameter(
|
|
477
|
-
name="
|
|
610
|
+
name="API Token",
|
|
611
|
+
id="api_token",
|
|
478
612
|
description="Security service API token",
|
|
479
613
|
required=True,
|
|
480
614
|
sensitive=True
|
|
481
615
|
),
|
|
482
616
|
Parameter(
|
|
483
|
-
name="
|
|
617
|
+
name="Severity Threshold",
|
|
618
|
+
id="severity_threshold",
|
|
484
619
|
description="Minimum severity level to report",
|
|
485
620
|
required=False,
|
|
486
621
|
default="medium"
|
|
@@ -581,8 +716,8 @@ Generate and test this plugin:
|
|
|
581
716
|
spaceforge generate security_scanner.py
|
|
582
717
|
|
|
583
718
|
# Test locally
|
|
584
|
-
export
|
|
585
|
-
export
|
|
719
|
+
export API_TOKEN="your-token"
|
|
720
|
+
export SEVERITY_THRESHOLD="high"
|
|
586
721
|
spaceforge runner after_plan
|
|
587
722
|
```
|
|
588
723
|
|