goatbeat-cli 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.
- goatbeat/cli/__init__.py +0 -0
- goatbeat/cli/commands.py +183 -0
- goatbeat/cli/environment.py +256 -0
- goatbeat/cli/logs.py +47 -0
- goatbeat/cli/main.py +112 -0
- goatbeat/cli/py.typed +0 -0
- goatbeat_cli-0.1.0.dist-info/METADATA +26 -0
- goatbeat_cli-0.1.0.dist-info/RECORD +11 -0
- goatbeat_cli-0.1.0.dist-info/WHEEL +4 -0
- goatbeat_cli-0.1.0.dist-info/entry_points.txt +3 -0
- goatbeat_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
goatbeat/cli/__init__.py
ADDED
|
File without changes
|
goatbeat/cli/commands.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import signal
|
|
3
|
+
import socket
|
|
4
|
+
import sys
|
|
5
|
+
import threading
|
|
6
|
+
import uuid
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from types import FrameType
|
|
9
|
+
from typing import Final
|
|
10
|
+
|
|
11
|
+
from goatbeat.cli import environment as runtime
|
|
12
|
+
from goatbeat.cli.environment import Check, Environment
|
|
13
|
+
from goatbeat.core.ids import IdempotencyKey, SchedulerId
|
|
14
|
+
from goatbeat.core.schedule import Schedule
|
|
15
|
+
from goatbeat.core.scheduler import Scheduler
|
|
16
|
+
from goatbeat.core.time import SystemClock
|
|
17
|
+
from goatbeat.core.triggers import CronTrigger
|
|
18
|
+
|
|
19
|
+
SUCCESS: Final = 0
|
|
20
|
+
CHECK_FAILED: Final = 1
|
|
21
|
+
STOP_SIGNALS: Final = (signal.SIGTERM, signal.SIGINT)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CommandFailedError(Exception):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run_scheduler(environment: Environment, *, once: bool, metrics_port: int | None) -> int:
|
|
29
|
+
environment.ready()
|
|
30
|
+
metrics = runtime.scheduler_metrics(environment.metrics, metrics_port)
|
|
31
|
+
scheduler = Scheduler(
|
|
32
|
+
store=environment.store,
|
|
33
|
+
leases=environment.leases,
|
|
34
|
+
submitter=environment.submitter,
|
|
35
|
+
config=environment.config,
|
|
36
|
+
registry=environment.registry,
|
|
37
|
+
metrics=metrics,
|
|
38
|
+
)
|
|
39
|
+
if once:
|
|
40
|
+
try:
|
|
41
|
+
scheduler.start()
|
|
42
|
+
scheduler.tick()
|
|
43
|
+
finally:
|
|
44
|
+
scheduler.release()
|
|
45
|
+
return SUCCESS
|
|
46
|
+
stop = threading.Event()
|
|
47
|
+
|
|
48
|
+
def request_stop(signum: int, frame: FrameType | None) -> None:
|
|
49
|
+
del signum, frame
|
|
50
|
+
stop.set()
|
|
51
|
+
|
|
52
|
+
for stop_signal in STOP_SIGNALS:
|
|
53
|
+
signal.signal(stop_signal, request_stop)
|
|
54
|
+
scheduler.run(stop)
|
|
55
|
+
return SUCCESS
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def publish(environment: Environment) -> None:
|
|
59
|
+
"""Code-declared schedules must be in the store before anything reads it."""
|
|
60
|
+
environment.ready()
|
|
61
|
+
environment.store.sync_code_schedules(environment.registry.definitions(), SystemClock().now())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def status(environment: Environment, *, as_json: bool) -> int:
|
|
65
|
+
publish(environment)
|
|
66
|
+
now = SystemClock().now()
|
|
67
|
+
lease = environment.leases.current()
|
|
68
|
+
schedules = environment.store.list_schedules()
|
|
69
|
+
wakeup = environment.store.next_wakeup()
|
|
70
|
+
report: dict[str, object] = {
|
|
71
|
+
"leader": None
|
|
72
|
+
if lease is None
|
|
73
|
+
else {
|
|
74
|
+
"holder": lease.holder.value,
|
|
75
|
+
"token": lease.token.value,
|
|
76
|
+
"expires_at": lease.expires_at.isoformat(),
|
|
77
|
+
"held": lease.held_at(now),
|
|
78
|
+
},
|
|
79
|
+
"schedules": {
|
|
80
|
+
"total": len(schedules),
|
|
81
|
+
"enabled": sum(schedule.definition.enabled for schedule in schedules),
|
|
82
|
+
},
|
|
83
|
+
"next_wakeup": None if wakeup is None else wakeup.isoformat(),
|
|
84
|
+
}
|
|
85
|
+
if as_json:
|
|
86
|
+
_write(json.dumps(report))
|
|
87
|
+
return SUCCESS
|
|
88
|
+
leader = report["leader"]
|
|
89
|
+
_write(f"leader: {leader if leader is not None else 'none yet'}")
|
|
90
|
+
_write(f"schedules: {report['schedules']}")
|
|
91
|
+
_write(f"next wakeup: {report['next_wakeup']}")
|
|
92
|
+
return SUCCESS
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def doctor(environment: Environment, *, as_json: bool) -> int:
|
|
96
|
+
checks = _run_checks(environment)
|
|
97
|
+
if as_json:
|
|
98
|
+
_write(json.dumps([_check_dict(check) for check in checks]))
|
|
99
|
+
else:
|
|
100
|
+
for check in checks:
|
|
101
|
+
_write(f"{'ok ' if check.ok else 'FAIL'} {check.name}: {check.detail}")
|
|
102
|
+
return SUCCESS if all(check.ok for check in checks) else CHECK_FAILED
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def list_schedules(environment: Environment, *, as_json: bool) -> int:
|
|
106
|
+
publish(environment)
|
|
107
|
+
described = [_describe(schedule) for schedule in environment.store.list_schedules()]
|
|
108
|
+
if as_json:
|
|
109
|
+
_write(json.dumps(described))
|
|
110
|
+
return SUCCESS
|
|
111
|
+
for item in described:
|
|
112
|
+
state = "enabled" if item["enabled"] else "disabled"
|
|
113
|
+
_write(
|
|
114
|
+
f"{item['name']:<40} {item['source']:<6} {state:<9} "
|
|
115
|
+
f"{item['next_run_at']:<33} {item['trigger']}"
|
|
116
|
+
)
|
|
117
|
+
return SUCCESS
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def run_now(environment: Environment, name: str) -> int:
|
|
121
|
+
publish(environment)
|
|
122
|
+
schedule = environment.store.get_schedule(name)
|
|
123
|
+
if schedule is None:
|
|
124
|
+
raise CommandFailedError(f"unknown schedule {name!r}")
|
|
125
|
+
key = IdempotencyKey(f"manual:{uuid.uuid4().hex}")
|
|
126
|
+
requester = SchedulerId(f"cli:{socket.gethostname()}")
|
|
127
|
+
execution = environment.store.request_run(schedule, key, requester, SystemClock().now())
|
|
128
|
+
_write(f"{execution.idempotency_key.value} {execution.status.value}")
|
|
129
|
+
return SUCCESS
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def prune(environment: Environment) -> int:
|
|
133
|
+
environment.store.sync_code_schedules(
|
|
134
|
+
environment.registry.definitions(), SystemClock().now(), prune=True
|
|
135
|
+
)
|
|
136
|
+
return SUCCESS
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def migrate(environment: Environment) -> int:
|
|
140
|
+
_write(environment.migrate() if environment.migrate else "nothing to migrate")
|
|
141
|
+
return SUCCESS
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _run_checks(environment: Environment) -> Sequence[Check]:
|
|
145
|
+
backend = list(environment.backend_checks())
|
|
146
|
+
if any(not check.ok for check in backend if check.name == "schema"):
|
|
147
|
+
return [Check("store", ok=True, detail="reachable"), *backend]
|
|
148
|
+
publish(environment)
|
|
149
|
+
schedules = environment.store.list_schedules()
|
|
150
|
+
checks = [Check("store", ok=True, detail=f"{len(schedules)} schedules readable")]
|
|
151
|
+
paths = sorted({schedule.definition.task.path for schedule in schedules})
|
|
152
|
+
missing = [problem for path in paths if (problem := environment.task_problem(path))]
|
|
153
|
+
checks.append(
|
|
154
|
+
Check(
|
|
155
|
+
"tasks",
|
|
156
|
+
ok=not missing,
|
|
157
|
+
detail="every task imports" if not missing else "; ".join(missing),
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
return [*checks, *backend]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _check_dict(check: Check) -> dict[str, object]:
|
|
164
|
+
return {"name": check.name, "ok": check.ok, "detail": check.detail}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _describe(schedule: Schedule) -> dict[str, str | bool]:
|
|
168
|
+
trigger = schedule.definition.trigger
|
|
169
|
+
if isinstance(trigger, CronTrigger):
|
|
170
|
+
described = f"cron {trigger.expression.text} ({trigger.timezone.key})"
|
|
171
|
+
else:
|
|
172
|
+
described = f"every {trigger.every}"
|
|
173
|
+
return {
|
|
174
|
+
"name": schedule.name,
|
|
175
|
+
"source": schedule.source.value,
|
|
176
|
+
"enabled": schedule.definition.enabled,
|
|
177
|
+
"next_run_at": schedule.next_run_at.isoformat(),
|
|
178
|
+
"trigger": described,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _write(line: str) -> None:
|
|
183
|
+
sys.stdout.write(f"{line}\n")
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from collections.abc import Callable, Generator, Sequence
|
|
3
|
+
from contextlib import ExitStack, contextmanager
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
from importlib import import_module
|
|
7
|
+
from importlib.util import find_spec
|
|
8
|
+
from typing import Final
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
from goatbeat.core.app import GoatbeatApp
|
|
12
|
+
from goatbeat.core.config import GoatbeatConfig
|
|
13
|
+
from goatbeat.core.errors import ConfigurationError
|
|
14
|
+
from goatbeat.core.observability import FanoutMetrics, MetricsSink, NoopMetrics
|
|
15
|
+
from goatbeat.core.protocols import LeaseManager, ScheduleStore, TaskSubmitter
|
|
16
|
+
from goatbeat.core.registry import SupportsDefinitions
|
|
17
|
+
from goatbeat.core.stores.memory import MemoryStore
|
|
18
|
+
from goatbeat.core.stores.sqlite import SQLiteStore
|
|
19
|
+
from goatbeat.core.tasks import import_object
|
|
20
|
+
from goatbeat.core.time import SystemClock
|
|
21
|
+
|
|
22
|
+
APP_VARIABLE: Final = "GOATBEAT_APP"
|
|
23
|
+
DJANGO_VARIABLE: Final = "DJANGO_SETTINGS_MODULE"
|
|
24
|
+
DATABASE_VARIABLE: Final = "GOATBEAT_DATABASE_URL"
|
|
25
|
+
SQLITE_PREFIX: Final = "sqlite:///"
|
|
26
|
+
# libpq also accepts "host=... dbname=..." strings; they carry no URL scheme.
|
|
27
|
+
LIBPQ_KEYWORDS: Final = "libpq"
|
|
28
|
+
# Lease correctness ignores host clocks; a larger drift still confuses logs and schedules.
|
|
29
|
+
MAX_CLOCK_DRIFT: Final = timedelta(seconds=2)
|
|
30
|
+
PROMETHEUS_HINT: Final = "--metrics-port needs: pip install goatbeat[prometheus]"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class Check:
|
|
35
|
+
name: str
|
|
36
|
+
ok: bool
|
|
37
|
+
detail: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def no_checks() -> Sequence[Check]:
|
|
41
|
+
return ()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def always_ready() -> None:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def import_problem(path: str) -> str | None:
|
|
49
|
+
try:
|
|
50
|
+
import_object(path)
|
|
51
|
+
except Exception as error:
|
|
52
|
+
return f"{path}: {type(error).__name__}: {error}"
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(slots=True)
|
|
57
|
+
class Environment:
|
|
58
|
+
config: GoatbeatConfig
|
|
59
|
+
store: ScheduleStore
|
|
60
|
+
leases: LeaseManager
|
|
61
|
+
registry: SupportsDefinitions
|
|
62
|
+
submitter: TaskSubmitter
|
|
63
|
+
metrics: MetricsSink
|
|
64
|
+
backend_checks: Callable[[], Sequence[Check]] = no_checks
|
|
65
|
+
migrate: Callable[[], str] | None = None
|
|
66
|
+
task_problem: Callable[[str], str | None] = import_problem
|
|
67
|
+
ready: Callable[[], None] = always_ready
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def start_http_server(port: int) -> None:
|
|
71
|
+
try:
|
|
72
|
+
from prometheus_client import start_http_server as serve # noqa: PLC0415
|
|
73
|
+
except ImportError as error:
|
|
74
|
+
raise ConfigurationError(PROMETHEUS_HINT) from error
|
|
75
|
+
serve(port)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def scheduler_metrics(configured: MetricsSink, port: int | None) -> MetricsSink:
|
|
79
|
+
if port is None:
|
|
80
|
+
return configured
|
|
81
|
+
try:
|
|
82
|
+
from goatbeat.prometheus import PrometheusMetrics # noqa: PLC0415
|
|
83
|
+
except ImportError as error:
|
|
84
|
+
raise ConfigurationError(PROMETHEUS_HINT) from error
|
|
85
|
+
start_http_server(port)
|
|
86
|
+
if isinstance(configured, PrometheusMetrics):
|
|
87
|
+
return configured
|
|
88
|
+
if isinstance(configured, NoopMetrics):
|
|
89
|
+
return PrometheusMetrics()
|
|
90
|
+
return FanoutMetrics(configured, PrometheusMetrics())
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@contextmanager
|
|
94
|
+
def open_environment(app_reference: str | None) -> Generator[Environment]:
|
|
95
|
+
reference = app_reference or os.environ.get(APP_VARIABLE)
|
|
96
|
+
with ExitStack() as stack:
|
|
97
|
+
if reference is None and os.environ.get(DJANGO_VARIABLE):
|
|
98
|
+
yield _django_environment(stack)
|
|
99
|
+
elif reference is None:
|
|
100
|
+
raise ConfigurationError(
|
|
101
|
+
"tell goatbeat where your schedules are: --app module:attribute, "
|
|
102
|
+
f"{APP_VARIABLE}, or {DJANGO_VARIABLE} for a Django project"
|
|
103
|
+
)
|
|
104
|
+
else:
|
|
105
|
+
config = GoatbeatConfig.from_env(os.environ)
|
|
106
|
+
yield _standalone_environment(stack, load_app(reference), config)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def load_app(reference: str) -> GoatbeatApp:
|
|
110
|
+
module_name, _, attribute = reference.partition(":")
|
|
111
|
+
if not module_name or not attribute:
|
|
112
|
+
raise ConfigurationError(f"--app expects module:attribute, got {reference!r}")
|
|
113
|
+
try:
|
|
114
|
+
module = import_module(module_name) # nosemgrep: non-literal-import
|
|
115
|
+
target: object = getattr(module, attribute)
|
|
116
|
+
except (ImportError, AttributeError) as error:
|
|
117
|
+
raise ConfigurationError(f"cannot load {reference!r}: {error}") from error
|
|
118
|
+
if callable(target):
|
|
119
|
+
target = target()
|
|
120
|
+
if not isinstance(target, GoatbeatApp):
|
|
121
|
+
raise ConfigurationError(f"{reference!r} is not a GoatbeatApp")
|
|
122
|
+
return target
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _standalone_environment(
|
|
126
|
+
stack: ExitStack, app: GoatbeatApp, config: GoatbeatConfig
|
|
127
|
+
) -> Environment:
|
|
128
|
+
url = config.database_url
|
|
129
|
+
if url is None:
|
|
130
|
+
raise ConfigurationError(
|
|
131
|
+
f"set {DATABASE_VARIABLE} (postgresql://..., {SQLITE_PREFIX}path or memory://)"
|
|
132
|
+
)
|
|
133
|
+
scheme = urlsplit(url).scheme or (LIBPQ_KEYWORDS if "=" in url else "")
|
|
134
|
+
if scheme == "memory":
|
|
135
|
+
memory = MemoryStore(SystemClock())
|
|
136
|
+
return _environment(app, config, memory, memory)
|
|
137
|
+
if scheme == "sqlite":
|
|
138
|
+
sqlite = SQLiteStore(_sqlite_path(url), SystemClock())
|
|
139
|
+
stack.callback(sqlite.close)
|
|
140
|
+
return _environment(app, config, sqlite, sqlite)
|
|
141
|
+
if scheme in {"postgres", "postgresql", LIBPQ_KEYWORDS}:
|
|
142
|
+
return _postgres_environment(stack, app, config, url)
|
|
143
|
+
raise ConfigurationError(f"unsupported database URL scheme {scheme!r}")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _sqlite_path(url: str) -> str:
|
|
147
|
+
path = url.removeprefix(SQLITE_PREFIX)
|
|
148
|
+
if not url.startswith(SQLITE_PREFIX) or not path or urlsplit(url).netloc:
|
|
149
|
+
raise ConfigurationError(
|
|
150
|
+
f"SQLite URLs look like {SQLITE_PREFIX}relative.db or {SQLITE_PREFIX}/absolute.db, "
|
|
151
|
+
f"got {url!r}"
|
|
152
|
+
)
|
|
153
|
+
return path
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _environment(
|
|
157
|
+
app: GoatbeatApp, config: GoatbeatConfig, store: ScheduleStore, leases: LeaseManager
|
|
158
|
+
) -> Environment:
|
|
159
|
+
return Environment(
|
|
160
|
+
config=config,
|
|
161
|
+
store=store,
|
|
162
|
+
leases=leases,
|
|
163
|
+
registry=app.registry,
|
|
164
|
+
submitter=app.submitter,
|
|
165
|
+
metrics=app.metrics,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _postgres_environment(
|
|
170
|
+
stack: ExitStack, app: GoatbeatApp, config: GoatbeatConfig, url: str
|
|
171
|
+
) -> Environment:
|
|
172
|
+
try:
|
|
173
|
+
from goatbeat.postgres.notify import PsycopgNotifier # noqa: PLC0415
|
|
174
|
+
from goatbeat.postgres.provider import PsycopgProvider # noqa: PLC0415
|
|
175
|
+
from goatbeat.postgres.schema import ( # noqa: PLC0415
|
|
176
|
+
SCHEMA_VERSION,
|
|
177
|
+
applied_version,
|
|
178
|
+
database_clock,
|
|
179
|
+
migrate,
|
|
180
|
+
)
|
|
181
|
+
from goatbeat.postgres.store import PostgresStore # noqa: PLC0415
|
|
182
|
+
except ImportError as error:
|
|
183
|
+
raise ConfigurationError("PostgreSQL needs: pip install goatbeat[postgres]") from error
|
|
184
|
+
provider = stack.enter_context(PsycopgProvider.connect(url, wait=True))
|
|
185
|
+
notifier = stack.enter_context(PsycopgNotifier(url))
|
|
186
|
+
store = PostgresStore(provider, notifier=notifier)
|
|
187
|
+
|
|
188
|
+
def schema_problem() -> str | None:
|
|
189
|
+
version = applied_version(provider)
|
|
190
|
+
if version < SCHEMA_VERSION:
|
|
191
|
+
return f"schema at version {version}, expected {SCHEMA_VERSION}: run goatbeat migrate"
|
|
192
|
+
if version > SCHEMA_VERSION:
|
|
193
|
+
return f"schema at version {version} is newer than this release: upgrade goatbeat"
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
def ready() -> None:
|
|
197
|
+
problem = schema_problem()
|
|
198
|
+
if problem is not None:
|
|
199
|
+
raise ConfigurationError(problem)
|
|
200
|
+
|
|
201
|
+
def checks() -> Sequence[Check]:
|
|
202
|
+
problem = schema_problem()
|
|
203
|
+
drift = database_clock(provider) - SystemClock().now()
|
|
204
|
+
return (
|
|
205
|
+
Check("schema", problem is None, problem or f"at version {SCHEMA_VERSION}"),
|
|
206
|
+
Check(
|
|
207
|
+
"clock",
|
|
208
|
+
abs(drift) <= MAX_CLOCK_DRIFT,
|
|
209
|
+
f"local clock is {drift.total_seconds():+.3f} s from PostgreSQL",
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
def apply_migrations() -> str:
|
|
214
|
+
return f"schema at version {migrate(provider)}"
|
|
215
|
+
|
|
216
|
+
environment = _environment(app, config, store, store)
|
|
217
|
+
environment.backend_checks = checks
|
|
218
|
+
environment.migrate = apply_migrations
|
|
219
|
+
environment.ready = ready
|
|
220
|
+
return environment
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _django_environment(stack: ExitStack) -> Environment:
|
|
224
|
+
try:
|
|
225
|
+
import django # noqa: PLC0415
|
|
226
|
+
from django.core.exceptions import ImproperlyConfigured # noqa: PLC0415
|
|
227
|
+
|
|
228
|
+
from goatbeat.django import registry # noqa: PLC0415
|
|
229
|
+
from goatbeat.django.conf import DjangoSettings # noqa: PLC0415
|
|
230
|
+
from goatbeat.django.provider import DjangoProvider, notifier_conninfo # noqa: PLC0415
|
|
231
|
+
from goatbeat.django.submitter import DjangoTasksSubmitter, is_django_task # noqa: PLC0415
|
|
232
|
+
from goatbeat.postgres.notify import PsycopgNotifier # noqa: PLC0415
|
|
233
|
+
from goatbeat.postgres.store import PostgresStore # noqa: PLC0415
|
|
234
|
+
except ImportError as error:
|
|
235
|
+
raise ConfigurationError("Django projects need: pip install goatbeat[django]") from error
|
|
236
|
+
module = os.environ.get(DJANGO_VARIABLE, "")
|
|
237
|
+
try:
|
|
238
|
+
if find_spec(module) is None:
|
|
239
|
+
raise ImportError(f"no module named {module!r}")
|
|
240
|
+
django.setup()
|
|
241
|
+
except (ImportError, ImproperlyConfigured) as error:
|
|
242
|
+
raise ConfigurationError(f"cannot load the Django settings {module!r}: {error}") from error
|
|
243
|
+
settings = DjangoSettings.from_django()
|
|
244
|
+
notifier = stack.enter_context(PsycopgNotifier(notifier_conninfo(settings.database)))
|
|
245
|
+
store = PostgresStore(DjangoProvider(settings.database), notifier=notifier)
|
|
246
|
+
environment = Environment(
|
|
247
|
+
config=settings.config,
|
|
248
|
+
store=store,
|
|
249
|
+
leases=store,
|
|
250
|
+
registry=registry(),
|
|
251
|
+
submitter=DjangoTasksSubmitter(),
|
|
252
|
+
metrics=NoopMetrics(),
|
|
253
|
+
task_problem=lambda path: None if is_django_task(path) else f"{path}: not a Django task",
|
|
254
|
+
)
|
|
255
|
+
environment.migrate = lambda: "Django projects migrate with: python manage.py migrate"
|
|
256
|
+
return environment
|
goatbeat/cli/logs.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
from typing import Final, override
|
|
4
|
+
|
|
5
|
+
from goatbeat.core.observability import EXTRA_KEY, LOGGER_NAME, JsonFormatter
|
|
6
|
+
|
|
7
|
+
DATABASE_MODULES: Final = ("psycopg", "django.db")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TextFormatter(logging.Formatter):
|
|
11
|
+
@override
|
|
12
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
13
|
+
line = super().format(record)
|
|
14
|
+
fields: object = record.__dict__.get(EXTRA_KEY)
|
|
15
|
+
if isinstance(fields, dict):
|
|
16
|
+
extra = " ".join(
|
|
17
|
+
f"{key}={value}"
|
|
18
|
+
for key, value in fields.items() # pyright: ignore[reportUnknownVariableType]
|
|
19
|
+
if key != "event"
|
|
20
|
+
)
|
|
21
|
+
line = f"{line} {extra}" if extra else line
|
|
22
|
+
return line
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def configure_logging(level: str, *, as_json: bool) -> None:
|
|
26
|
+
logger = logging.getLogger(LOGGER_NAME)
|
|
27
|
+
for handler in list(logger.handlers):
|
|
28
|
+
if getattr(handler, "goatbeat_cli", False):
|
|
29
|
+
logger.removeHandler(handler)
|
|
30
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
31
|
+
handler.goatbeat_cli = True # type: ignore[attr-defined]
|
|
32
|
+
handler.setFormatter(
|
|
33
|
+
JsonFormatter() if as_json else TextFormatter("%(asctime)s %(levelname)s %(message)s")
|
|
34
|
+
)
|
|
35
|
+
logger.addHandler(handler)
|
|
36
|
+
logger.setLevel(level)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_database_unavailable(error: Exception) -> bool:
|
|
40
|
+
"""psycopg is optional here, so its connection errors are recognized without importing it."""
|
|
41
|
+
for kind in type(error).__mro__:
|
|
42
|
+
if (
|
|
43
|
+
kind.__module__.startswith(("psycopg", "django.db"))
|
|
44
|
+
and kind.__name__ == "OperationalError"
|
|
45
|
+
):
|
|
46
|
+
return True
|
|
47
|
+
return False
|
goatbeat/cli/main.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
from collections.abc import Callable, Sequence
|
|
4
|
+
from typing import Final
|
|
5
|
+
|
|
6
|
+
from goatbeat.cli import commands
|
|
7
|
+
from goatbeat.cli.environment import Environment, open_environment
|
|
8
|
+
from goatbeat.cli.logs import configure_logging, is_database_unavailable
|
|
9
|
+
from goatbeat.core.errors import ConfigurationError, StoreUnavailableError
|
|
10
|
+
|
|
11
|
+
CONFIGURATION_ERROR: Final = 3
|
|
12
|
+
STORE_UNAVAILABLE: Final = 4
|
|
13
|
+
UNEXPECTED_ERROR: Final = 5
|
|
14
|
+
LOG_LEVELS: Final = ("DEBUG", "INFO", "WARNING", "ERROR")
|
|
15
|
+
|
|
16
|
+
type Handler = Callable[[Environment, argparse.Namespace], int]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parser() -> argparse.ArgumentParser:
|
|
20
|
+
root = argparse.ArgumentParser(prog="goatbeat", description=__doc__)
|
|
21
|
+
root.add_argument("--app", help="module:attribute of a GoatbeatApp (or GOATBEAT_APP)")
|
|
22
|
+
root.add_argument("--log-level", choices=LOG_LEVELS, default="INFO")
|
|
23
|
+
root.add_argument("--log-format", choices=("text", "json"), default="text")
|
|
24
|
+
sub = root.add_subparsers(dest="command", required=True)
|
|
25
|
+
|
|
26
|
+
scheduler = sub.add_parser("scheduler", help="run the scheduler until SIGTERM")
|
|
27
|
+
scheduler.add_argument("--once", action="store_true", help="run one tick and exit")
|
|
28
|
+
scheduler.add_argument("--metrics-port", type=int, help="serve Prometheus metrics")
|
|
29
|
+
scheduler.set_defaults(handler=_scheduler)
|
|
30
|
+
|
|
31
|
+
status = sub.add_parser("status", help="show the leader and schedules")
|
|
32
|
+
status.add_argument("--json", action="store_true", help="machine-readable output")
|
|
33
|
+
status.set_defaults(handler=_status)
|
|
34
|
+
|
|
35
|
+
doctor = sub.add_parser("doctor", help="check the setup")
|
|
36
|
+
doctor.add_argument("--json", action="store_true", help="machine-readable output")
|
|
37
|
+
doctor.set_defaults(handler=_doctor)
|
|
38
|
+
|
|
39
|
+
migrate = sub.add_parser("migrate", help="create or upgrade the database schema")
|
|
40
|
+
migrate.set_defaults(handler=_migrate)
|
|
41
|
+
|
|
42
|
+
schedule = sub.add_parser("schedule", help="inspect and manage schedules")
|
|
43
|
+
actions = schedule.add_subparsers(dest="action", required=True)
|
|
44
|
+
listing = actions.add_parser("list", help="publish code schedules and list them")
|
|
45
|
+
listing.add_argument("--json", action="store_true", help="machine-readable output")
|
|
46
|
+
listing.set_defaults(handler=_list)
|
|
47
|
+
run = actions.add_parser("run", help="ask the leader to run a schedule now")
|
|
48
|
+
run.add_argument("name")
|
|
49
|
+
run.set_defaults(handler=_run)
|
|
50
|
+
prune = actions.add_parser("prune", help="disable code schedules no longer declared")
|
|
51
|
+
prune.set_defaults(handler=_prune)
|
|
52
|
+
return root
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
56
|
+
arguments = parser().parse_args(argv)
|
|
57
|
+
handler: Handler = arguments.handler
|
|
58
|
+
configure_logging(arguments.log_level, as_json=arguments.log_format == "json")
|
|
59
|
+
try:
|
|
60
|
+
with open_environment(arguments.app) as environment:
|
|
61
|
+
return handler(environment, arguments)
|
|
62
|
+
except ConfigurationError as error:
|
|
63
|
+
sys.stderr.write(f"goatbeat: configuration error: {error}\n")
|
|
64
|
+
return CONFIGURATION_ERROR
|
|
65
|
+
except StoreUnavailableError as error:
|
|
66
|
+
sys.stderr.write(f"goatbeat: store unavailable: {error}\n")
|
|
67
|
+
return STORE_UNAVAILABLE
|
|
68
|
+
except commands.CommandFailedError as error:
|
|
69
|
+
sys.stderr.write(f"goatbeat: {error}\n")
|
|
70
|
+
return commands.CHECK_FAILED
|
|
71
|
+
except Exception as error:
|
|
72
|
+
if is_database_unavailable(error):
|
|
73
|
+
sys.stderr.write(f"goatbeat: store unavailable: {error}\n")
|
|
74
|
+
return STORE_UNAVAILABLE
|
|
75
|
+
sys.stderr.write(f"goatbeat: unexpected error: {type(error).__name__}: {error}\n")
|
|
76
|
+
return UNEXPECTED_ERROR
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _scheduler(environment: Environment, arguments: argparse.Namespace) -> int:
|
|
80
|
+
return commands.run_scheduler(
|
|
81
|
+
environment, once=arguments.once, metrics_port=arguments.metrics_port
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _status(environment: Environment, arguments: argparse.Namespace) -> int:
|
|
86
|
+
return commands.status(environment, as_json=bool(arguments.json))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _doctor(environment: Environment, arguments: argparse.Namespace) -> int:
|
|
90
|
+
return commands.doctor(environment, as_json=bool(arguments.json))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _migrate(environment: Environment, arguments: argparse.Namespace) -> int:
|
|
94
|
+
del arguments
|
|
95
|
+
return commands.migrate(environment)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _list(environment: Environment, arguments: argparse.Namespace) -> int:
|
|
99
|
+
return commands.list_schedules(environment, as_json=bool(arguments.json))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _run(environment: Environment, arguments: argparse.Namespace) -> int:
|
|
103
|
+
return commands.run_now(environment, str(arguments.name))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _prune(environment: Environment, arguments: argparse.Namespace) -> int:
|
|
107
|
+
del arguments
|
|
108
|
+
return commands.prune(environment)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
raise SystemExit(main())
|
goatbeat/cli/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: goatbeat-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The goatbeat command line.
|
|
5
|
+
Keywords: scheduler,cron,periodic-tasks,distributed,django-tasks,leader-election
|
|
6
|
+
Author: Louis Midson LAJEANTY
|
|
7
|
+
Author-email: Louis Midson LAJEANTY <midsonlajeanty@proton.me>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Dist: goatbeat-core==0.1.0
|
|
18
|
+
Requires-Python: >=3.13
|
|
19
|
+
Project-URL: Homepage, https://github.com/goatlabshq/goatbeat
|
|
20
|
+
Project-URL: Documentation, https://goatlabshq.github.io/goatbeat/
|
|
21
|
+
Project-URL: Issues, https://github.com/goatlabshq/goatbeat/issues
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# goatbeat-cli
|
|
25
|
+
|
|
26
|
+
The `goatbeat` command line: run the scheduler, inspect status and manage schedules. Part of [Goatbeat](https://github.com/goatlabshq/goatbeat).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
goatbeat/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
goatbeat/cli/commands.py,sha256=fGC8pSVtpgxEpGijn6_Qofu272mrQ2-sk5Qoi5mrt_Q,6110
|
|
3
|
+
goatbeat/cli/environment.py,sha256=BtMysRc5ZMUU40zHkHvz1Yq7d_fdRLOG2kwmQHmRD1E,9763
|
|
4
|
+
goatbeat/cli/logs.py,sha256=5J2JnDp11Zql3iKv003ZHcAM4qA-xuRy__9KFNhcYlk,1629
|
|
5
|
+
goatbeat/cli/main.py,sha256=NA4XCq4NNVGOD9jKAadoC1E3HWG4TiQolRQpHhnp2Uc,4663
|
|
6
|
+
goatbeat/cli/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
goatbeat_cli-0.1.0.dist-info/licenses/LICENSE,sha256=Zcs5rp01UDASfBq28ErEU7tdk14P5WTR3zNmlaZKnkA,1105
|
|
8
|
+
goatbeat_cli-0.1.0.dist-info/WHEEL,sha256=oBPtwmRtX9fzUVIGqTf2NA4uW8MUj4N1HrmGwtPkDyA,80
|
|
9
|
+
goatbeat_cli-0.1.0.dist-info/entry_points.txt,sha256=8aiWREFaiqegZ_H_imAz5IzEvf0DUjdbyyHnWDpvG5c,53
|
|
10
|
+
goatbeat_cli-0.1.0.dist-info/METADATA,sha256=QNkLohgobgT__50dU07eXn90bH-_Nw2oUkocp7ClsfA,1089
|
|
11
|
+
goatbeat_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Louis Midson LAJEANTY <midsonlajeanty@proton.me>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|