pyworkflow-engine 0.1.7__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.
- dashboard/backend/app/__init__.py +1 -0
- dashboard/backend/app/config.py +32 -0
- dashboard/backend/app/controllers/__init__.py +6 -0
- dashboard/backend/app/controllers/run_controller.py +86 -0
- dashboard/backend/app/controllers/workflow_controller.py +33 -0
- dashboard/backend/app/dependencies/__init__.py +5 -0
- dashboard/backend/app/dependencies/storage.py +50 -0
- dashboard/backend/app/repositories/__init__.py +6 -0
- dashboard/backend/app/repositories/run_repository.py +80 -0
- dashboard/backend/app/repositories/workflow_repository.py +27 -0
- dashboard/backend/app/rest/__init__.py +8 -0
- dashboard/backend/app/rest/v1/__init__.py +12 -0
- dashboard/backend/app/rest/v1/health.py +33 -0
- dashboard/backend/app/rest/v1/runs.py +133 -0
- dashboard/backend/app/rest/v1/workflows.py +41 -0
- dashboard/backend/app/schemas/__init__.py +23 -0
- dashboard/backend/app/schemas/common.py +16 -0
- dashboard/backend/app/schemas/event.py +24 -0
- dashboard/backend/app/schemas/hook.py +25 -0
- dashboard/backend/app/schemas/run.py +54 -0
- dashboard/backend/app/schemas/step.py +28 -0
- dashboard/backend/app/schemas/workflow.py +31 -0
- dashboard/backend/app/server.py +87 -0
- dashboard/backend/app/services/__init__.py +6 -0
- dashboard/backend/app/services/run_service.py +240 -0
- dashboard/backend/app/services/workflow_service.py +155 -0
- dashboard/backend/main.py +18 -0
- docs/concepts/cancellation.mdx +362 -0
- docs/concepts/continue-as-new.mdx +434 -0
- docs/concepts/events.mdx +266 -0
- docs/concepts/fault-tolerance.mdx +370 -0
- docs/concepts/hooks.mdx +552 -0
- docs/concepts/limitations.mdx +167 -0
- docs/concepts/schedules.mdx +775 -0
- docs/concepts/sleep.mdx +312 -0
- docs/concepts/steps.mdx +301 -0
- docs/concepts/workflows.mdx +255 -0
- docs/guides/cli.mdx +942 -0
- docs/guides/configuration.mdx +560 -0
- docs/introduction.mdx +155 -0
- docs/quickstart.mdx +279 -0
- examples/__init__.py +1 -0
- examples/celery/__init__.py +1 -0
- examples/celery/durable/docker-compose.yml +55 -0
- examples/celery/durable/pyworkflow.config.yaml +12 -0
- examples/celery/durable/workflows/__init__.py +122 -0
- examples/celery/durable/workflows/basic.py +87 -0
- examples/celery/durable/workflows/batch_processing.py +102 -0
- examples/celery/durable/workflows/cancellation.py +273 -0
- examples/celery/durable/workflows/child_workflow_patterns.py +240 -0
- examples/celery/durable/workflows/child_workflows.py +202 -0
- examples/celery/durable/workflows/continue_as_new.py +260 -0
- examples/celery/durable/workflows/fault_tolerance.py +210 -0
- examples/celery/durable/workflows/hooks.py +211 -0
- examples/celery/durable/workflows/idempotency.py +112 -0
- examples/celery/durable/workflows/long_running.py +99 -0
- examples/celery/durable/workflows/retries.py +101 -0
- examples/celery/durable/workflows/schedules.py +209 -0
- examples/celery/transient/01_basic_workflow.py +91 -0
- examples/celery/transient/02_fault_tolerance.py +257 -0
- examples/celery/transient/__init__.py +20 -0
- examples/celery/transient/pyworkflow.config.yaml +25 -0
- examples/local/__init__.py +1 -0
- examples/local/durable/01_basic_workflow.py +94 -0
- examples/local/durable/02_file_storage.py +132 -0
- examples/local/durable/03_retries.py +169 -0
- examples/local/durable/04_long_running.py +119 -0
- examples/local/durable/05_event_log.py +145 -0
- examples/local/durable/06_idempotency.py +148 -0
- examples/local/durable/07_hooks.py +334 -0
- examples/local/durable/08_cancellation.py +233 -0
- examples/local/durable/09_child_workflows.py +198 -0
- examples/local/durable/10_child_workflow_patterns.py +265 -0
- examples/local/durable/11_continue_as_new.py +249 -0
- examples/local/durable/12_schedules.py +198 -0
- examples/local/durable/__init__.py +1 -0
- examples/local/transient/01_quick_tasks.py +87 -0
- examples/local/transient/02_retries.py +130 -0
- examples/local/transient/03_sleep.py +141 -0
- examples/local/transient/__init__.py +1 -0
- pyworkflow/__init__.py +256 -0
- pyworkflow/aws/__init__.py +68 -0
- pyworkflow/aws/context.py +234 -0
- pyworkflow/aws/handler.py +184 -0
- pyworkflow/aws/testing.py +310 -0
- pyworkflow/celery/__init__.py +41 -0
- pyworkflow/celery/app.py +198 -0
- pyworkflow/celery/scheduler.py +315 -0
- pyworkflow/celery/tasks.py +1746 -0
- pyworkflow/cli/__init__.py +132 -0
- pyworkflow/cli/__main__.py +6 -0
- pyworkflow/cli/commands/__init__.py +1 -0
- pyworkflow/cli/commands/hooks.py +640 -0
- pyworkflow/cli/commands/quickstart.py +495 -0
- pyworkflow/cli/commands/runs.py +773 -0
- pyworkflow/cli/commands/scheduler.py +130 -0
- pyworkflow/cli/commands/schedules.py +794 -0
- pyworkflow/cli/commands/setup.py +703 -0
- pyworkflow/cli/commands/worker.py +413 -0
- pyworkflow/cli/commands/workflows.py +1257 -0
- pyworkflow/cli/output/__init__.py +1 -0
- pyworkflow/cli/output/formatters.py +321 -0
- pyworkflow/cli/output/styles.py +121 -0
- pyworkflow/cli/utils/__init__.py +1 -0
- pyworkflow/cli/utils/async_helpers.py +30 -0
- pyworkflow/cli/utils/config.py +130 -0
- pyworkflow/cli/utils/config_generator.py +344 -0
- pyworkflow/cli/utils/discovery.py +53 -0
- pyworkflow/cli/utils/docker_manager.py +651 -0
- pyworkflow/cli/utils/interactive.py +364 -0
- pyworkflow/cli/utils/storage.py +115 -0
- pyworkflow/config.py +329 -0
- pyworkflow/context/__init__.py +63 -0
- pyworkflow/context/aws.py +230 -0
- pyworkflow/context/base.py +416 -0
- pyworkflow/context/local.py +930 -0
- pyworkflow/context/mock.py +381 -0
- pyworkflow/core/__init__.py +0 -0
- pyworkflow/core/exceptions.py +353 -0
- pyworkflow/core/registry.py +313 -0
- pyworkflow/core/scheduled.py +328 -0
- pyworkflow/core/step.py +494 -0
- pyworkflow/core/workflow.py +294 -0
- pyworkflow/discovery.py +248 -0
- pyworkflow/engine/__init__.py +0 -0
- pyworkflow/engine/events.py +879 -0
- pyworkflow/engine/executor.py +682 -0
- pyworkflow/engine/replay.py +273 -0
- pyworkflow/observability/__init__.py +19 -0
- pyworkflow/observability/logging.py +234 -0
- pyworkflow/primitives/__init__.py +33 -0
- pyworkflow/primitives/child_handle.py +174 -0
- pyworkflow/primitives/child_workflow.py +372 -0
- pyworkflow/primitives/continue_as_new.py +101 -0
- pyworkflow/primitives/define_hook.py +150 -0
- pyworkflow/primitives/hooks.py +97 -0
- pyworkflow/primitives/resume_hook.py +210 -0
- pyworkflow/primitives/schedule.py +545 -0
- pyworkflow/primitives/shield.py +96 -0
- pyworkflow/primitives/sleep.py +100 -0
- pyworkflow/runtime/__init__.py +21 -0
- pyworkflow/runtime/base.py +179 -0
- pyworkflow/runtime/celery.py +310 -0
- pyworkflow/runtime/factory.py +101 -0
- pyworkflow/runtime/local.py +706 -0
- pyworkflow/scheduler/__init__.py +9 -0
- pyworkflow/scheduler/local.py +248 -0
- pyworkflow/serialization/__init__.py +0 -0
- pyworkflow/serialization/decoder.py +146 -0
- pyworkflow/serialization/encoder.py +162 -0
- pyworkflow/storage/__init__.py +54 -0
- pyworkflow/storage/base.py +612 -0
- pyworkflow/storage/config.py +185 -0
- pyworkflow/storage/dynamodb.py +1315 -0
- pyworkflow/storage/file.py +827 -0
- pyworkflow/storage/memory.py +549 -0
- pyworkflow/storage/postgres.py +1161 -0
- pyworkflow/storage/schemas.py +486 -0
- pyworkflow/storage/sqlite.py +1136 -0
- pyworkflow/utils/__init__.py +0 -0
- pyworkflow/utils/duration.py +177 -0
- pyworkflow/utils/schedule.py +391 -0
- pyworkflow_engine-0.1.7.dist-info/METADATA +687 -0
- pyworkflow_engine-0.1.7.dist-info/RECORD +196 -0
- pyworkflow_engine-0.1.7.dist-info/WHEEL +5 -0
- pyworkflow_engine-0.1.7.dist-info/entry_points.txt +2 -0
- pyworkflow_engine-0.1.7.dist-info/licenses/LICENSE +21 -0
- pyworkflow_engine-0.1.7.dist-info/top_level.txt +5 -0
- tests/examples/__init__.py +0 -0
- tests/integration/__init__.py +0 -0
- tests/integration/test_cancellation.py +330 -0
- tests/integration/test_child_workflows.py +439 -0
- tests/integration/test_continue_as_new.py +428 -0
- tests/integration/test_dynamodb_storage.py +1146 -0
- tests/integration/test_fault_tolerance.py +369 -0
- tests/integration/test_schedule_storage.py +484 -0
- tests/unit/__init__.py +0 -0
- tests/unit/backends/__init__.py +1 -0
- tests/unit/backends/test_dynamodb_storage.py +1554 -0
- tests/unit/backends/test_postgres_storage.py +1281 -0
- tests/unit/backends/test_sqlite_storage.py +1460 -0
- tests/unit/conftest.py +41 -0
- tests/unit/test_cancellation.py +364 -0
- tests/unit/test_child_workflows.py +680 -0
- tests/unit/test_continue_as_new.py +441 -0
- tests/unit/test_event_limits.py +316 -0
- tests/unit/test_executor.py +320 -0
- tests/unit/test_fault_tolerance.py +334 -0
- tests/unit/test_hooks.py +495 -0
- tests/unit/test_registry.py +261 -0
- tests/unit/test_replay.py +420 -0
- tests/unit/test_schedule_schemas.py +285 -0
- tests/unit/test_schedule_utils.py +286 -0
- tests/unit/test_scheduled_workflow.py +274 -0
- tests/unit/test_step.py +353 -0
- tests/unit/test_workflow.py +243 -0
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Docker infrastructure management utilities.
|
|
3
|
+
|
|
4
|
+
This module provides functions for managing Docker Compose services,
|
|
5
|
+
generating docker-compose.yml files, and checking service health.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import shutil
|
|
9
|
+
import socket
|
|
10
|
+
import subprocess
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def check_docker_available() -> tuple[bool, str | None]:
|
|
19
|
+
"""
|
|
20
|
+
Check if Docker and Docker Compose are available and running.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Tuple of (available, error_message)
|
|
24
|
+
- (True, None) if Docker is available
|
|
25
|
+
- (False, error_message) if not available
|
|
26
|
+
|
|
27
|
+
Example:
|
|
28
|
+
>>> available, error = check_docker_available()
|
|
29
|
+
>>> if not available:
|
|
30
|
+
... print(f"Docker error: {error}")
|
|
31
|
+
"""
|
|
32
|
+
# Check if docker command exists
|
|
33
|
+
if not shutil.which("docker"):
|
|
34
|
+
return False, "Docker is not installed. Install from: https://docs.docker.com/get-docker/"
|
|
35
|
+
|
|
36
|
+
# Check if docker daemon is running
|
|
37
|
+
try:
|
|
38
|
+
result = subprocess.run(
|
|
39
|
+
["docker", "info"],
|
|
40
|
+
capture_output=True,
|
|
41
|
+
text=True,
|
|
42
|
+
timeout=5,
|
|
43
|
+
)
|
|
44
|
+
if result.returncode != 0:
|
|
45
|
+
return False, "Docker daemon is not running. Please start Docker."
|
|
46
|
+
except subprocess.TimeoutExpired:
|
|
47
|
+
return False, "Docker daemon is not responding"
|
|
48
|
+
except Exception as e:
|
|
49
|
+
return False, f"Error checking Docker: {str(e)}"
|
|
50
|
+
|
|
51
|
+
# Check docker compose command (modern: 'docker compose')
|
|
52
|
+
try:
|
|
53
|
+
result = subprocess.run(
|
|
54
|
+
["docker", "compose", "version"],
|
|
55
|
+
capture_output=True,
|
|
56
|
+
text=True,
|
|
57
|
+
timeout=5,
|
|
58
|
+
)
|
|
59
|
+
if result.returncode == 0:
|
|
60
|
+
return True, None
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
# Fallback: check legacy docker-compose
|
|
65
|
+
if shutil.which("docker-compose"):
|
|
66
|
+
return True, None
|
|
67
|
+
|
|
68
|
+
return False, "Docker Compose is not available. Upgrade Docker to get 'docker compose'."
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def generate_docker_compose_content(
|
|
72
|
+
storage_type: str,
|
|
73
|
+
storage_path: str | None = None,
|
|
74
|
+
dynamodb_endpoint_url: str | None = None,
|
|
75
|
+
) -> str:
|
|
76
|
+
"""
|
|
77
|
+
Generate docker-compose.yml content for PyWorkflow services.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
storage_type: Storage backend type ("sqlite", "file", "memory", "dynamodb")
|
|
81
|
+
storage_path: Path to storage (for file/sqlite backends)
|
|
82
|
+
dynamodb_endpoint_url: Optional local DynamoDB endpoint URL
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
docker-compose.yml content as string
|
|
86
|
+
|
|
87
|
+
Example:
|
|
88
|
+
>>> compose_content = generate_docker_compose_content(
|
|
89
|
+
... storage_type="sqlite",
|
|
90
|
+
... storage_path="./pyworkflow_data/pyworkflow.db"
|
|
91
|
+
... )
|
|
92
|
+
"""
|
|
93
|
+
# Normalize storage path - extract directory for volume mapping
|
|
94
|
+
if not storage_path:
|
|
95
|
+
volume_mapping = "./pyworkflow_data"
|
|
96
|
+
else:
|
|
97
|
+
# For SQLite, storage_path is a file (e.g., ./pyworkflow_data/pyworkflow.db)
|
|
98
|
+
# We need to mount the directory, not the file
|
|
99
|
+
from pathlib import Path
|
|
100
|
+
|
|
101
|
+
path_obj = Path(storage_path)
|
|
102
|
+
if storage_type == "sqlite" and path_obj.suffix == ".db":
|
|
103
|
+
# Mount the parent directory
|
|
104
|
+
volume_mapping = str(path_obj.parent)
|
|
105
|
+
else:
|
|
106
|
+
# For file storage, it's already a directory
|
|
107
|
+
volume_mapping = storage_path
|
|
108
|
+
|
|
109
|
+
# Ensure volume_mapping is a proper path (starts with ./ or / for bind mount)
|
|
110
|
+
if not volume_mapping.startswith(("./", "/", "~")):
|
|
111
|
+
volume_mapping = f"./{volume_mapping}"
|
|
112
|
+
|
|
113
|
+
# DynamoDB Local service (only for dynamodb storage type with local endpoint)
|
|
114
|
+
dynamodb_service = ""
|
|
115
|
+
dynamodb_depends = ""
|
|
116
|
+
dynamodb_env = ""
|
|
117
|
+
if storage_type == "dynamodb":
|
|
118
|
+
dynamodb_service = """
|
|
119
|
+
dynamodb-local:
|
|
120
|
+
image: amazon/dynamodb-local:latest
|
|
121
|
+
container_name: pyworkflow-dynamodb-local
|
|
122
|
+
ports:
|
|
123
|
+
- "8000:8000"
|
|
124
|
+
command: "-jar DynamoDBLocal.jar -sharedDb -inMemory"
|
|
125
|
+
healthcheck:
|
|
126
|
+
test: ["CMD-SHELL", "curl -s http://localhost:8000 || exit 1"]
|
|
127
|
+
interval: 5s
|
|
128
|
+
timeout: 3s
|
|
129
|
+
retries: 5
|
|
130
|
+
restart: unless-stopped
|
|
131
|
+
"""
|
|
132
|
+
dynamodb_depends = """
|
|
133
|
+
dynamodb-local:
|
|
134
|
+
condition: service_healthy"""
|
|
135
|
+
dynamodb_env = """
|
|
136
|
+
- DASHBOARD_DYNAMODB_ENDPOINT_URL=http://dynamodb-local:8000"""
|
|
137
|
+
|
|
138
|
+
template = f"""services:
|
|
139
|
+
redis:
|
|
140
|
+
image: redis:7-alpine
|
|
141
|
+
container_name: pyworkflow-redis
|
|
142
|
+
ports:
|
|
143
|
+
- "6379:6379"
|
|
144
|
+
volumes:
|
|
145
|
+
- redis_data:/data
|
|
146
|
+
healthcheck:
|
|
147
|
+
test: ["CMD", "redis-cli", "ping"]
|
|
148
|
+
interval: 5s
|
|
149
|
+
timeout: 3s
|
|
150
|
+
retries: 5
|
|
151
|
+
restart: unless-stopped
|
|
152
|
+
{dynamodb_service}
|
|
153
|
+
dashboard-backend:
|
|
154
|
+
image: yashabro/pyworkflow-dashboard-backend:latest
|
|
155
|
+
platform: linux/amd64
|
|
156
|
+
container_name: pyworkflow-dashboard-backend
|
|
157
|
+
working_dir: /app/project
|
|
158
|
+
ports:
|
|
159
|
+
- "8585:8585"
|
|
160
|
+
environment:
|
|
161
|
+
- DASHBOARD_PYWORKFLOW_CONFIG_PATH=/app/project/pyworkflow.config.yaml
|
|
162
|
+
- DASHBOARD_STORAGE_TYPE={storage_type}
|
|
163
|
+
- DASHBOARD_STORAGE_PATH=/app/project/pyworkflow_data
|
|
164
|
+
- DASHBOARD_HOST=0.0.0.0
|
|
165
|
+
- DASHBOARD_PORT=8585
|
|
166
|
+
- DASHBOARD_CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
|
|
167
|
+
- PYWORKFLOW_CELERY_BROKER=redis://redis:6379/0
|
|
168
|
+
- PYWORKFLOW_CELERY_RESULT_BACKEND=redis://redis:6379/1
|
|
169
|
+
- PYTHONPATH=/app/project{dynamodb_env}
|
|
170
|
+
volumes:
|
|
171
|
+
- .:/app/project:ro
|
|
172
|
+
- {volume_mapping}:/app/project/pyworkflow_data
|
|
173
|
+
depends_on:
|
|
174
|
+
redis:
|
|
175
|
+
condition: service_healthy{dynamodb_depends}
|
|
176
|
+
restart: unless-stopped
|
|
177
|
+
|
|
178
|
+
dashboard-frontend:
|
|
179
|
+
image: yashabro/pyworkflow-dashboard-frontend:latest
|
|
180
|
+
platform: linux/amd64
|
|
181
|
+
container_name: pyworkflow-dashboard-frontend
|
|
182
|
+
ports:
|
|
183
|
+
- "5173:80"
|
|
184
|
+
environment:
|
|
185
|
+
- VITE_API_URL=http://localhost:8585
|
|
186
|
+
depends_on:
|
|
187
|
+
- dashboard-backend
|
|
188
|
+
restart: unless-stopped
|
|
189
|
+
|
|
190
|
+
volumes:
|
|
191
|
+
redis_data:
|
|
192
|
+
driver: local
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
return template
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def generate_postgres_docker_compose_content(
|
|
199
|
+
postgres_host: str = "postgres",
|
|
200
|
+
postgres_port: int = 5432,
|
|
201
|
+
postgres_user: str = "pyworkflow",
|
|
202
|
+
postgres_password: str = "pyworkflow",
|
|
203
|
+
postgres_database: str = "pyworkflow",
|
|
204
|
+
) -> str:
|
|
205
|
+
"""
|
|
206
|
+
Generate docker-compose.yml content for PostgreSQL-based PyWorkflow services.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
postgres_host: PostgreSQL host (container name for docker networking)
|
|
210
|
+
postgres_port: PostgreSQL port
|
|
211
|
+
postgres_user: PostgreSQL user
|
|
212
|
+
postgres_password: PostgreSQL password
|
|
213
|
+
postgres_database: PostgreSQL database name
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
docker-compose.yml content as string
|
|
217
|
+
|
|
218
|
+
Example:
|
|
219
|
+
>>> compose_content = generate_postgres_docker_compose_content()
|
|
220
|
+
"""
|
|
221
|
+
template = f"""services:
|
|
222
|
+
postgres:
|
|
223
|
+
image: postgres:16-alpine
|
|
224
|
+
container_name: pyworkflow-postgres
|
|
225
|
+
ports:
|
|
226
|
+
- "{postgres_port}:5432"
|
|
227
|
+
environment:
|
|
228
|
+
- POSTGRES_USER={postgres_user}
|
|
229
|
+
- POSTGRES_PASSWORD={postgres_password}
|
|
230
|
+
- POSTGRES_DB={postgres_database}
|
|
231
|
+
volumes:
|
|
232
|
+
- postgres_data:/var/lib/postgresql/data
|
|
233
|
+
healthcheck:
|
|
234
|
+
test: ["CMD-SHELL", "pg_isready -U {postgres_user} -d {postgres_database}"]
|
|
235
|
+
interval: 5s
|
|
236
|
+
timeout: 3s
|
|
237
|
+
retries: 5
|
|
238
|
+
restart: unless-stopped
|
|
239
|
+
|
|
240
|
+
redis:
|
|
241
|
+
image: redis:7-alpine
|
|
242
|
+
container_name: pyworkflow-redis
|
|
243
|
+
ports:
|
|
244
|
+
- "6379:6379"
|
|
245
|
+
volumes:
|
|
246
|
+
- redis_data:/data
|
|
247
|
+
healthcheck:
|
|
248
|
+
test: ["CMD", "redis-cli", "ping"]
|
|
249
|
+
interval: 5s
|
|
250
|
+
timeout: 3s
|
|
251
|
+
retries: 5
|
|
252
|
+
restart: unless-stopped
|
|
253
|
+
|
|
254
|
+
dashboard-backend:
|
|
255
|
+
image: yashabro/pyworkflow-dashboard-backend:latest
|
|
256
|
+
container_name: pyworkflow-dashboard-backend
|
|
257
|
+
working_dir: /app/project
|
|
258
|
+
ports:
|
|
259
|
+
- "8585:8585"
|
|
260
|
+
environment:
|
|
261
|
+
- DASHBOARD_PYWORKFLOW_CONFIG_PATH=/app/project/pyworkflow.config.yaml
|
|
262
|
+
- DASHBOARD_STORAGE_TYPE=postgres
|
|
263
|
+
- DASHBOARD_POSTGRES_HOST={postgres_host}
|
|
264
|
+
- DASHBOARD_POSTGRES_PORT=5432
|
|
265
|
+
- DASHBOARD_POSTGRES_USER={postgres_user}
|
|
266
|
+
- DASHBOARD_POSTGRES_PASSWORD={postgres_password}
|
|
267
|
+
- DASHBOARD_POSTGRES_DATABASE={postgres_database}
|
|
268
|
+
- DASHBOARD_HOST=0.0.0.0
|
|
269
|
+
- DASHBOARD_PORT=8585
|
|
270
|
+
- DASHBOARD_CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
|
|
271
|
+
- PYWORKFLOW_CELERY_BROKER=redis://redis:6379/0
|
|
272
|
+
- PYWORKFLOW_CELERY_RESULT_BACKEND=redis://redis:6379/1
|
|
273
|
+
- PYTHONPATH=/app/project
|
|
274
|
+
volumes:
|
|
275
|
+
- .:/app/project:ro
|
|
276
|
+
depends_on:
|
|
277
|
+
postgres:
|
|
278
|
+
condition: service_healthy
|
|
279
|
+
redis:
|
|
280
|
+
condition: service_healthy
|
|
281
|
+
restart: unless-stopped
|
|
282
|
+
|
|
283
|
+
dashboard-frontend:
|
|
284
|
+
image: yashabro/pyworkflow-dashboard-frontend:latest
|
|
285
|
+
container_name: pyworkflow-dashboard-frontend
|
|
286
|
+
ports:
|
|
287
|
+
- "5173:80"
|
|
288
|
+
environment:
|
|
289
|
+
- VITE_API_URL=http://localhost:8585
|
|
290
|
+
depends_on:
|
|
291
|
+
- dashboard-backend
|
|
292
|
+
restart: unless-stopped
|
|
293
|
+
|
|
294
|
+
volumes:
|
|
295
|
+
postgres_data:
|
|
296
|
+
driver: local
|
|
297
|
+
redis_data:
|
|
298
|
+
driver: local
|
|
299
|
+
"""
|
|
300
|
+
|
|
301
|
+
return template
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def write_docker_compose(content: str, path: Path) -> None:
|
|
305
|
+
"""
|
|
306
|
+
Write docker-compose.yml to file.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
content: docker-compose.yml content
|
|
310
|
+
path: Target file path
|
|
311
|
+
|
|
312
|
+
Example:
|
|
313
|
+
>>> compose_content = generate_docker_compose_content("sqlite")
|
|
314
|
+
>>> write_docker_compose(compose_content, Path("./docker-compose.yml"))
|
|
315
|
+
"""
|
|
316
|
+
path.write_text(content)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def get_docker_compose_command() -> list[str]:
|
|
320
|
+
"""
|
|
321
|
+
Get the appropriate docker compose command for the platform.
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
Command as list (e.g., ["docker", "compose"] or ["docker-compose"])
|
|
325
|
+
|
|
326
|
+
Example:
|
|
327
|
+
>>> cmd = get_docker_compose_command()
|
|
328
|
+
>>> # Use in subprocess: subprocess.run(cmd + ["up", "-d"])
|
|
329
|
+
"""
|
|
330
|
+
# Try modern 'docker compose' first
|
|
331
|
+
try:
|
|
332
|
+
result = subprocess.run(
|
|
333
|
+
["docker", "compose", "version"],
|
|
334
|
+
capture_output=True,
|
|
335
|
+
timeout=2,
|
|
336
|
+
)
|
|
337
|
+
if result.returncode == 0:
|
|
338
|
+
return ["docker", "compose"]
|
|
339
|
+
except Exception:
|
|
340
|
+
pass
|
|
341
|
+
|
|
342
|
+
# Fall back to legacy 'docker-compose'
|
|
343
|
+
if shutil.which("docker-compose"):
|
|
344
|
+
return ["docker-compose"]
|
|
345
|
+
|
|
346
|
+
# Default to modern syntax
|
|
347
|
+
return ["docker", "compose"]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def run_docker_command(
|
|
351
|
+
args: list[str],
|
|
352
|
+
compose_file: Path | None = None,
|
|
353
|
+
capture_output: bool = False,
|
|
354
|
+
stream_output: bool = False,
|
|
355
|
+
) -> tuple[bool, str]:
|
|
356
|
+
"""
|
|
357
|
+
Run a docker compose command.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
args: Command arguments (e.g., ["up", "-d"])
|
|
361
|
+
compose_file: Path to docker-compose.yml (default: ./docker-compose.yml)
|
|
362
|
+
capture_output: If True, capture and return output
|
|
363
|
+
stream_output: If True, stream output to console in real-time
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
Tuple of (success, output_or_error_message)
|
|
367
|
+
|
|
368
|
+
Example:
|
|
369
|
+
>>> success, output = run_docker_command(["up", "-d"])
|
|
370
|
+
>>> if not success:
|
|
371
|
+
... print(f"Error: {output}")
|
|
372
|
+
"""
|
|
373
|
+
cmd = get_docker_compose_command()
|
|
374
|
+
|
|
375
|
+
if compose_file:
|
|
376
|
+
cmd.extend(["-f", str(compose_file)])
|
|
377
|
+
|
|
378
|
+
cmd.extend(args)
|
|
379
|
+
|
|
380
|
+
try:
|
|
381
|
+
if stream_output:
|
|
382
|
+
# Stream output in real-time with spinner
|
|
383
|
+
import sys
|
|
384
|
+
import threading
|
|
385
|
+
|
|
386
|
+
# ANSI codes
|
|
387
|
+
GRAY = "\033[90m"
|
|
388
|
+
RESET = "\033[0m"
|
|
389
|
+
CLEAR_LINE = "\033[K"
|
|
390
|
+
|
|
391
|
+
spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
392
|
+
spinner_running = True
|
|
393
|
+
current_frame = [0]
|
|
394
|
+
|
|
395
|
+
def spinner_thread():
|
|
396
|
+
"""Background thread for spinner animation."""
|
|
397
|
+
while spinner_running:
|
|
398
|
+
frame = spinner_frames[current_frame[0] % len(spinner_frames)]
|
|
399
|
+
# Print spinner at current position
|
|
400
|
+
sys.stdout.write(f"{GRAY}{frame} Working...{RESET}{CLEAR_LINE}\r")
|
|
401
|
+
sys.stdout.flush()
|
|
402
|
+
current_frame[0] += 1
|
|
403
|
+
import time
|
|
404
|
+
|
|
405
|
+
time.sleep(0.1)
|
|
406
|
+
# Clear spinner when done
|
|
407
|
+
sys.stdout.write(f"{CLEAR_LINE}\r")
|
|
408
|
+
sys.stdout.flush()
|
|
409
|
+
|
|
410
|
+
# Start spinner in background
|
|
411
|
+
spinner = threading.Thread(target=spinner_thread, daemon=True)
|
|
412
|
+
spinner.start()
|
|
413
|
+
|
|
414
|
+
process = subprocess.Popen(
|
|
415
|
+
cmd,
|
|
416
|
+
stdout=subprocess.PIPE,
|
|
417
|
+
stderr=subprocess.STDOUT,
|
|
418
|
+
text=True,
|
|
419
|
+
bufsize=1,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
output_lines = []
|
|
423
|
+
if process.stdout:
|
|
424
|
+
for line in process.stdout:
|
|
425
|
+
# Clear spinner line, print log in gray, restore spinner
|
|
426
|
+
sys.stdout.write(f"{CLEAR_LINE}\r")
|
|
427
|
+
print(f"{GRAY} {line.rstrip()}{RESET}")
|
|
428
|
+
output_lines.append(line)
|
|
429
|
+
|
|
430
|
+
process.wait(timeout=600) # 10 minute timeout for builds
|
|
431
|
+
spinner_running = False
|
|
432
|
+
spinner.join(timeout=0.5)
|
|
433
|
+
|
|
434
|
+
output = "".join(output_lines)
|
|
435
|
+
|
|
436
|
+
if process.returncode == 0:
|
|
437
|
+
return True, output if capture_output else "Success"
|
|
438
|
+
else:
|
|
439
|
+
return False, output
|
|
440
|
+
|
|
441
|
+
else:
|
|
442
|
+
# Capture output
|
|
443
|
+
result = subprocess.run(
|
|
444
|
+
cmd,
|
|
445
|
+
capture_output=True,
|
|
446
|
+
text=True,
|
|
447
|
+
timeout=300, # 5 minute timeout
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
if result.returncode == 0:
|
|
451
|
+
return True, result.stdout if capture_output else "Success"
|
|
452
|
+
else:
|
|
453
|
+
error_msg = result.stderr or result.stdout or "Unknown error"
|
|
454
|
+
return False, error_msg
|
|
455
|
+
|
|
456
|
+
except subprocess.TimeoutExpired:
|
|
457
|
+
return False, "Command timed out"
|
|
458
|
+
except Exception as e:
|
|
459
|
+
return False, f"Error running docker command: {str(e)}"
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def wait_for_tcp_port(
|
|
463
|
+
host: str,
|
|
464
|
+
port: int,
|
|
465
|
+
timeout: int = 30,
|
|
466
|
+
) -> bool:
|
|
467
|
+
"""
|
|
468
|
+
Wait for a TCP port to become available.
|
|
469
|
+
|
|
470
|
+
Args:
|
|
471
|
+
host: Hostname or IP address
|
|
472
|
+
port: Port number
|
|
473
|
+
timeout: Maximum wait time in seconds
|
|
474
|
+
|
|
475
|
+
Returns:
|
|
476
|
+
True if port is available, False if timeout
|
|
477
|
+
|
|
478
|
+
Example:
|
|
479
|
+
>>> if wait_for_tcp_port("localhost", 6379, timeout=10):
|
|
480
|
+
... print("Redis is ready!")
|
|
481
|
+
"""
|
|
482
|
+
start_time = time.time()
|
|
483
|
+
|
|
484
|
+
while time.time() - start_time < timeout:
|
|
485
|
+
try:
|
|
486
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
487
|
+
sock.settimeout(1)
|
|
488
|
+
result = sock.connect_ex((host, port))
|
|
489
|
+
sock.close()
|
|
490
|
+
|
|
491
|
+
if result == 0:
|
|
492
|
+
return True
|
|
493
|
+
|
|
494
|
+
except Exception:
|
|
495
|
+
pass
|
|
496
|
+
|
|
497
|
+
time.sleep(0.5)
|
|
498
|
+
|
|
499
|
+
return False
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def wait_for_http_service(
|
|
503
|
+
url: str,
|
|
504
|
+
timeout: int = 30,
|
|
505
|
+
expected_status: int = 200,
|
|
506
|
+
) -> bool:
|
|
507
|
+
"""
|
|
508
|
+
Wait for an HTTP service to become available.
|
|
509
|
+
|
|
510
|
+
Args:
|
|
511
|
+
url: Service URL to check
|
|
512
|
+
timeout: Maximum wait time in seconds
|
|
513
|
+
expected_status: Expected HTTP status code
|
|
514
|
+
|
|
515
|
+
Returns:
|
|
516
|
+
True if service responds with expected status, False if timeout
|
|
517
|
+
|
|
518
|
+
Example:
|
|
519
|
+
>>> if wait_for_http_service("http://localhost:8585/api/v1/health"):
|
|
520
|
+
... print("Dashboard backend is ready!")
|
|
521
|
+
"""
|
|
522
|
+
start_time = time.time()
|
|
523
|
+
|
|
524
|
+
while time.time() - start_time < timeout:
|
|
525
|
+
try:
|
|
526
|
+
response = httpx.get(url, timeout=2.0)
|
|
527
|
+
if response.status_code == expected_status or response.status_code < 500:
|
|
528
|
+
return True
|
|
529
|
+
except (httpx.ConnectError, httpx.TimeoutException):
|
|
530
|
+
pass
|
|
531
|
+
except Exception:
|
|
532
|
+
# Other errors might indicate the service is up but returning an error
|
|
533
|
+
return True
|
|
534
|
+
|
|
535
|
+
time.sleep(1)
|
|
536
|
+
|
|
537
|
+
return False
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def check_service_health(service_checks: dict[str, dict[str, Any]]) -> dict[str, bool]:
|
|
541
|
+
"""
|
|
542
|
+
Check health of multiple services.
|
|
543
|
+
|
|
544
|
+
Args:
|
|
545
|
+
service_checks: Dict mapping service names to check configs
|
|
546
|
+
Example:
|
|
547
|
+
{
|
|
548
|
+
"redis": {"type": "tcp", "host": "localhost", "port": 6379},
|
|
549
|
+
"backend": {"type": "http", "url": "http://localhost:8585/api/v1/health"}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
Returns:
|
|
553
|
+
Dict mapping service names to health status (True/False)
|
|
554
|
+
|
|
555
|
+
Example:
|
|
556
|
+
>>> checks = {
|
|
557
|
+
... "Redis": {"type": "tcp", "host": "localhost", "port": 6379},
|
|
558
|
+
... "Dashboard": {"type": "http", "url": "http://localhost:8585/api/v1/health"}
|
|
559
|
+
... }
|
|
560
|
+
>>> results = check_service_health(checks)
|
|
561
|
+
>>> for service, healthy in results.items():
|
|
562
|
+
... print(f"{service}: {'✓' if healthy else '✗'}")
|
|
563
|
+
"""
|
|
564
|
+
results = {}
|
|
565
|
+
|
|
566
|
+
for service_name, check_config in service_checks.items():
|
|
567
|
+
check_type = check_config.get("type")
|
|
568
|
+
|
|
569
|
+
if check_type == "tcp":
|
|
570
|
+
host = check_config.get("host", "localhost")
|
|
571
|
+
port = check_config["port"]
|
|
572
|
+
results[service_name] = wait_for_tcp_port(host, port, timeout=5)
|
|
573
|
+
|
|
574
|
+
elif check_type == "http":
|
|
575
|
+
url = check_config["url"]
|
|
576
|
+
expected_status = check_config.get("expected_status", 200)
|
|
577
|
+
results[service_name] = wait_for_http_service(
|
|
578
|
+
url, timeout=5, expected_status=expected_status
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
else:
|
|
582
|
+
results[service_name] = False
|
|
583
|
+
|
|
584
|
+
return results
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def get_service_logs(
|
|
588
|
+
service_name: str,
|
|
589
|
+
compose_file: Path | None = None,
|
|
590
|
+
lines: int = 50,
|
|
591
|
+
) -> str:
|
|
592
|
+
"""
|
|
593
|
+
Get logs from a docker compose service.
|
|
594
|
+
|
|
595
|
+
Args:
|
|
596
|
+
service_name: Name of the service
|
|
597
|
+
compose_file: Path to docker-compose.yml
|
|
598
|
+
lines: Number of log lines to retrieve
|
|
599
|
+
|
|
600
|
+
Returns:
|
|
601
|
+
Service logs as string
|
|
602
|
+
|
|
603
|
+
Example:
|
|
604
|
+
>>> logs = get_service_logs("dashboard-backend", lines=20)
|
|
605
|
+
>>> print(logs)
|
|
606
|
+
"""
|
|
607
|
+
success, output = run_docker_command(
|
|
608
|
+
["logs", "--tail", str(lines), service_name],
|
|
609
|
+
compose_file=compose_file,
|
|
610
|
+
capture_output=True,
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
return output if success else f"Error getting logs: {output}"
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def stop_services(compose_file: Path | None = None) -> tuple[bool, str]:
|
|
617
|
+
"""
|
|
618
|
+
Stop all docker compose services.
|
|
619
|
+
|
|
620
|
+
Args:
|
|
621
|
+
compose_file: Path to docker-compose.yml
|
|
622
|
+
|
|
623
|
+
Returns:
|
|
624
|
+
Tuple of (success, message)
|
|
625
|
+
|
|
626
|
+
Example:
|
|
627
|
+
>>> success, msg = stop_services()
|
|
628
|
+
>>> if success:
|
|
629
|
+
... print("Services stopped successfully")
|
|
630
|
+
"""
|
|
631
|
+
return run_docker_command(["down"], compose_file=compose_file)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def restart_service(
|
|
635
|
+
service_name: str,
|
|
636
|
+
compose_file: Path | None = None,
|
|
637
|
+
) -> tuple[bool, str]:
|
|
638
|
+
"""
|
|
639
|
+
Restart a specific docker compose service.
|
|
640
|
+
|
|
641
|
+
Args:
|
|
642
|
+
service_name: Name of the service to restart
|
|
643
|
+
compose_file: Path to docker-compose.yml
|
|
644
|
+
|
|
645
|
+
Returns:
|
|
646
|
+
Tuple of (success, message)
|
|
647
|
+
|
|
648
|
+
Example:
|
|
649
|
+
>>> success, msg = restart_service("dashboard-backend")
|
|
650
|
+
"""
|
|
651
|
+
return run_docker_command(["restart", service_name], compose_file=compose_file)
|