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,151 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import os
|
|
3
|
+
import platform
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
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 = "v1.808.0"
|
|
15
|
+
URL = f"https://github.com/windmill-labs/windmill/releases/download/{VERSION}/windmill-amd64"
|
|
16
|
+
SHA256 = "ed48bfb9a391daa437f0c867376f009c7186855530de7fe2f58cf557ff1f7c3a"
|
|
17
|
+
_ENV_TEMPLATE = f"""DATABASE_URL=
|
|
18
|
+
MODE=standalone
|
|
19
|
+
SERVER_BIND_ADDR={SERVICE_BIND_HOST}
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _home() -> Path:
|
|
24
|
+
return Path(os.getenv("FUNMILL_HOME", Path.home() / ".farfarfun" / "funmill"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _target() -> Path:
|
|
28
|
+
return _home() / "services" / "windmill" / "windmill"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _config_path() -> Path:
|
|
32
|
+
return _target().parent / ".env"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _ensure_config() -> Path:
|
|
36
|
+
path = _config_path()
|
|
37
|
+
if not path.exists():
|
|
38
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
path.write_text(_ENV_TEMPLATE, encoding="utf-8")
|
|
40
|
+
path.chmod(0o600)
|
|
41
|
+
print(f"created config: {path}")
|
|
42
|
+
return path
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _read_config(path: Path) -> dict[str, str]:
|
|
46
|
+
values = {}
|
|
47
|
+
for line_number, raw_line in enumerate(
|
|
48
|
+
path.read_text(encoding="utf-8").splitlines(), 1
|
|
49
|
+
):
|
|
50
|
+
line = raw_line.strip()
|
|
51
|
+
if not line or line.startswith("#"):
|
|
52
|
+
continue
|
|
53
|
+
key, separator, value = line.partition("=")
|
|
54
|
+
key = key.strip()
|
|
55
|
+
if not separator or not key.isidentifier():
|
|
56
|
+
raise RuntimeError(f"invalid config at {path}:{line_number}")
|
|
57
|
+
value = value.strip()
|
|
58
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
59
|
+
value = value[1:-1]
|
|
60
|
+
values[key] = value
|
|
61
|
+
return values
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _sha256(path: Path) -> str:
|
|
65
|
+
with path.open("rb") as file:
|
|
66
|
+
return hashlib.file_digest(file, "sha256").hexdigest()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _environment() -> dict[str, str]:
|
|
70
|
+
path = _config_path()
|
|
71
|
+
environment = _read_config(path) if path.exists() else {}
|
|
72
|
+
environment.update(os.environ)
|
|
73
|
+
return environment
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _instance_name(environment: dict[str, str]) -> str:
|
|
77
|
+
if environment.get("MODE", "standalone") != "worker":
|
|
78
|
+
return "windmill"
|
|
79
|
+
suffix = environment.get("WORKER_SUFFIX", "worker")
|
|
80
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]+", suffix):
|
|
81
|
+
raise RuntimeError("WORKER_SUFFIX must contain only letters, numbers, _ or -")
|
|
82
|
+
return f"windmill-{suffix}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def install(force: bool = False) -> Path:
|
|
86
|
+
if platform.system() != "Linux" or platform.machine().lower() not in {
|
|
87
|
+
"x86_64",
|
|
88
|
+
"amd64",
|
|
89
|
+
}:
|
|
90
|
+
raise RuntimeError("Windmill v1.808.0 installer only supports Linux x86_64")
|
|
91
|
+
|
|
92
|
+
target = _target()
|
|
93
|
+
_ensure_config()
|
|
94
|
+
if target.exists() and _sha256(target) == SHA256:
|
|
95
|
+
return target
|
|
96
|
+
if target.exists() and not force:
|
|
97
|
+
raise RuntimeError(
|
|
98
|
+
f"{target} exists but has an unexpected checksum; use --force"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
descriptor, temporary_name = tempfile.mkstemp(dir=target.parent)
|
|
103
|
+
os.close(descriptor)
|
|
104
|
+
temporary = Path(temporary_name)
|
|
105
|
+
try:
|
|
106
|
+
print(f"downloading Windmill {VERSION}...", flush=True)
|
|
107
|
+
request = Request(URL, headers={"User-Agent": "funmill"})
|
|
108
|
+
with urlopen(request, timeout=30) as response, temporary.open("wb") as file:
|
|
109
|
+
shutil.copyfileobj(response, file)
|
|
110
|
+
if _sha256(temporary) != SHA256:
|
|
111
|
+
raise RuntimeError("downloaded Windmill binary failed SHA-256 verification")
|
|
112
|
+
temporary.chmod(0o755)
|
|
113
|
+
temporary.replace(target)
|
|
114
|
+
finally:
|
|
115
|
+
temporary.unlink(missing_ok=True)
|
|
116
|
+
return target
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def start() -> None:
|
|
120
|
+
executable = _target()
|
|
121
|
+
if not executable.exists():
|
|
122
|
+
system_executable = shutil.which("windmill")
|
|
123
|
+
if system_executable is None:
|
|
124
|
+
raise RuntimeError(
|
|
125
|
+
"Windmill is not installed; run: funmill install windmill"
|
|
126
|
+
)
|
|
127
|
+
executable = Path(system_executable)
|
|
128
|
+
|
|
129
|
+
config = _ensure_config()
|
|
130
|
+
environment = _environment()
|
|
131
|
+
if not environment.get("DATABASE_URL"):
|
|
132
|
+
raise RuntimeError(f"DATABASE_URL is required; edit {config}")
|
|
133
|
+
|
|
134
|
+
mode = environment.setdefault("MODE", "standalone")
|
|
135
|
+
if mode in {"standalone", "server"}:
|
|
136
|
+
environment["PORT"] = str(THIRD_PARTY_WEB_PORT)
|
|
137
|
+
environment.setdefault("BASE_URL", f"http://127.0.0.1:{THIRD_PARTY_WEB_PORT}")
|
|
138
|
+
environment["SERVER_BIND_ADDR"] = SERVICE_BIND_HOST
|
|
139
|
+
start_background(
|
|
140
|
+
_instance_name(environment), [str(executable)], environment, _target().parent
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def stop() -> None:
|
|
145
|
+
environment = _environment()
|
|
146
|
+
stop_background(_instance_name(environment), _target().parent)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def status() -> bool:
|
|
150
|
+
environment = _environment()
|
|
151
|
+
return status_background(_instance_name(environment), _target().parent)
|
funmill/api/cli.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import importlib
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from types import ModuleType
|
|
5
|
+
|
|
6
|
+
import uvicorn
|
|
7
|
+
|
|
8
|
+
from funmill.api.backends import BACKEND_SPECS
|
|
9
|
+
from funmill.api.ports import FUNMILL_API_PORT, SERVICE_BIND_HOST
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _service_names() -> list[str]:
|
|
13
|
+
return [name for name, spec in BACKEND_SPECS.items() if spec.service]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _service(name: str) -> ModuleType:
|
|
17
|
+
spec = BACKEND_SPECS[name]
|
|
18
|
+
return importlib.import_module(spec.service, "funmill.api.backends")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parser() -> argparse.ArgumentParser:
|
|
22
|
+
parser = argparse.ArgumentParser(prog="funmill")
|
|
23
|
+
commands = parser.add_subparsers(dest="command")
|
|
24
|
+
|
|
25
|
+
commands.add_parser("services", help="列出可安装的第三方服务")
|
|
26
|
+
|
|
27
|
+
install = commands.add_parser("install", help="安装第三方服务")
|
|
28
|
+
install.add_argument("service", choices=_service_names())
|
|
29
|
+
install.add_argument("--force", action="store_true", help="覆盖现有安装")
|
|
30
|
+
|
|
31
|
+
start = commands.add_parser("start", help="启动 Funmill 或第三方服务")
|
|
32
|
+
start.add_argument(
|
|
33
|
+
"service", nargs="?", choices=["api", *_service_names()], default="api"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
for command in ("stop", "status", "restart"):
|
|
37
|
+
service_command = commands.add_parser(
|
|
38
|
+
command,
|
|
39
|
+
help={
|
|
40
|
+
"stop": "停止第三方服务",
|
|
41
|
+
"status": "查看第三方服务状态",
|
|
42
|
+
"restart": "重启第三方服务",
|
|
43
|
+
}[command],
|
|
44
|
+
)
|
|
45
|
+
service_command.add_argument("service", choices=_service_names())
|
|
46
|
+
return parser
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
50
|
+
parser = _parser()
|
|
51
|
+
args = parser.parse_args(argv)
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
if args.command == "services":
|
|
55
|
+
print("\n".join(_service_names()))
|
|
56
|
+
elif args.command == "install":
|
|
57
|
+
path = _service(args.service).install(force=args.force)
|
|
58
|
+
print(f"installed {args.service}: {path}")
|
|
59
|
+
elif args.command == "start" and args.service != "api":
|
|
60
|
+
_service(args.service).start()
|
|
61
|
+
elif args.command == "stop":
|
|
62
|
+
_service(args.service).stop()
|
|
63
|
+
elif args.command == "status":
|
|
64
|
+
if not _service(args.service).status():
|
|
65
|
+
raise SystemExit(1)
|
|
66
|
+
elif args.command == "restart":
|
|
67
|
+
service = _service(args.service)
|
|
68
|
+
service.stop()
|
|
69
|
+
service.start()
|
|
70
|
+
elif args.command == "start":
|
|
71
|
+
uvicorn.run(
|
|
72
|
+
"funmill.api:app",
|
|
73
|
+
host=SERVICE_BIND_HOST,
|
|
74
|
+
port=FUNMILL_API_PORT,
|
|
75
|
+
)
|
|
76
|
+
else:
|
|
77
|
+
parser.print_help()
|
|
78
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
79
|
+
parser.exit(1, f"error: {exc}\n")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
main()
|
funmill/api/models.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from enum import StrEnum
|
|
3
|
+
from typing import Annotated, Any, Self
|
|
4
|
+
|
|
5
|
+
from pydantic import AnyHttpUrl, BaseModel, Field, model_validator
|
|
6
|
+
|
|
7
|
+
DependencyId = Annotated[str, Field(min_length=1, max_length=200)]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TaskLanguage(StrEnum):
|
|
11
|
+
PYTHON = "python"
|
|
12
|
+
BASH = "bash"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TaskStatus(StrEnum):
|
|
16
|
+
QUEUED = "queued"
|
|
17
|
+
RUNNING = "running"
|
|
18
|
+
SUCCEEDED = "succeeded"
|
|
19
|
+
FAILED = "failed"
|
|
20
|
+
CANCELED = "canceled"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RetryPolicy(BaseModel):
|
|
24
|
+
attempts: int = Field(default=0, ge=0, le=10)
|
|
25
|
+
delay_seconds: int = Field(default=1, ge=0, le=3600)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TaskDefinition(BaseModel):
|
|
29
|
+
language: TaskLanguage
|
|
30
|
+
source: str = Field(min_length=1, max_length=1_000_000)
|
|
31
|
+
args: dict[str, Any] = Field(default_factory=dict)
|
|
32
|
+
retry: RetryPolicy = Field(default_factory=RetryPolicy)
|
|
33
|
+
timeout_seconds: int | None = Field(default=None, ge=1, le=86_400)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TaskSubmit(TaskDefinition):
|
|
37
|
+
depends_on: list[DependencyId] = Field(default_factory=list, max_length=100)
|
|
38
|
+
dependency_timeout_seconds: int = Field(default=86_400, ge=1, le=604_800)
|
|
39
|
+
callback_url: AnyHttpUrl | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class WorkflowTask(TaskDefinition):
|
|
43
|
+
key: str = Field(pattern=r"^[A-Za-z][A-Za-z0-9_-]{0,63}$")
|
|
44
|
+
depends_on: list[str] = Field(default_factory=list, max_length=100)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class WorkflowSubmit(BaseModel):
|
|
48
|
+
tasks: list[WorkflowTask] = Field(min_length=1, max_length=100)
|
|
49
|
+
depends_on: list[DependencyId] = Field(default_factory=list, max_length=100)
|
|
50
|
+
dependency_timeout_seconds: int = Field(default=86_400, ge=1, le=604_800)
|
|
51
|
+
callback_url: AnyHttpUrl | None = None
|
|
52
|
+
|
|
53
|
+
@model_validator(mode="after")
|
|
54
|
+
def validate_dependencies(self) -> Self:
|
|
55
|
+
keys = [task.key for task in self.tasks]
|
|
56
|
+
if len(keys) != len(set(keys)):
|
|
57
|
+
raise ValueError("workflow task keys must be unique")
|
|
58
|
+
if any(key == "failure" or key.startswith("funmill_") for key in keys):
|
|
59
|
+
raise ValueError("task keys cannot be 'failure' or start with 'funmill_'")
|
|
60
|
+
|
|
61
|
+
known = set(keys)
|
|
62
|
+
for task in self.tasks:
|
|
63
|
+
unknown = set(task.depends_on) - known
|
|
64
|
+
if unknown:
|
|
65
|
+
raise ValueError(
|
|
66
|
+
f"task {task.key!r} has unknown dependencies: {sorted(unknown)}"
|
|
67
|
+
)
|
|
68
|
+
self.topological_layers()
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def topological_layers(self) -> list[list[WorkflowTask]]:
|
|
72
|
+
by_key = {task.key: task for task in self.tasks}
|
|
73
|
+
remaining = {task.key: set(task.depends_on) for task in self.tasks}
|
|
74
|
+
layers: list[list[WorkflowTask]] = []
|
|
75
|
+
while remaining:
|
|
76
|
+
ready = [key for key, dependencies in remaining.items() if not dependencies]
|
|
77
|
+
if not ready:
|
|
78
|
+
raise ValueError("workflow contains a dependency cycle")
|
|
79
|
+
layers.append([by_key[key] for key in ready])
|
|
80
|
+
ready_set = set(ready)
|
|
81
|
+
remaining = {
|
|
82
|
+
key: dependencies - ready_set
|
|
83
|
+
for key, dependencies in remaining.items()
|
|
84
|
+
if key not in ready_set
|
|
85
|
+
}
|
|
86
|
+
return layers
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class TaskAccepted(BaseModel):
|
|
90
|
+
task_id: str
|
|
91
|
+
status: TaskStatus = TaskStatus.QUEUED
|
|
92
|
+
rerun_of: str | None = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class TaskInfo(BaseModel):
|
|
96
|
+
task_id: str
|
|
97
|
+
status: TaskStatus
|
|
98
|
+
created_at: datetime | None = None
|
|
99
|
+
started_at: datetime | None = None
|
|
100
|
+
completed_at: datetime | None = None
|
|
101
|
+
duration_ms: int | None = None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TaskProgress(BaseModel):
|
|
105
|
+
task_id: str
|
|
106
|
+
progress: int | None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class TaskLogs(BaseModel):
|
|
110
|
+
task_id: str
|
|
111
|
+
logs: str
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class TaskResult(BaseModel):
|
|
115
|
+
task_id: str
|
|
116
|
+
result: Any
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class CancelRequest(BaseModel):
|
|
120
|
+
reason: str = Field(default="canceled through Funmill", max_length=500)
|
funmill/api/ports.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: funmill-api
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Backend-neutral task execution API
|
|
5
|
+
Project-URL: Organization, https://github.com/farfarfun
|
|
6
|
+
Project-URL: Repository, https://github.com/farfarfun/funmill-api
|
|
7
|
+
Project-URL: Releases, https://github.com/farfarfun/funmill-api/releases
|
|
8
|
+
Author-email: 牛哥 <niuliangtao@qq.com>, farfarfun <farfarfun@qq.com>
|
|
9
|
+
Maintainer-email: 牛哥 <niuliangtao@qq.com>, farfarfun <farfarfun@qq.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
Requires-Python: >=3.12
|
|
12
|
+
Requires-Dist: fastapi<1,>=0.115
|
|
13
|
+
Requires-Dist: httpx<1,>=0.27
|
|
14
|
+
Requires-Dist: uvicorn<1,>=0.30
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# Funmill
|
|
18
|
+
|
|
19
|
+
Funmill provides one stable task API while execution is delegated to a
|
|
20
|
+
replaceable backend. The recommended local backend is self-hosted
|
|
21
|
+
[Dagu](https://github.com/dagucloud/dagu), pinned to `v2.16.3`. Windmill remains
|
|
22
|
+
available for existing deployments.
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
client -> Funmill /v1 -> TaskBackend -> Dagu
|
|
26
|
+
-> Windmill
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Funmill owns the public request and response models. Backend job IDs remain
|
|
30
|
+
opaque strings, and no backend-specific routes or payloads are exposed to clients.
|
|
31
|
+
|
|
32
|
+
## Start
|
|
33
|
+
|
|
34
|
+
Install the project and the Dagu binary. The installer supports macOS and Linux
|
|
35
|
+
on Intel/AMD and ARM64:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
uv sync
|
|
39
|
+
uv run funmill install dagu
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Start Dagu in the background. It stores state under
|
|
43
|
+
`~/.farfarfun/funmill/services/dagu/data/` and needs no external database:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uv run funmill start dagu
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The command reports its PID and log path. Open <http://localhost:8813>, then
|
|
50
|
+
start Funmill:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
FUNMILL_API_KEY='replace-me' \
|
|
54
|
+
FUNMILL_BACKEND=dagu \
|
|
55
|
+
DAGU_URL='http://127.0.0.1:8813' \
|
|
56
|
+
uv run funmill start
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Managed HTTP services bind to `0.0.0.0`: Funmill uses port `8812` and the active
|
|
60
|
+
third-party service uses `8813`. Local client URLs still use `127.0.0.1` or
|
|
61
|
+
`localhost`; `0.0.0.0` is a listen address, not a client destination.
|
|
62
|
+
|
|
63
|
+
Dagu starts without authentication. Because it listens on every interface,
|
|
64
|
+
restrict port `8813` with a firewall or enable Dagu authentication. When
|
|
65
|
+
authentication is enabled, set `DAGU_TOKEN` for both `funmill start dagu` and
|
|
66
|
+
`funmill start` so cross-run dependencies can query Dagu from worker processes.
|
|
67
|
+
|
|
68
|
+
Dagu runs submitted source with the service user's host permissions. Keep both
|
|
69
|
+
services private and accept only trusted code; use isolated workers or
|
|
70
|
+
containers before accepting untrusted jobs.
|
|
71
|
+
|
|
72
|
+
The Funmill API port is fixed at `8812`; the active third-party UI/API port is
|
|
73
|
+
fixed at `8813`. OpenAPI docs are at <http://localhost:8812/docs>. All `/v1`
|
|
74
|
+
routes require `X-API-Key`. See the
|
|
75
|
+
[Dagu deployment guide](src/funmill/api/backends/dagu/README.md) or the
|
|
76
|
+
[Windmill deployment guide](src/funmill/api/backends/windmill/README.md) for
|
|
77
|
+
backend-specific setup.
|
|
78
|
+
|
|
79
|
+
Run the end-to-end task and DAG checks with:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
FUNMILL_API_KEY=the-value-from-env ./scripts/smoke.sh
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Set `FUNMILL_CALLBACK_URL` to test callbacks. The URL must be reachable from
|
|
86
|
+
the backend workers.
|
|
87
|
+
|
|
88
|
+
## API
|
|
89
|
+
|
|
90
|
+
| Operation | Route |
|
|
91
|
+
| --- | --- |
|
|
92
|
+
| Health check | `GET /health` |
|
|
93
|
+
| Submit Python/Bash | `POST /v1/tasks` |
|
|
94
|
+
| Submit a DAG | `POST /v1/workflows` |
|
|
95
|
+
| Status | `GET /v1/tasks/{task_id}` |
|
|
96
|
+
| Logs | `GET /v1/tasks/{task_id}/logs` |
|
|
97
|
+
| Progress | `GET /v1/tasks/{task_id}/progress` |
|
|
98
|
+
| Result | `GET /v1/tasks/{task_id}/result` |
|
|
99
|
+
| Cancel | `POST /v1/tasks/{task_id}/cancel` |
|
|
100
|
+
| Rerun | `POST /v1/tasks/{task_id}/rerun` |
|
|
101
|
+
|
|
102
|
+
`GET /health` is unauthenticated and reports whether the Funmill process and
|
|
103
|
+
its configured backend are reachable; it returns `{"status": "ok", "backend":
|
|
104
|
+
"..."}` on success and a 503 with a `detail` message when the backend is
|
|
105
|
+
unreachable or misconfigured.
|
|
106
|
+
|
|
107
|
+
Submit one task, optionally waiting for existing task IDs:
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"language": "python",
|
|
112
|
+
"source": "def main(value: int):\n return value * 2\n",
|
|
113
|
+
"args": {"value": 21},
|
|
114
|
+
"depends_on": ["EXISTING_TASK_ID"],
|
|
115
|
+
"dependency_timeout_seconds": 3600,
|
|
116
|
+
"retry": {"attempts": 2, "delay_seconds": 5},
|
|
117
|
+
"timeout_seconds": 300,
|
|
118
|
+
"callback_url": "https://example.internal/task-callback"
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Submit `A -> [B, C]` as one workflow:
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
{
|
|
126
|
+
"tasks": [
|
|
127
|
+
{"key": "a", "language": "python", "source": "def main(): return 1"},
|
|
128
|
+
{"key": "b", "language": "python", "source": "def main(): return 2", "depends_on": ["a"]},
|
|
129
|
+
{"key": "c", "language": "bash", "source": "main() { echo 3; }", "depends_on": ["a"]}
|
|
130
|
+
]
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Dependencies inside a workflow are task keys. Top-level `depends_on` values are
|
|
135
|
+
IDs returned by earlier Funmill submissions. Backends check those dependencies
|
|
136
|
+
from worker jobs, so waiting does not hold the Funmill API process. Failure or
|
|
137
|
+
cancellation of a dependency fails the waiting task.
|
|
138
|
+
Workflow results and callback payloads are objects keyed by every workflow task
|
|
139
|
+
key. Each topological layer is a synchronization barrier; tasks in the same
|
|
140
|
+
layer run in parallel.
|
|
141
|
+
|
|
142
|
+
Callbacks contain `task_id`, `status`, and `payload`; success and failure
|
|
143
|
+
delivery retry three times. Delivery is at least once, so callback receivers
|
|
144
|
+
must be idempotent.
|
|
145
|
+
|
|
146
|
+
## Python SDK
|
|
147
|
+
|
|
148
|
+
A Python client for the `/v1` routes plus `/health` lives in the separate
|
|
149
|
+
[`funmill-sdk`](https://github.com/farfarfun/funmill-sdk) repository, published
|
|
150
|
+
to PyPI as `funmill` (imported as `funmill.client`):
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
pip install funmill
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
from funmill.client import FunmillClient, TaskSubmit
|
|
158
|
+
|
|
159
|
+
with FunmillClient(base_url="http://127.0.0.1:8812", api_key="replace-me") as client:
|
|
160
|
+
client.health() # {"status": "ok", "backend": "dagu"}
|
|
161
|
+
|
|
162
|
+
accepted = client.submit_task(
|
|
163
|
+
TaskSubmit(language="python", source="def main(): return 21 * 2")
|
|
164
|
+
)
|
|
165
|
+
task = client.get_task(accepted.task_id)
|
|
166
|
+
result = client.get_result(accepted.task_id)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
See the `funmill-sdk` README for the full client API and error handling.
|
|
170
|
+
|
|
171
|
+
## Backends
|
|
172
|
+
|
|
173
|
+
The public contract is `TaskBackend` in `src/funmill/api/backends/base.py`. Backend
|
|
174
|
+
selection uses `FUNMILL_BACKEND`; registration lives in
|
|
175
|
+
`src/funmill/api/backends/__init__.py`, following the same driver pattern as
|
|
176
|
+
`fundrive`. Each third-party adapter lives in its own directory, such as
|
|
177
|
+
`src/funmill/api/backends/windmill/`.
|
|
178
|
+
|
|
179
|
+
Every third-party adapter directory must include a `README.md` covering its
|
|
180
|
+
supported platforms, installation, configuration, startup, verification, and
|
|
181
|
+
security or operational constraints.
|
|
182
|
+
|
|
183
|
+
Every managed HTTP service must bind to `SERVICE_BIND_HOST` (`0.0.0.0`). Every
|
|
184
|
+
third-party service must use the shared background lifecycle in
|
|
185
|
+
`src/funmill/api/backends/service.py` and expose `start`, `stop`, and `status`;
|
|
186
|
+
`restart` is composed from `stop` and `start` by the CLI.
|
|
187
|
+
|
|
188
|
+
Changing the backend does not change `/v1`, but it does not migrate old jobs or
|
|
189
|
+
their IDs. Add a Funmill-owned ID mapping database only when jobs must remain
|
|
190
|
+
queryable after a live backend migration.
|
|
191
|
+
|
|
192
|
+
## Operations
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
funmill services
|
|
196
|
+
funmill install dagu
|
|
197
|
+
funmill start dagu
|
|
198
|
+
funmill status dagu
|
|
199
|
+
funmill restart dagu
|
|
200
|
+
funmill stop dagu
|
|
201
|
+
funmill install windmill
|
|
202
|
+
funmill start windmill
|
|
203
|
+
funmill start
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Third-party services run in the background with PID and log files under
|
|
207
|
+
`~/.farfarfun/funmill/services/<service>/`; replace `dagu` with `windmill` in
|
|
208
|
+
the lifecycle commands as needed. The Funmill API remains in the foreground and
|
|
209
|
+
stops with `Ctrl+C`. Add authentication, TLS, firewall rules, PostgreSQL
|
|
210
|
+
backups, callback egress restrictions, and a secrets manager before network
|
|
211
|
+
exposure.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
funmill/api/__init__.py,sha256=bOFRmVJ0Fum8eBNn31EyOkjpDwqUzlQmbHOa2kArp9k,765
|
|
2
|
+
funmill/api/app.py,sha256=McZUCWKuQho1Di4001qzD8MbuQ-7U0sAlE8kJYaKZqg,3145
|
|
3
|
+
funmill/api/cli.py,sha256=MO2iFLOsjny6JBAiTUTbtczHfL-vKn_iqDonc1CBNeo,2751
|
|
4
|
+
funmill/api/models.py,sha256=c0R-gJi2Tfo8TZKDz-Gwmd5Qd4fGmrNE_n5AjV2jmzM,3756
|
|
5
|
+
funmill/api/ports.py,sha256=xfBREmyI6vBOhF_E6HQgUDjysSRzy_0GPfjfua7vHRI,82
|
|
6
|
+
funmill/api/backends/__init__.py,sha256=ZXD-BqF4t59W_AhoLV3l20n0MxBJEM6zrPz6TrtxP4k,1066
|
|
7
|
+
funmill/api/backends/base.py,sha256=8hXbv3Bw3wJ4WF75MZqf7hA-ZRKpKgjDNFhzlRFyL7M,1208
|
|
8
|
+
funmill/api/backends/service.py,sha256=8OEb4Uepo3l6fudscVjYfhO9ISYWQIy-ANNwOmxVWLk,3122
|
|
9
|
+
funmill/api/backends/dagu/README.md,sha256=wzER_C0XPDILL-Urlgh5uvIDjU2RUAfBteK-2qKdc04,2945
|
|
10
|
+
funmill/api/backends/dagu/__init__.py,sha256=27eRanZ2aX_46_T6uPMheKxewWCGOz2GsTioOx8sdQg,16407
|
|
11
|
+
funmill/api/backends/dagu/service.py,sha256=7ddkGzOVX1bfxHOzNVZ3mG_mjXSDya2i9ltoxwcqlME,5147
|
|
12
|
+
funmill/api/backends/windmill/README.md,sha256=nifEkIMoqXr77Jct7ZrET9tuwtwUkmlRLTDK7OcGt8Y,3437
|
|
13
|
+
funmill/api/backends/windmill/__init__.py,sha256=o5j3Yi9TNuY9w6wW4REaq5VEQSlDf1aAgmqzdACF05s,14689
|
|
14
|
+
funmill/api/backends/windmill/service.py,sha256=gf7ZVPHVW6JXSipbkTHmc3U8KN4qA0Ulk2fHe-bEzco,4887
|
|
15
|
+
funmill_api-0.1.2.dist-info/METADATA,sha256=q-xGYmlStuU1eriIDjslACmBFlxnSFXLZ1Q_jLJUvFk,7435
|
|
16
|
+
funmill_api-0.1.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
17
|
+
funmill_api-0.1.2.dist-info/entry_points.txt,sha256=O6hMrrwUkrlQDnKfgSs_joamaUzVrfxfH2_gyfO0Nrg,49
|
|
18
|
+
funmill_api-0.1.2.dist-info/RECORD,,
|