tasklease 0.1.0__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.
- tasklease/__init__.py +7 -0
- tasklease/__main__.py +5 -0
- tasklease/cli/__init__.py +53 -0
- tasklease/cli/common.py +87 -0
- tasklease/cli/identity.py +106 -0
- tasklease/cli/inbox.py +37 -0
- tasklease/cli/monitor.py +57 -0
- tasklease/cli/requester.py +197 -0
- tasklease/cli/service.py +169 -0
- tasklease/cli/tasks.py +175 -0
- tasklease/cli/topics.py +65 -0
- tasklease/cli/worker.py +186 -0
- tasklease/client.py +283 -0
- tasklease/cloudflare/__init__.py +6 -0
- tasklease/cloudflare/bundle.py +78 -0
- tasklease/cloudflare/deployer.py +714 -0
- tasklease/cloudflare/wrangler.py +300 -0
- tasklease/config.py +206 -0
- tasklease/errors.py +89 -0
- tasklease/fsio.py +275 -0
- tasklease/generated/__init__.py +1 -0
- tasklease/generated/protocol.py +478 -0
- tasklease/monitor/__init__.py +1 -0
- tasklease/monitor/server.py +325 -0
- tasklease/monitor/static/THIRD_PARTY_NOTICES.md +25 -0
- tasklease/monitor/static/assets/index-ClHk7fvq.css +1 -0
- tasklease/monitor/static/assets/index-CmFr3mRG.js +6 -0
- tasklease/monitor/static/index.html +13 -0
- tasklease/names.py +60 -0
- tasklease/py.typed +0 -0
- tasklease/service_bundle/README.md +1 -0
- tasklease/service_bundle/THIRD_PARTY_NOTICES.md +2163 -0
- tasklease/service_bundle/index.js +18533 -0
- tasklease/service_bundle/index.js.map +8 -0
- tasklease/service_bundle/manifest.json +4 -0
- tasklease/service_bundle/wrangler.template.jsonc +53 -0
- tasklease/store.py +669 -0
- tasklease/worker.py +444 -0
- tasklease/worker_control.py +114 -0
- tasklease/worker_templates/launchd.plist +22 -0
- tasklease/worker_templates/systemd.service +15 -0
- tasklease/worker_templates/windows-stop.ps1 +4 -0
- tasklease/worker_templates/windows.xml +27 -0
- tasklease-0.1.0.dist-info/METADATA +495 -0
- tasklease-0.1.0.dist-info/RECORD +48 -0
- tasklease-0.1.0.dist-info/WHEEL +4 -0
- tasklease-0.1.0.dist-info/entry_points.txt +2 -0
- tasklease-0.1.0.dist-info/licenses/LICENSE +202 -0
tasklease/cli/service.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Operator-facing deployment commands and complete first-use instructions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import getpass
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from tasklease.cloudflare import DeployError, DeployOptions, deploy, status
|
|
13
|
+
from tasklease.config import load_config
|
|
14
|
+
from tasklease.errors import ConfigError
|
|
15
|
+
|
|
16
|
+
DEPLOY_HELP = """Deploy or upgrade tasklease in your own Cloudflare account.
|
|
17
|
+
|
|
18
|
+
You need Workers and R2 enabled, and Node.js current LTS (with npx).
|
|
19
|
+
tl uses the exact Wrangler version recorded in its bundled manifest.
|
|
20
|
+
|
|
21
|
+
1. SIGN IN
|
|
22
|
+
Run npx --yes wrangler@4.156.0 login once and approve the browser flow.
|
|
23
|
+
For scripts, use the Cloudflare Edit Cloudflare Workers API token template,
|
|
24
|
+
export CLOUDFLARE_API_TOKEN, and pass --account-id. The token must permit
|
|
25
|
+
creating/updating Workers and managing R2 buckets and lifecycle rules.
|
|
26
|
+
|
|
27
|
+
2. ACCOUNT ID
|
|
28
|
+
Pass --account-id, set CLOUDFLARE_ACCOUNT_ID, or reuse the saved account.
|
|
29
|
+
Without one, a single account in Wrangler's OAuth sign-in is selected.
|
|
30
|
+
Dashboard: Workers & Pages > Account Details > Account ID.
|
|
31
|
+
|
|
32
|
+
3. R2 SIGNING KEYS
|
|
33
|
+
Give both TL_R2_ACCESS_KEY_ID and TL_R2_SECRET_ACCESS_KEY, or pass the ID
|
|
34
|
+
with --r2-access-key-id and enter the secret at the private prompt.
|
|
35
|
+
On first use, run without keys: tl creates the bucket and Worker, prints
|
|
36
|
+
and confirms an admin token, then stops with instructions for the keys.
|
|
37
|
+
In R2 Object Storage > Overview > Account Details > Manage API Tokens,
|
|
38
|
+
create an Account API token with Object Read & Write permission scoped
|
|
39
|
+
to the selected bucket. Record the Access Key ID and Secret Access Key.
|
|
40
|
+
OAuth login credentials cannot sign S3 URLs.
|
|
41
|
+
|
|
42
|
+
The service uses https://<name>.<subdomain>.workers.dev. Open Workers & Pages
|
|
43
|
+
once if the account has no workers.dev subdomain. --url supplies that address
|
|
44
|
+
when Wrangler cannot report it. No custom domain is needed.
|
|
45
|
+
|
|
46
|
+
New admin tokens are printed before submission and never saved. Store yours
|
|
47
|
+
in a password manager. If confirmation cannot finish, retain the candidate;
|
|
48
|
+
set TL_ADMIN_TOKEN to it and rerun, or use --rotate-admin-token. A timeout
|
|
49
|
+
does not prove the candidate was rejected or that the old token still works.
|
|
50
|
+
|
|
51
|
+
On upgrades, TL_ADMIN_TOKEN enables the task and 16-byte R2 upload/download
|
|
52
|
+
check. Without it, the deploy reports that verification was skipped. Probe
|
|
53
|
+
identities are revoked; probe blobs expire under the seven-day lifecycle rule.
|
|
54
|
+
Changing the account or bucket requires a new pair of signing keys.
|
|
55
|
+
|
|
56
|
+
--config (or TL_CONFIG) selects every config read and write. Unrelated config
|
|
57
|
+
settings are preserved. Nonsecret coordinates are saved after upload so an
|
|
58
|
+
interrupted bootstrap resumes against the same target. --dry-run performs
|
|
59
|
+
read-only inspection and prints the generated Wrangler config and action plan.
|
|
60
|
+
|
|
61
|
+
Separate test copy:
|
|
62
|
+
tl service deploy --config /tmp/tl-test.toml --name tasklease-test \\
|
|
63
|
+
--bucket tasklease-test-blobs
|
|
64
|
+
|
|
65
|
+
Exit codes: 0 completed; 4 more input needed; 1 failed.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _text(args: argparse.Namespace, name: str) -> str | None:
|
|
70
|
+
value: object = getattr(args, name, None)
|
|
71
|
+
if value is None:
|
|
72
|
+
return None
|
|
73
|
+
if isinstance(value, Path):
|
|
74
|
+
return str(value)
|
|
75
|
+
if not isinstance(value, str):
|
|
76
|
+
raise DeployError(f"Invalid {name} argument.", 4)
|
|
77
|
+
return value
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _run_deploy(args: argparse.Namespace) -> int:
|
|
81
|
+
try:
|
|
82
|
+
environment = dict(os.environ)
|
|
83
|
+
key_id = _text(args, "r2_access_key_id") or environment.get(
|
|
84
|
+
"TL_R2_ACCESS_KEY_ID"
|
|
85
|
+
)
|
|
86
|
+
if (
|
|
87
|
+
key_id
|
|
88
|
+
and not environment.get("TL_R2_SECRET_ACCESS_KEY")
|
|
89
|
+
and sys.stdin.isatty()
|
|
90
|
+
):
|
|
91
|
+
environment["TL_R2_SECRET_ACCESS_KEY"] = getpass.getpass(
|
|
92
|
+
"R2 Secret Access Key: "
|
|
93
|
+
)
|
|
94
|
+
result = deploy(
|
|
95
|
+
DeployOptions(
|
|
96
|
+
account_id=_text(args, "account_id"),
|
|
97
|
+
name=_text(args, "name"),
|
|
98
|
+
bucket=_text(args, "bucket"),
|
|
99
|
+
r2_access_key_id=key_id,
|
|
100
|
+
url=_text(args, "url"),
|
|
101
|
+
rotate_admin_token=bool(getattr(args, "rotate_admin_token", False)),
|
|
102
|
+
dry_run=bool(getattr(args, "dry_run", False)),
|
|
103
|
+
),
|
|
104
|
+
load_config(_text(args, "config"), environ=environment),
|
|
105
|
+
environ=environment,
|
|
106
|
+
)
|
|
107
|
+
except DeployError as exc:
|
|
108
|
+
print(str(exc), file=sys.stderr)
|
|
109
|
+
return exc.exit_code
|
|
110
|
+
except (ConfigError, OSError) as exc:
|
|
111
|
+
print(str(exc), file=sys.stderr)
|
|
112
|
+
return 4 if isinstance(exc, ConfigError) else 1
|
|
113
|
+
print(json.dumps(result))
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _run_status(args: argparse.Namespace) -> int:
|
|
118
|
+
try:
|
|
119
|
+
environment = dict(os.environ)
|
|
120
|
+
override = _text(args, "url")
|
|
121
|
+
if override is not None:
|
|
122
|
+
environment["TL_URL"] = override
|
|
123
|
+
result = status(
|
|
124
|
+
load_config(_text(args, "config"), environ=environment),
|
|
125
|
+
environ=environment,
|
|
126
|
+
)
|
|
127
|
+
except DeployError as exc:
|
|
128
|
+
print(str(exc), file=sys.stderr)
|
|
129
|
+
return exc.exit_code
|
|
130
|
+
except (ConfigError, OSError) as exc:
|
|
131
|
+
print(str(exc), file=sys.stderr)
|
|
132
|
+
return 4 if isinstance(exc, ConfigError) else 1
|
|
133
|
+
print(json.dumps(result))
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def configure_parser(
|
|
138
|
+
subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
|
|
139
|
+
) -> None:
|
|
140
|
+
service = subparsers.add_parser("service", help="deploy or inspect the service")
|
|
141
|
+
commands = service.add_subparsers(dest="service_command", required=True)
|
|
142
|
+
deployment = commands.add_parser(
|
|
143
|
+
"deploy",
|
|
144
|
+
help="deploy or upgrade the bundled Worker",
|
|
145
|
+
description=DEPLOY_HELP,
|
|
146
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
147
|
+
)
|
|
148
|
+
for name, help_text in (
|
|
149
|
+
("account-id", "Cloudflare account ID"),
|
|
150
|
+
("name", "Worker name"),
|
|
151
|
+
("bucket", "R2 bucket name"),
|
|
152
|
+
("r2-access-key-id", "R2 signing access key ID"),
|
|
153
|
+
("url", "HTTPS workers.dev address when Wrangler cannot report it"),
|
|
154
|
+
("config", "tasklease config file (or TL_CONFIG)"),
|
|
155
|
+
):
|
|
156
|
+
deployment.add_argument(f"--{name}", help=help_text, default=argparse.SUPPRESS)
|
|
157
|
+
deployment.add_argument("--rotate-admin-token", action="store_true")
|
|
158
|
+
deployment.add_argument("--dry-run", action="store_true")
|
|
159
|
+
deployment.set_defaults(handler=_run_deploy)
|
|
160
|
+
inspection = commands.add_parser(
|
|
161
|
+
"status", help="compare service and installed versions"
|
|
162
|
+
)
|
|
163
|
+
inspection.add_argument(
|
|
164
|
+
"--config", default=argparse.SUPPRESS, help="tasklease config file"
|
|
165
|
+
)
|
|
166
|
+
inspection.add_argument(
|
|
167
|
+
"--url", default=argparse.SUPPRESS, help="service origin to inspect"
|
|
168
|
+
)
|
|
169
|
+
inspection.set_defaults(handler=_run_status)
|
tasklease/cli/tasks.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Read, move and prune tasks using their current fenced handles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
|
|
9
|
+
from tasklease.cli.common import (
|
|
10
|
+
client,
|
|
11
|
+
configuration,
|
|
12
|
+
connection_options,
|
|
13
|
+
domain,
|
|
14
|
+
duration_seconds,
|
|
15
|
+
flag,
|
|
16
|
+
optional_text,
|
|
17
|
+
output,
|
|
18
|
+
text,
|
|
19
|
+
)
|
|
20
|
+
from tasklease.client import Client
|
|
21
|
+
from tasklease.errors import Forbidden, NotFound
|
|
22
|
+
from tasklease.generated.protocol import TasksListRequest, TaskSummary
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _tasks(args: argparse.Namespace) -> int:
|
|
26
|
+
request: TasksListRequest = {
|
|
27
|
+
"domain": domain(args, configuration(args)),
|
|
28
|
+
"topic": text(args, "topic"),
|
|
29
|
+
}
|
|
30
|
+
cursor = optional_text(args, "cursor")
|
|
31
|
+
if cursor is not None:
|
|
32
|
+
request["cursor"] = cursor
|
|
33
|
+
with client(args) as api:
|
|
34
|
+
output(api.tasks_list(request))
|
|
35
|
+
return 0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _read(args: argparse.Namespace) -> int:
|
|
39
|
+
with client(args) as api:
|
|
40
|
+
output(
|
|
41
|
+
api.read(
|
|
42
|
+
{
|
|
43
|
+
"domain": domain(args, configuration(args)),
|
|
44
|
+
"topic": text(args, "topic"),
|
|
45
|
+
"id": text(args, "id"),
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _delete(args: argparse.Namespace) -> int:
|
|
53
|
+
with client(args) as api:
|
|
54
|
+
output(
|
|
55
|
+
api.delete(
|
|
56
|
+
{
|
|
57
|
+
"domain": domain(args, configuration(args)),
|
|
58
|
+
"topic": text(args, "topic"),
|
|
59
|
+
"id": text(args, "id"),
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _pollers(args: argparse.Namespace) -> int:
|
|
67
|
+
with client(args) as api:
|
|
68
|
+
output(
|
|
69
|
+
api.topic_pollers(
|
|
70
|
+
{
|
|
71
|
+
"domain": domain(args, configuration(args)),
|
|
72
|
+
"topic": text(args, "topic"),
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _move(args: argparse.Namespace) -> int:
|
|
80
|
+
selected, topic, task_id = (
|
|
81
|
+
domain(args, configuration(args)),
|
|
82
|
+
text(args, "topic"),
|
|
83
|
+
text(args, "id"),
|
|
84
|
+
)
|
|
85
|
+
with client(args) as api:
|
|
86
|
+
task = api.read({"domain": selected, "topic": topic, "id": task_id})["task"]
|
|
87
|
+
output(
|
|
88
|
+
api.consume(
|
|
89
|
+
{
|
|
90
|
+
"domain": selected,
|
|
91
|
+
"topic": topic,
|
|
92
|
+
"id": task["id"],
|
|
93
|
+
"tasks": [{"topic": text(args, "to"), "data": task["data"]}],
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _age(value: str) -> int:
|
|
101
|
+
return duration_seconds(value, name="Age") * 1000
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _pages(api: Client, selected: str, topic: str) -> Iterator[TaskSummary]:
|
|
105
|
+
request: TasksListRequest = {"domain": selected, "topic": topic}
|
|
106
|
+
while True:
|
|
107
|
+
page = api.tasks_list(request)
|
|
108
|
+
yield from page["tasks"]
|
|
109
|
+
cursor = page.get("cursor")
|
|
110
|
+
if cursor is None:
|
|
111
|
+
return
|
|
112
|
+
request["cursor"] = cursor
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _prune(args: argparse.Namespace) -> int:
|
|
116
|
+
selected = domain(args, configuration(args))
|
|
117
|
+
cutoff = time.time_ns() // 1_000_000 - _age(text(args, "lease_older_than"))
|
|
118
|
+
deleted = skipped = 0
|
|
119
|
+
with client(args) as api:
|
|
120
|
+
topics = api.topics_list({"domain": selected, "glob": text(args, "topic")})[
|
|
121
|
+
"topics"
|
|
122
|
+
]
|
|
123
|
+
for topic in topics:
|
|
124
|
+
for task in _pages(api, selected, topic["topic"]):
|
|
125
|
+
if task["lease_until"] >= cutoff or (
|
|
126
|
+
task["owned_count"] == 0 and not flag(args, "all")
|
|
127
|
+
):
|
|
128
|
+
continue
|
|
129
|
+
try:
|
|
130
|
+
api.delete(
|
|
131
|
+
{
|
|
132
|
+
"domain": selected,
|
|
133
|
+
"topic": topic["topic"],
|
|
134
|
+
"id": task["id"],
|
|
135
|
+
"lease_before": cutoff,
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
except (NotFound, Forbidden):
|
|
139
|
+
skipped += 1
|
|
140
|
+
else:
|
|
141
|
+
deleted += 1
|
|
142
|
+
output({"ok": True, "deleted": deleted, "skipped": skipped})
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def configure_parser(
|
|
147
|
+
subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
|
|
148
|
+
) -> None:
|
|
149
|
+
for name, handler in (
|
|
150
|
+
("tasks", _tasks),
|
|
151
|
+
("read", _read),
|
|
152
|
+
("delete", _delete),
|
|
153
|
+
("pollers", _pollers),
|
|
154
|
+
("move", _move),
|
|
155
|
+
):
|
|
156
|
+
parser = subparsers.add_parser(name)
|
|
157
|
+
connection_options(parser)
|
|
158
|
+
parser.add_argument("domain")
|
|
159
|
+
parser.add_argument("topic")
|
|
160
|
+
if name in {"read", "delete", "move"}:
|
|
161
|
+
parser.add_argument("id")
|
|
162
|
+
if name == "tasks":
|
|
163
|
+
parser.add_argument("--cursor")
|
|
164
|
+
if name == "move":
|
|
165
|
+
parser.add_argument("--to", required=True)
|
|
166
|
+
parser.set_defaults(handler=handler)
|
|
167
|
+
prune = subparsers.add_parser("prune", help="Remove abandoned tasks by age")
|
|
168
|
+
connection_options(prune)
|
|
169
|
+
prune.add_argument("domain")
|
|
170
|
+
prune.add_argument("--topic", required=True, help="Topic glob")
|
|
171
|
+
prune.add_argument("--lease-older-than", required=True)
|
|
172
|
+
prune.add_argument(
|
|
173
|
+
"--all", action="store_true", help="Also include never-owned tasks"
|
|
174
|
+
)
|
|
175
|
+
prune.set_defaults(handler=_prune)
|
tasklease/cli/topics.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Topic discovery and declarations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from tasklease.cli.common import (
|
|
8
|
+
client,
|
|
9
|
+
configuration,
|
|
10
|
+
connection_options,
|
|
11
|
+
domain,
|
|
12
|
+
flag,
|
|
13
|
+
optional_text,
|
|
14
|
+
output,
|
|
15
|
+
text,
|
|
16
|
+
)
|
|
17
|
+
from tasklease.generated.protocol import TopicsListRequest
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _topics(args: argparse.Namespace) -> int:
|
|
21
|
+
selected = optional_text(args, "domain")
|
|
22
|
+
with client(args) as api:
|
|
23
|
+
domains = [selected] if selected else api.identity_whoami({})["domains"]
|
|
24
|
+
results: list[object] = []
|
|
25
|
+
for name in domains:
|
|
26
|
+
request: TopicsListRequest = {"domain": name}
|
|
27
|
+
glob = optional_text(args, "glob")
|
|
28
|
+
if glob is not None:
|
|
29
|
+
request["glob"] = glob
|
|
30
|
+
results.append({"domain": name, **api.topics_list(request)})
|
|
31
|
+
output(results)
|
|
32
|
+
return 0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _declare(args: argparse.Namespace) -> int:
|
|
36
|
+
config = configuration(args)
|
|
37
|
+
with client(args) as api:
|
|
38
|
+
output(
|
|
39
|
+
api.topic_declare(
|
|
40
|
+
{
|
|
41
|
+
"domain": domain(args, config),
|
|
42
|
+
"topic": text(args, "topic"),
|
|
43
|
+
"declared": not flag(args, "remove"),
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def configure_parser(
|
|
51
|
+
subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
|
|
52
|
+
) -> None:
|
|
53
|
+
topics = subparsers.add_parser("topics", help="List topics across domains")
|
|
54
|
+
connection_options(topics)
|
|
55
|
+
topics.add_argument("--domain")
|
|
56
|
+
topics.add_argument("--glob")
|
|
57
|
+
topics.set_defaults(handler=_topics)
|
|
58
|
+
topic = subparsers.add_parser("topic", help="Declare well-known topics")
|
|
59
|
+
commands = topic.add_subparsers(dest="topic_command", required=True)
|
|
60
|
+
declare = commands.add_parser("declare")
|
|
61
|
+
connection_options(declare)
|
|
62
|
+
declare.add_argument("domain")
|
|
63
|
+
declare.add_argument("topic")
|
|
64
|
+
declare.add_argument("--remove", action="store_true", help="Remove the declaration")
|
|
65
|
+
declare.set_defaults(handler=_declare)
|
tasklease/cli/worker.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Run a generic worker or request a graceful local stop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import importlib
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from importlib.resources import files
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from threading import TIMEOUT_MAX
|
|
12
|
+
from typing import cast
|
|
13
|
+
|
|
14
|
+
from tasklease.cli.common import (
|
|
15
|
+
client,
|
|
16
|
+
configuration,
|
|
17
|
+
connection_options,
|
|
18
|
+
duration_seconds,
|
|
19
|
+
flag,
|
|
20
|
+
optional_text,
|
|
21
|
+
output,
|
|
22
|
+
text,
|
|
23
|
+
)
|
|
24
|
+
from tasklease.errors import ConfigError
|
|
25
|
+
from tasklease.worker import (
|
|
26
|
+
EchoHandler,
|
|
27
|
+
Handler,
|
|
28
|
+
UnlimitedCapacity,
|
|
29
|
+
WorkerLoop,
|
|
30
|
+
validate_work_topic,
|
|
31
|
+
)
|
|
32
|
+
from tasklease.worker_control import WorkerControl, request_stop
|
|
33
|
+
|
|
34
|
+
_TEMPLATES = {
|
|
35
|
+
"systemd": "systemd.service",
|
|
36
|
+
"launchd": "launchd.plist",
|
|
37
|
+
"windows": "windows.xml",
|
|
38
|
+
"windows-stop": "windows-stop.ps1",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _template(args: argparse.Namespace) -> int:
|
|
43
|
+
resource = files("tasklease").joinpath(
|
|
44
|
+
"worker_templates", _TEMPLATES[text(args, "platform")]
|
|
45
|
+
)
|
|
46
|
+
sys.stdout.write(resource.read_text(encoding="utf-8"))
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _table(values: dict[str, object]) -> dict[str, object]:
|
|
51
|
+
value = values.get("worker", {})
|
|
52
|
+
if not isinstance(value, dict):
|
|
53
|
+
raise ConfigError("worker must be a TOML table")
|
|
54
|
+
return cast(dict[str, object], value)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _setting(
|
|
58
|
+
args: argparse.Namespace, settings: dict[str, object], name: str, default: object
|
|
59
|
+
) -> object:
|
|
60
|
+
selected: object = getattr(
|
|
61
|
+
args, "worker_handler" if name == "handler" else name, None
|
|
62
|
+
)
|
|
63
|
+
return settings.get(name, default) if selected is None else selected
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _text(value: object, name: str) -> str:
|
|
67
|
+
if not isinstance(value, str) or not value:
|
|
68
|
+
raise ConfigError(f"worker {name} must be a nonempty string")
|
|
69
|
+
return value
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _seconds(value: object, name: str) -> int:
|
|
73
|
+
if type(value) is int and value > 0:
|
|
74
|
+
return value
|
|
75
|
+
if isinstance(value, str):
|
|
76
|
+
return duration_seconds(value, name=name)
|
|
77
|
+
raise ConfigError(f"worker {name} must be positive seconds or a duration")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _handler(value: str) -> Handler[object, object]:
|
|
81
|
+
if value == "echo":
|
|
82
|
+
return cast(Handler[object, object], EchoHandler())
|
|
83
|
+
module_name, separator, factory_name = value.partition(":")
|
|
84
|
+
if not separator or not module_name or not factory_name.isidentifier():
|
|
85
|
+
raise ConfigError("worker handler must be echo or module:factory")
|
|
86
|
+
try:
|
|
87
|
+
module = importlib.import_module(module_name)
|
|
88
|
+
factory: object = getattr(module, factory_name)
|
|
89
|
+
if not callable(factory):
|
|
90
|
+
raise TypeError("Factory is not callable")
|
|
91
|
+
instance = cast(Callable[[], object], factory)()
|
|
92
|
+
except Exception:
|
|
93
|
+
raise ConfigError(f"Cannot load worker handler {value}") from None
|
|
94
|
+
if not all(
|
|
95
|
+
callable(getattr(instance, name, None)) for name in ("validate", "run", "reply")
|
|
96
|
+
):
|
|
97
|
+
raise ConfigError("worker handler must provide validate, run and reply")
|
|
98
|
+
return cast(Handler[object, object], instance)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _root(args: argparse.Namespace) -> Path | None:
|
|
102
|
+
value: object = getattr(args, "state_dir", None)
|
|
103
|
+
if value is not None and not isinstance(value, Path):
|
|
104
|
+
raise ConfigError("state-dir must be a path")
|
|
105
|
+
return value
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _stop(args: argparse.Namespace) -> int:
|
|
109
|
+
label = optional_text(args, "worker") or configuration(args).worker_label
|
|
110
|
+
path = request_stop(label, _root(args))
|
|
111
|
+
output({"ok": True, "worker": label, "stop_file": str(path)})
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _run(args: argparse.Namespace) -> int:
|
|
116
|
+
config = configuration(args)
|
|
117
|
+
settings = _table(config.values)
|
|
118
|
+
selected = _text(optional_text(args, "domain") or config.domain, "domain")
|
|
119
|
+
topic = validate_work_topic(_text(_setting(args, settings, "topic", None), "topic"))
|
|
120
|
+
lease = _seconds(_setting(args, settings, "lease", 600), "lease")
|
|
121
|
+
renew = _seconds(_setting(args, settings, "renew_every", 180), "renew_every")
|
|
122
|
+
poll = _seconds(_setting(args, settings, "poll_interval", 30), "poll_interval")
|
|
123
|
+
if poll > TIMEOUT_MAX / 1.2:
|
|
124
|
+
raise ConfigError("worker poll_interval exceeds this platform's timer limit")
|
|
125
|
+
maximum = _setting(args, settings, "max_owned", 3)
|
|
126
|
+
if type(maximum) is not int or maximum <= 0:
|
|
127
|
+
raise ConfigError("worker max_owned must be a positive integer")
|
|
128
|
+
if lease > 86400 or renew >= lease:
|
|
129
|
+
raise ConfigError(
|
|
130
|
+
"worker lease must be at most 86400s and renew_every below lease"
|
|
131
|
+
)
|
|
132
|
+
handler = _handler(_text(_setting(args, settings, "handler", "echo"), "handler"))
|
|
133
|
+
label = optional_text(args, "worker") or config.worker_label
|
|
134
|
+
with WorkerControl(label, _root(args)) as control:
|
|
135
|
+
if control.stop.is_set():
|
|
136
|
+
return 0
|
|
137
|
+
with client(args) as api:
|
|
138
|
+
capacity = UnlimitedCapacity()
|
|
139
|
+
loop = WorkerLoop(
|
|
140
|
+
api,
|
|
141
|
+
selected,
|
|
142
|
+
topic,
|
|
143
|
+
capacity,
|
|
144
|
+
handler,
|
|
145
|
+
lease=lease,
|
|
146
|
+
renew_every=renew,
|
|
147
|
+
max_owned=maximum,
|
|
148
|
+
poll_interval=poll,
|
|
149
|
+
stop=control.stop,
|
|
150
|
+
)
|
|
151
|
+
if flag(args, "once"):
|
|
152
|
+
with capacity.acquire():
|
|
153
|
+
result = loop.step()
|
|
154
|
+
output({"status": result})
|
|
155
|
+
else:
|
|
156
|
+
loop.run()
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def configure_parser(
|
|
161
|
+
subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
|
|
162
|
+
) -> None:
|
|
163
|
+
parser = subparsers.add_parser("worker", help="Run an echo or plugin worker")
|
|
164
|
+
connection_options(parser)
|
|
165
|
+
parser.add_argument("--domain")
|
|
166
|
+
parser.add_argument("--topic")
|
|
167
|
+
parser.add_argument(
|
|
168
|
+
"--handler", dest="worker_handler", help="echo or module:factory"
|
|
169
|
+
)
|
|
170
|
+
parser.add_argument("--lease", help="Lease duration, default 600s")
|
|
171
|
+
parser.add_argument("--renew-every", help="Renew interval, default 180s")
|
|
172
|
+
parser.add_argument("--max-owned", type=int)
|
|
173
|
+
parser.add_argument("--poll-interval", help="Idle poll duration, default 30s")
|
|
174
|
+
parser.add_argument("--once", action="store_true", help="Own at most one task")
|
|
175
|
+
parser.add_argument("--state-dir", type=Path)
|
|
176
|
+
parser.set_defaults(handler=_run)
|
|
177
|
+
commands = parser.add_subparsers(dest="worker_command")
|
|
178
|
+
stop = commands.add_parser("stop", help="Request a graceful stop without a token")
|
|
179
|
+
connection_options(stop)
|
|
180
|
+
stop.add_argument("--state-dir", type=Path, default=argparse.SUPPRESS)
|
|
181
|
+
stop.set_defaults(handler=_stop)
|
|
182
|
+
template = commands.add_parser(
|
|
183
|
+
"template", help="Print a user-worker startup template for editing"
|
|
184
|
+
)
|
|
185
|
+
template.add_argument("--platform", choices=tuple(_TEMPLATES), required=True)
|
|
186
|
+
template.set_defaults(handler=_template)
|