funmill-api 0.1.2__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.
- funmill/api/__init__.py +44 -0
- funmill/api/app.py +103 -0
- funmill/api/backends/__init__.py +42 -0
- funmill/api/backends/base.py +51 -0
- funmill/api/backends/dagu/README.md +83 -0
- funmill/api/backends/dagu/__init__.py +458 -0
- funmill/api/backends/dagu/service.py +149 -0
- funmill/api/backends/service.py +107 -0
- funmill/api/backends/windmill/README.md +112 -0
- funmill/api/backends/windmill/__init__.py +414 -0
- funmill/api/backends/windmill/service.py +151 -0
- funmill/api/cli.py +83 -0
- funmill/api/models.py +120 -0
- funmill/api/ports.py +3 -0
- funmill_api-0.1.2.dist-info/METADATA +211 -0
- funmill_api-0.1.2.dist-info/RECORD +18 -0
- funmill_api-0.1.2.dist-info/WHEEL +4 -0
- funmill_api-0.1.2.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import shlex
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from funmill.api.models import (
|
|
12
|
+
TaskDefinition,
|
|
13
|
+
TaskInfo,
|
|
14
|
+
TaskLanguage,
|
|
15
|
+
TaskLogs,
|
|
16
|
+
TaskProgress,
|
|
17
|
+
TaskResult,
|
|
18
|
+
TaskStatus,
|
|
19
|
+
TaskSubmit,
|
|
20
|
+
WorkflowSubmit,
|
|
21
|
+
)
|
|
22
|
+
from funmill.api.ports import THIRD_PARTY_WEB_PORT
|
|
23
|
+
|
|
24
|
+
from ..base import BackendError, TaskBackend
|
|
25
|
+
|
|
26
|
+
_DAG_NAME = "funmill"
|
|
27
|
+
_TASK_ID = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
28
|
+
_TERMINAL_NODE_STATES = {"succeeded", "failed", "aborted", "skipped", "rejected"}
|
|
29
|
+
|
|
30
|
+
_DEPENDENCY_SOURCE = """#!/usr/bin/env python3
|
|
31
|
+
import base64
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import time
|
|
35
|
+
from urllib.parse import quote
|
|
36
|
+
from urllib.request import Request, urlopen
|
|
37
|
+
|
|
38
|
+
base_url = base64.b64decode("__BASE_URL__").decode()
|
|
39
|
+
dependencies = json.loads(base64.b64decode("__DEPENDENCIES__"))
|
|
40
|
+
deadline = time.monotonic() + __TIMEOUT_SECONDS__
|
|
41
|
+
token = os.getenv("FUNMILL_DAGU_TOKEN", "")
|
|
42
|
+
headers = {"Authorization": f"Bearer {token}"} if token else {}
|
|
43
|
+
|
|
44
|
+
while True:
|
|
45
|
+
waiting = False
|
|
46
|
+
for task_id in dependencies:
|
|
47
|
+
request = Request(
|
|
48
|
+
f"{base_url}/dag-runs/funmill/{quote(task_id, safe='')}",
|
|
49
|
+
headers=headers,
|
|
50
|
+
)
|
|
51
|
+
with urlopen(request, timeout=30) as response:
|
|
52
|
+
status = json.load(response)["dagRunDetails"]["statusLabel"]
|
|
53
|
+
if status in {"failed", "partially_succeeded", "rejected"}:
|
|
54
|
+
raise RuntimeError(f"dependency {task_id} failed")
|
|
55
|
+
if status == "aborted":
|
|
56
|
+
raise RuntimeError(f"dependency {task_id} was canceled")
|
|
57
|
+
waiting |= status != "succeeded"
|
|
58
|
+
if not waiting:
|
|
59
|
+
break
|
|
60
|
+
if time.monotonic() >= deadline:
|
|
61
|
+
raise TimeoutError("dependency wait timed out")
|
|
62
|
+
time.sleep(2)
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
_CALLBACK_SOURCE = """#!/usr/bin/env python3
|
|
66
|
+
import base64
|
|
67
|
+
import json
|
|
68
|
+
import os
|
|
69
|
+
from urllib.request import Request, urlopen
|
|
70
|
+
|
|
71
|
+
def decode(value):
|
|
72
|
+
if isinstance(value, str):
|
|
73
|
+
if value.startswith("json:"):
|
|
74
|
+
return json.loads(value[5:])
|
|
75
|
+
if value.startswith("text:"):
|
|
76
|
+
return value[5:]
|
|
77
|
+
try:
|
|
78
|
+
return decode(json.loads(value))
|
|
79
|
+
except (json.JSONDecodeError, TypeError):
|
|
80
|
+
return value
|
|
81
|
+
if isinstance(value, dict):
|
|
82
|
+
return {key: decode(item) for key, item in value.items()}
|
|
83
|
+
return value
|
|
84
|
+
|
|
85
|
+
body = {
|
|
86
|
+
"task_id": os.environ["FUNMILL_CALLBACK_TASK_ID"],
|
|
87
|
+
"status": os.environ["FUNMILL_CALLBACK_STATUS"],
|
|
88
|
+
"payload": decode(os.environ["FUNMILL_CALLBACK_PAYLOAD"]),
|
|
89
|
+
}
|
|
90
|
+
request = Request(
|
|
91
|
+
base64.b64decode("__CALLBACK_URL__").decode(),
|
|
92
|
+
data=json.dumps(body, default=str).encode(),
|
|
93
|
+
headers={"Content-Type": "application/json"},
|
|
94
|
+
method="POST",
|
|
95
|
+
)
|
|
96
|
+
with urlopen(request, timeout=10):
|
|
97
|
+
pass
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _encoded(value: str) -> str:
|
|
102
|
+
return base64.b64encode(value.encode()).decode()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _decode_result(value: Any) -> Any:
|
|
106
|
+
if isinstance(value, str):
|
|
107
|
+
if value.startswith("json:"):
|
|
108
|
+
return json.loads(value[5:])
|
|
109
|
+
if value.startswith("text:"):
|
|
110
|
+
return value[5:]
|
|
111
|
+
try:
|
|
112
|
+
return _decode_result(json.loads(value))
|
|
113
|
+
except (json.JSONDecodeError, TypeError):
|
|
114
|
+
return value
|
|
115
|
+
if isinstance(value, dict):
|
|
116
|
+
return {key: _decode_result(item) for key, item in value.items()}
|
|
117
|
+
return value
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _python_script(task: TaskDefinition) -> str:
|
|
121
|
+
source = _encoded(task.source)
|
|
122
|
+
args = _encoded(json.dumps(task.args, separators=(",", ":")))
|
|
123
|
+
return f"""#!/usr/bin/env python3
|
|
124
|
+
import base64
|
|
125
|
+
import json
|
|
126
|
+
import os
|
|
127
|
+
|
|
128
|
+
namespace = {{"__name__": "__funmill__"}}
|
|
129
|
+
source = base64.b64decode({source!r}).decode()
|
|
130
|
+
args = json.loads(base64.b64decode({args!r}))
|
|
131
|
+
exec(compile(source, "<funmill>", "exec"), namespace)
|
|
132
|
+
result = namespace["main"](**args)
|
|
133
|
+
with open(os.environ["DAGU_OUTPUT_FILE"], "a", encoding="utf-8") as output:
|
|
134
|
+
output.write("result=json:" + json.dumps(result, default=str) + "\\n")
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _bash_script(task: TaskDefinition) -> str:
|
|
139
|
+
args = " ".join(
|
|
140
|
+
shlex.quote(value if isinstance(value, str) else json.dumps(value))
|
|
141
|
+
for value in task.args.values()
|
|
142
|
+
)
|
|
143
|
+
return (
|
|
144
|
+
"#!/usr/bin/env bash\n"
|
|
145
|
+
"set -euo pipefail\n"
|
|
146
|
+
f"__funmill_source={shlex.quote(_encoded(task.source))}\n"
|
|
147
|
+
'__funmill_script="$(mktemp "${TMPDIR:-/tmp}/funmill.XXXXXX")"\n'
|
|
148
|
+
'__funmill_result="$(mktemp "${TMPDIR:-/tmp}/funmill.XXXXXX")"\n'
|
|
149
|
+
'trap \'rm -f "$__funmill_script" "$__funmill_result"\' EXIT\n'
|
|
150
|
+
"if ! printf '%s' \"$__funmill_source\" | base64 --decode "
|
|
151
|
+
'>"$__funmill_script" 2>/dev/null; then\n'
|
|
152
|
+
' printf \'%s\' "$__funmill_source" | base64 -D >"$__funmill_script"\n'
|
|
153
|
+
"fi\n"
|
|
154
|
+
'source "$__funmill_script"\n'
|
|
155
|
+
"set -euo pipefail\n"
|
|
156
|
+
f'main {args} | tee "$__funmill_result"\n'
|
|
157
|
+
'__funmill_delimiter="FUNMILL_RESULT_$$"\n'
|
|
158
|
+
"{\n"
|
|
159
|
+
" printf 'result<<%s\\n' \"$__funmill_delimiter\"\n"
|
|
160
|
+
" printf 'text:'\n"
|
|
161
|
+
' cat "$__funmill_result"\n'
|
|
162
|
+
" printf '\\n%s\\n' \"$__funmill_delimiter\"\n"
|
|
163
|
+
'} >>"$DAGU_OUTPUT_FILE"\n'
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class DaguBackend(TaskBackend):
|
|
168
|
+
name = "dagu"
|
|
169
|
+
|
|
170
|
+
def __init__(
|
|
171
|
+
self,
|
|
172
|
+
base_url: str,
|
|
173
|
+
token: str = "",
|
|
174
|
+
timeout: float = 30,
|
|
175
|
+
client: httpx.Client | None = None,
|
|
176
|
+
) -> None:
|
|
177
|
+
self.base_url = base_url.rstrip("/")
|
|
178
|
+
self.token = token
|
|
179
|
+
self.client = client or httpx.Client(
|
|
180
|
+
base_url=f"{self.base_url}/api/v1/", timeout=timeout
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
@classmethod
|
|
184
|
+
def from_env(cls) -> "DaguBackend":
|
|
185
|
+
return cls(
|
|
186
|
+
base_url=os.getenv("DAGU_URL", f"http://127.0.0.1:{THIRD_PARTY_WEB_PORT}"),
|
|
187
|
+
token=os.getenv("DAGU_TOKEN", ""),
|
|
188
|
+
timeout=float(os.getenv("DAGU_TIMEOUT", "30")),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
192
|
+
headers = kwargs.pop("headers", {})
|
|
193
|
+
if self.token:
|
|
194
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
195
|
+
try:
|
|
196
|
+
response = self.client.request(method, path, headers=headers, **kwargs)
|
|
197
|
+
except httpx.TimeoutException as exc:
|
|
198
|
+
raise BackendError("Dagu request timed out", 504) from exc
|
|
199
|
+
except httpx.HTTPError as exc:
|
|
200
|
+
raise BackendError(f"Dagu is unavailable: {exc}", 502) from exc
|
|
201
|
+
if response.is_error:
|
|
202
|
+
status_code = 404 if response.status_code == 404 else 502
|
|
203
|
+
detail = response.text.strip()[:500] or f"HTTP {response.status_code}"
|
|
204
|
+
raise BackendError(f"Dagu rejected the request: {detail}", status_code)
|
|
205
|
+
return response
|
|
206
|
+
|
|
207
|
+
def health_check(self) -> None:
|
|
208
|
+
response = self._request("GET", "health")
|
|
209
|
+
status = response.json().get("status")
|
|
210
|
+
if status not in {"healthy", "ok"}:
|
|
211
|
+
raise BackendError(f"Dagu reports status {status!r}", 502)
|
|
212
|
+
|
|
213
|
+
@staticmethod
|
|
214
|
+
def _task_id(value: Any) -> str:
|
|
215
|
+
if not isinstance(value, str):
|
|
216
|
+
raise BackendError(f"Dagu returned an invalid task ID: {value!r}")
|
|
217
|
+
task_id = value
|
|
218
|
+
if task_id == "latest" or not _TASK_ID.fullmatch(task_id):
|
|
219
|
+
raise BackendError(f"Dagu returned an invalid task ID: {task_id!r}")
|
|
220
|
+
return task_id
|
|
221
|
+
|
|
222
|
+
def _task_step(
|
|
223
|
+
self, step_id: str, task: TaskDefinition, dependencies: list[str]
|
|
224
|
+
) -> dict[str, Any]:
|
|
225
|
+
step: dict[str, Any] = {
|
|
226
|
+
"id": step_id,
|
|
227
|
+
"run": (
|
|
228
|
+
_python_script(task)
|
|
229
|
+
if task.language == TaskLanguage.PYTHON
|
|
230
|
+
else _bash_script(task)
|
|
231
|
+
),
|
|
232
|
+
"outputs": [{"name": "result"}],
|
|
233
|
+
}
|
|
234
|
+
if dependencies:
|
|
235
|
+
step["depends"] = dependencies
|
|
236
|
+
if task.retry.attempts:
|
|
237
|
+
step["retry_policy"] = {
|
|
238
|
+
"limit": task.retry.attempts,
|
|
239
|
+
"interval_sec": task.retry.delay_seconds,
|
|
240
|
+
}
|
|
241
|
+
if task.timeout_seconds:
|
|
242
|
+
step["timeout_sec"] = task.timeout_seconds
|
|
243
|
+
return step
|
|
244
|
+
|
|
245
|
+
def _dependency_step(
|
|
246
|
+
self, dependencies: list[str], timeout_seconds: int
|
|
247
|
+
) -> dict[str, Any]:
|
|
248
|
+
dependencies = [self._task_id(task_id) for task_id in dependencies]
|
|
249
|
+
source = (
|
|
250
|
+
_DEPENDENCY_SOURCE.replace(
|
|
251
|
+
"__BASE_URL__", _encoded(f"{self.base_url}/api/v1")
|
|
252
|
+
)
|
|
253
|
+
.replace("__DEPENDENCIES__", _encoded(json.dumps(dependencies)))
|
|
254
|
+
.replace("__TIMEOUT_SECONDS__", str(timeout_seconds))
|
|
255
|
+
)
|
|
256
|
+
return {"id": "funmill_wait", "run": source}
|
|
257
|
+
|
|
258
|
+
@staticmethod
|
|
259
|
+
def _result_step(task_ids: list[str]) -> dict[str, Any]:
|
|
260
|
+
result: str | dict[str, str]
|
|
261
|
+
if task_ids == ["task"]:
|
|
262
|
+
result = "${steps.task.outputs.result}"
|
|
263
|
+
else:
|
|
264
|
+
result = {
|
|
265
|
+
task_id: f"${{steps.{task_id}.outputs.result}}" for task_id in task_ids
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
"id": "funmill_result",
|
|
269
|
+
"depends": task_ids,
|
|
270
|
+
"action": "outputs.write",
|
|
271
|
+
"with": {"values": {"result": result}},
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
@staticmethod
|
|
275
|
+
def _callback(status: str, callback_url: str) -> dict[str, Any]:
|
|
276
|
+
payload = (
|
|
277
|
+
"${steps.funmill_result.outputs.result}"
|
|
278
|
+
if status == "succeeded"
|
|
279
|
+
else f"text:Dagu run {status}"
|
|
280
|
+
)
|
|
281
|
+
return {
|
|
282
|
+
"run": _CALLBACK_SOURCE.replace("__CALLBACK_URL__", _encoded(callback_url)),
|
|
283
|
+
"env": {
|
|
284
|
+
"FUNMILL_CALLBACK_TASK_ID": "${context.run.id}",
|
|
285
|
+
"FUNMILL_CALLBACK_STATUS": status,
|
|
286
|
+
"FUNMILL_CALLBACK_PAYLOAD": payload,
|
|
287
|
+
},
|
|
288
|
+
"retry_policy": {"limit": 3, "interval_sec": 2},
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
def _submit(
|
|
292
|
+
self,
|
|
293
|
+
steps: list[dict[str, Any]],
|
|
294
|
+
callback_url: str | None,
|
|
295
|
+
) -> str:
|
|
296
|
+
spec: dict[str, Any] = {"name": _DAG_NAME, "steps": steps}
|
|
297
|
+
if callback_url:
|
|
298
|
+
success_callback = self._callback("succeeded", callback_url)
|
|
299
|
+
success_callback.update(
|
|
300
|
+
{"id": "funmill_callback", "depends": ["funmill_result"]}
|
|
301
|
+
)
|
|
302
|
+
steps.append(success_callback)
|
|
303
|
+
spec["handler_on"] = {
|
|
304
|
+
"failure": self._callback("failed", callback_url),
|
|
305
|
+
"abort": self._callback("canceled", callback_url),
|
|
306
|
+
}
|
|
307
|
+
response = self._request(
|
|
308
|
+
"POST", "dag-runs", json={"spec": json.dumps(spec, separators=(",", ":"))}
|
|
309
|
+
)
|
|
310
|
+
return self._task_id(response.json().get("dagRunId"))
|
|
311
|
+
|
|
312
|
+
def submit_task(self, task: TaskSubmit) -> str:
|
|
313
|
+
steps = []
|
|
314
|
+
dependencies = []
|
|
315
|
+
if task.depends_on:
|
|
316
|
+
steps.append(
|
|
317
|
+
self._dependency_step(task.depends_on, task.dependency_timeout_seconds)
|
|
318
|
+
)
|
|
319
|
+
dependencies.append("funmill_wait")
|
|
320
|
+
steps.append(self._task_step("task", task, dependencies))
|
|
321
|
+
steps.append(self._result_step(["task"]))
|
|
322
|
+
return self._submit(
|
|
323
|
+
steps, str(task.callback_url) if task.callback_url else None
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
def submit_workflow(self, workflow: WorkflowSubmit) -> str:
|
|
327
|
+
steps = []
|
|
328
|
+
if workflow.depends_on:
|
|
329
|
+
steps.append(
|
|
330
|
+
self._dependency_step(
|
|
331
|
+
workflow.depends_on, workflow.dependency_timeout_seconds
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
for task in workflow.tasks:
|
|
335
|
+
dependencies = list(task.depends_on)
|
|
336
|
+
if workflow.depends_on and not dependencies:
|
|
337
|
+
dependencies.append("funmill_wait")
|
|
338
|
+
steps.append(self._task_step(task.key, task, dependencies))
|
|
339
|
+
task_ids = [task.key for task in workflow.tasks]
|
|
340
|
+
steps.append(self._result_step(task_ids))
|
|
341
|
+
return self._submit(
|
|
342
|
+
steps, str(workflow.callback_url) if workflow.callback_url else None
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
def _details(self, task_id: str) -> dict[str, Any]:
|
|
346
|
+
task_id = self._task_id(task_id)
|
|
347
|
+
response = self._request("GET", f"dag-runs/{_DAG_NAME}/{task_id}").json()
|
|
348
|
+
try:
|
|
349
|
+
return response["dagRunDetails"]
|
|
350
|
+
except (KeyError, TypeError) as exc:
|
|
351
|
+
raise BackendError("Dagu returned invalid task details") from exc
|
|
352
|
+
|
|
353
|
+
@staticmethod
|
|
354
|
+
def _status(details: dict[str, Any]) -> TaskStatus:
|
|
355
|
+
states = {
|
|
356
|
+
"not_started": TaskStatus.QUEUED,
|
|
357
|
+
"queued": TaskStatus.QUEUED,
|
|
358
|
+
"running": TaskStatus.RUNNING,
|
|
359
|
+
"waiting": TaskStatus.RUNNING,
|
|
360
|
+
"succeeded": TaskStatus.SUCCEEDED,
|
|
361
|
+
"failed": TaskStatus.FAILED,
|
|
362
|
+
"partially_succeeded": TaskStatus.FAILED,
|
|
363
|
+
"rejected": TaskStatus.FAILED,
|
|
364
|
+
"aborted": TaskStatus.CANCELED,
|
|
365
|
+
}
|
|
366
|
+
try:
|
|
367
|
+
return states[details["statusLabel"]]
|
|
368
|
+
except KeyError as exc:
|
|
369
|
+
raise BackendError("Dagu returned an unknown task status") from exc
|
|
370
|
+
|
|
371
|
+
def get_task(self, task_id: str) -> TaskInfo:
|
|
372
|
+
details = self._details(task_id)
|
|
373
|
+
return TaskInfo(
|
|
374
|
+
task_id=task_id,
|
|
375
|
+
status=self._status(details),
|
|
376
|
+
created_at=details.get("queuedAt") or details.get("startedAt") or None,
|
|
377
|
+
started_at=details.get("startedAt") or None,
|
|
378
|
+
completed_at=details.get("finishedAt") or None,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
def get_progress(self, task_id: str) -> TaskProgress:
|
|
382
|
+
details = self._details(task_id)
|
|
383
|
+
if self._status(details) == TaskStatus.SUCCEEDED:
|
|
384
|
+
return TaskProgress(task_id=task_id, progress=100)
|
|
385
|
+
nodes = [
|
|
386
|
+
node
|
|
387
|
+
for node in details.get("nodes", [])
|
|
388
|
+
if not (node.get("step", {}).get("id") or "").startswith("funmill_")
|
|
389
|
+
]
|
|
390
|
+
completed = sum(
|
|
391
|
+
node.get("statusLabel") in _TERMINAL_NODE_STATES for node in nodes
|
|
392
|
+
)
|
|
393
|
+
progress = int(completed * 100 / len(nodes)) if nodes else None
|
|
394
|
+
return TaskProgress(task_id=task_id, progress=progress)
|
|
395
|
+
|
|
396
|
+
def get_logs(self, task_id: str) -> TaskLogs:
|
|
397
|
+
task_id = self._task_id(task_id)
|
|
398
|
+
details = self._details(task_id)
|
|
399
|
+
parts = []
|
|
400
|
+
for node in details.get("nodes", []):
|
|
401
|
+
step = node.get("step", {}).get("name")
|
|
402
|
+
if not step or node.get("statusLabel") == "not_started":
|
|
403
|
+
continue
|
|
404
|
+
# ponytail: Dagu lacks combined logs; use one request per stream.
|
|
405
|
+
for stream in ("stdout", "stderr"):
|
|
406
|
+
try:
|
|
407
|
+
content = (
|
|
408
|
+
self._request(
|
|
409
|
+
"GET",
|
|
410
|
+
f"dag-runs/{_DAG_NAME}/{task_id}/steps/{step}/log",
|
|
411
|
+
params={"stream": stream},
|
|
412
|
+
)
|
|
413
|
+
.json()
|
|
414
|
+
.get("content", "")
|
|
415
|
+
)
|
|
416
|
+
except BackendError as exc:
|
|
417
|
+
if exc.status_code == 404:
|
|
418
|
+
continue
|
|
419
|
+
raise
|
|
420
|
+
if content:
|
|
421
|
+
parts.append(f"[{step} {stream}]\n{content}")
|
|
422
|
+
return TaskLogs(task_id=task_id, logs="\n".join(parts))
|
|
423
|
+
|
|
424
|
+
def get_result(self, task_id: str) -> TaskResult:
|
|
425
|
+
task_id = self._task_id(task_id)
|
|
426
|
+
terminal = self._status(self._details(task_id)) not in {
|
|
427
|
+
TaskStatus.QUEUED,
|
|
428
|
+
TaskStatus.RUNNING,
|
|
429
|
+
}
|
|
430
|
+
for attempt in range(10):
|
|
431
|
+
payload = self._request(
|
|
432
|
+
"GET", f"dag-runs/{_DAG_NAME}/{task_id}/outputs"
|
|
433
|
+
).json()
|
|
434
|
+
if (
|
|
435
|
+
not terminal
|
|
436
|
+
or payload.get("metadata", {}).get("status")
|
|
437
|
+
or attempt == 9
|
|
438
|
+
):
|
|
439
|
+
break
|
|
440
|
+
# Dagu marks a run complete just before publishing outputs.json.
|
|
441
|
+
time.sleep(0.05)
|
|
442
|
+
outputs = payload.get("outputs", {})
|
|
443
|
+
return TaskResult(task_id=task_id, result=_decode_result(outputs.get("result")))
|
|
444
|
+
|
|
445
|
+
def cancel(self, task_id: str, reason: str) -> None:
|
|
446
|
+
del reason
|
|
447
|
+
task_id = self._task_id(task_id)
|
|
448
|
+
self._request("POST", f"dag-runs/{_DAG_NAME}/{task_id}/stop")
|
|
449
|
+
|
|
450
|
+
def rerun(self, task_id: str) -> str:
|
|
451
|
+
task_id = self._task_id(task_id)
|
|
452
|
+
response = self._request(
|
|
453
|
+
"POST", f"dag-runs/{_DAG_NAME}/{task_id}/reschedule", json={}
|
|
454
|
+
)
|
|
455
|
+
return self._task_id(response.json().get("dagRunId"))
|
|
456
|
+
|
|
457
|
+
def close(self) -> None:
|
|
458
|
+
self.client.close()
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import os
|
|
3
|
+
import platform
|
|
4
|
+
import shutil
|
|
5
|
+
import tarfile
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from urllib.request import Request, urlopen
|
|
9
|
+
|
|
10
|
+
from funmill.api.ports import SERVICE_BIND_HOST, THIRD_PARTY_WEB_PORT
|
|
11
|
+
|
|
12
|
+
from ..service import start_background, status_background, stop_background
|
|
13
|
+
|
|
14
|
+
VERSION = "v2.16.3"
|
|
15
|
+
_RELEASE = f"https://github.com/dagucloud/dagu/releases/download/{VERSION}"
|
|
16
|
+
_BUILDS = {
|
|
17
|
+
("darwin", "x86_64"): (
|
|
18
|
+
"darwin_amd64",
|
|
19
|
+
"09bd9122f9db02bc4247cb23d0a8a8a033482c381acfc701dc6cb248849e4d36",
|
|
20
|
+
"b99d0bb23e33c6f4a5ad22094d40acdbbc209ec3d801c99bf82fe072fd336306",
|
|
21
|
+
),
|
|
22
|
+
("darwin", "arm64"): (
|
|
23
|
+
"darwin_arm64",
|
|
24
|
+
"191ed4edc217680eae24ce348c29d6d4e2f10a6940f0281a7d1f9ca15c451555",
|
|
25
|
+
"ad9f1bdff3c1813f7caf39bc8cef72fa80435111f3980ecefc78de0a8d6b4918",
|
|
26
|
+
),
|
|
27
|
+
("linux", "x86_64"): (
|
|
28
|
+
"linux_amd64",
|
|
29
|
+
"2237cdd6287db00857af3471bcad7cea0bf786055bbd24f1bc53a02ceb9d4b06",
|
|
30
|
+
"fea2f1330644da0e54be983d34c1819f6158001eac23485ea9a476f26a8aba44",
|
|
31
|
+
),
|
|
32
|
+
("linux", "arm64"): (
|
|
33
|
+
"linux_arm64",
|
|
34
|
+
"e1f5fd611b003ee73e4846bad24455e02e052ff60314723e44aa067522c71762",
|
|
35
|
+
"6c0068970f6fcf678601566fb1e165aa4fb8f785d27b86677d227aaa32b1d0e3",
|
|
36
|
+
),
|
|
37
|
+
}
|
|
38
|
+
_MACHINES = {"amd64": "x86_64", "aarch64": "arm64"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _home() -> Path:
|
|
42
|
+
return Path(os.getenv("FUNMILL_HOME", Path.home() / ".farfarfun" / "funmill"))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _target() -> Path:
|
|
46
|
+
return _home() / "services" / "dagu" / "dagu"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _sha256(path: Path) -> str:
|
|
50
|
+
with path.open("rb") as file:
|
|
51
|
+
return hashlib.file_digest(file, "sha256").hexdigest()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _build() -> tuple[str, str, str]:
|
|
55
|
+
key = (
|
|
56
|
+
platform.system().lower(),
|
|
57
|
+
_MACHINES.get(platform.machine().lower(), platform.machine().lower()),
|
|
58
|
+
)
|
|
59
|
+
try:
|
|
60
|
+
return _BUILDS[key]
|
|
61
|
+
except KeyError as exc:
|
|
62
|
+
raise RuntimeError(
|
|
63
|
+
"Dagu installer only supports macOS/Linux amd64/arm64"
|
|
64
|
+
) from exc
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def install(force: bool = False) -> Path:
|
|
68
|
+
suffix, archive_sha256, binary_sha256 = _build()
|
|
69
|
+
target = _target()
|
|
70
|
+
if target.exists() and _sha256(target) == binary_sha256:
|
|
71
|
+
return target
|
|
72
|
+
if target.exists() and not force:
|
|
73
|
+
raise RuntimeError(
|
|
74
|
+
f"{target} exists but has an unexpected checksum; use --force"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
archive_name = f"dagu_{VERSION.removeprefix('v')}_{suffix}.tar.gz"
|
|
79
|
+
with tempfile.TemporaryDirectory(dir=target.parent) as temporary_dir:
|
|
80
|
+
archive_path = Path(temporary_dir) / archive_name
|
|
81
|
+
request = Request(
|
|
82
|
+
f"{_RELEASE}/{archive_name}", headers={"User-Agent": "funmill"}
|
|
83
|
+
)
|
|
84
|
+
print(f"downloading Dagu {VERSION}...", flush=True)
|
|
85
|
+
with urlopen(request, timeout=30) as response, archive_path.open("wb") as file:
|
|
86
|
+
shutil.copyfileobj(response, file)
|
|
87
|
+
if _sha256(archive_path) != archive_sha256:
|
|
88
|
+
raise RuntimeError("downloaded Dagu archive failed SHA-256 verification")
|
|
89
|
+
|
|
90
|
+
executable = Path(temporary_dir) / "dagu"
|
|
91
|
+
with tarfile.open(archive_path, "r:gz") as archive:
|
|
92
|
+
member = archive.getmember("dagu")
|
|
93
|
+
if not member.isfile():
|
|
94
|
+
raise RuntimeError("downloaded Dagu archive does not contain a binary")
|
|
95
|
+
source = archive.extractfile(member)
|
|
96
|
+
if source is None:
|
|
97
|
+
raise RuntimeError("downloaded Dagu archive does not contain a binary")
|
|
98
|
+
with source, executable.open("wb") as file:
|
|
99
|
+
shutil.copyfileobj(source, file)
|
|
100
|
+
if _sha256(executable) != binary_sha256:
|
|
101
|
+
raise RuntimeError("downloaded Dagu binary failed SHA-256 verification")
|
|
102
|
+
executable.chmod(0o755)
|
|
103
|
+
executable.replace(target)
|
|
104
|
+
return target
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def start() -> None:
|
|
108
|
+
executable = _target()
|
|
109
|
+
if not executable.exists():
|
|
110
|
+
system_executable = shutil.which("dagu")
|
|
111
|
+
if system_executable is None:
|
|
112
|
+
raise RuntimeError("Dagu is not installed; run: funmill install dagu")
|
|
113
|
+
executable = Path(system_executable)
|
|
114
|
+
|
|
115
|
+
environment = os.environ.copy()
|
|
116
|
+
environment.setdefault("DAGU_AUTH_MODE", "none")
|
|
117
|
+
service_directory = _target().parent
|
|
118
|
+
environment["DAGU_HOME"] = str(service_directory / "data")
|
|
119
|
+
environment.setdefault("DAGU_COORDINATOR_ENABLED", "false")
|
|
120
|
+
environment.setdefault(
|
|
121
|
+
"FUNMILL_DAGU_URL", f"http://127.0.0.1:{THIRD_PARTY_WEB_PORT}/api/v1"
|
|
122
|
+
)
|
|
123
|
+
if environment.get("DAGU_TOKEN"):
|
|
124
|
+
environment.setdefault("FUNMILL_DAGU_TOKEN", environment["DAGU_TOKEN"])
|
|
125
|
+
start_background(
|
|
126
|
+
"dagu",
|
|
127
|
+
[
|
|
128
|
+
str(executable),
|
|
129
|
+
"start-all",
|
|
130
|
+
"--dagu-home",
|
|
131
|
+
str(service_directory / "data"),
|
|
132
|
+
"--host",
|
|
133
|
+
SERVICE_BIND_HOST,
|
|
134
|
+
"--port",
|
|
135
|
+
str(THIRD_PARTY_WEB_PORT),
|
|
136
|
+
"--coordinator.host",
|
|
137
|
+
SERVICE_BIND_HOST,
|
|
138
|
+
],
|
|
139
|
+
environment,
|
|
140
|
+
service_directory,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def stop() -> None:
|
|
145
|
+
stop_background("dagu", _target().parent)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def status() -> bool:
|
|
149
|
+
return status_background("dagu", _target().parent)
|