percival-server 0.1.0__tar.gz

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.
Files changed (117) hide show
  1. percival_server-0.1.0/.gitignore +30 -0
  2. percival_server-0.1.0/PKG-INFO +82 -0
  3. percival_server-0.1.0/README.md +46 -0
  4. percival_server-0.1.0/percival_server/__init__.py +1 -0
  5. percival_server-0.1.0/percival_server/__main__.py +32 -0
  6. percival_server-0.1.0/percival_server/api/__init__.py +0 -0
  7. percival_server-0.1.0/percival_server/api/accounts.py +228 -0
  8. percival_server-0.1.0/percival_server/api/auth.py +84 -0
  9. percival_server-0.1.0/percival_server/api/cluster.py +175 -0
  10. percival_server-0.1.0/percival_server/api/history.py +146 -0
  11. percival_server-0.1.0/percival_server/api/schedules.py +407 -0
  12. percival_server-0.1.0/percival_server/api/setup.py +116 -0
  13. percival_server-0.1.0/percival_server/audit.py +33 -0
  14. percival_server-0.1.0/percival_server/auth/__init__.py +0 -0
  15. percival_server-0.1.0/percival_server/auth/deps.py +126 -0
  16. percival_server-0.1.0/percival_server/auth/ratelimit.py +40 -0
  17. percival_server-0.1.0/percival_server/auth/security.py +58 -0
  18. percival_server-0.1.0/percival_server/auth/sessions.py +47 -0
  19. percival_server-0.1.0/percival_server/bootstrap.py +55 -0
  20. percival_server-0.1.0/percival_server/config.py +167 -0
  21. percival_server-0.1.0/percival_server/connections.py +208 -0
  22. percival_server-0.1.0/percival_server/consumer.py +201 -0
  23. percival_server-0.1.0/percival_server/crypto.py +33 -0
  24. percival_server-0.1.0/percival_server/db.py +58 -0
  25. percival_server-0.1.0/percival_server/live.py +150 -0
  26. percival_server-0.1.0/percival_server/logs.py +41 -0
  27. percival_server-0.1.0/percival_server/main.py +93 -0
  28. percival_server-0.1.0/percival_server/migrations/env.py +34 -0
  29. percival_server-0.1.0/percival_server/migrations/script.py.mako +27 -0
  30. percival_server-0.1.0/percival_server/migrations/versions/0001_initial_schema.py +174 -0
  31. percival_server-0.1.0/percival_server/models.py +173 -0
  32. percival_server-0.1.0/percival_server/schedules/__init__.py +0 -0
  33. percival_server-0.1.0/percival_server/schedules/spec.py +115 -0
  34. percival_server-0.1.0/percival_server/schedules/store.py +177 -0
  35. percival_server-0.1.0/percival_server/spa.py +38 -0
  36. percival_server-0.1.0/percival_server/static/assets/ibm-plex-mono-cyrillic-400-normal-CJUKFyLa.woff +0 -0
  37. percival_server-0.1.0/percival_server/static/assets/ibm-plex-mono-cyrillic-400-normal-DvQQgHTq.woff2 +0 -0
  38. percival_server-0.1.0/percival_server/static/assets/ibm-plex-mono-cyrillic-500-normal-BtGHVI3Q.woff2 +0 -0
  39. percival_server-0.1.0/percival_server/static/assets/ibm-plex-mono-cyrillic-500-normal-N1TV9ACq.woff +0 -0
  40. percival_server-0.1.0/percival_server/static/assets/ibm-plex-mono-cyrillic-600-normal-C8N-B5AP.woff +0 -0
  41. percival_server-0.1.0/percival_server/static/assets/ibm-plex-mono-cyrillic-600-normal-DqTcxLwU.woff2 +0 -0
  42. percival_server-0.1.0/percival_server/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-B3hZxag_.woff2 +0 -0
  43. percival_server-0.1.0/percival_server/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-ZyMXPjaT.woff +0 -0
  44. percival_server-0.1.0/percival_server/static/assets/ibm-plex-mono-cyrillic-ext-500-normal-CmcxpzmF.woff2 +0 -0
  45. percival_server-0.1.0/percival_server/static/assets/ibm-plex-mono-cyrillic-ext-500-normal-yqk7_qG0.woff +0 -0
  46. percival_server-0.1.0/percival_server/static/assets/ibm-plex-mono-cyrillic-ext-600-normal-6-AWF9yE.woff2 +0 -0
  47. percival_server-0.1.0/percival_server/static/assets/ibm-plex-mono-cyrillic-ext-600-normal-BZFL66GJ.woff +0 -0
  48. percival_server-0.1.0/percival_server/static/assets/ibm-plex-mono-latin-400-normal-Dm_PoFIZ.woff2 +0 -0
  49. percival_server-0.1.0/percival_server/static/assets/ibm-plex-mono-latin-400-normal-O6-GRVqx.woff +0 -0
  50. percival_server-0.1.0/percival_server/static/assets/ibm-plex-mono-latin-500-normal-C_OblDzq.woff2 +0 -0
  51. percival_server-0.1.0/percival_server/static/assets/ibm-plex-mono-latin-500-normal-zoSM89R3.woff +0 -0
  52. percival_server-0.1.0/percival_server/static/assets/ibm-plex-mono-latin-600-normal-DeXYNBV7.woff +0 -0
  53. percival_server-0.1.0/percival_server/static/assets/ibm-plex-mono-latin-600-normal-rgB1DpUr.woff2 +0 -0
  54. percival_server-0.1.0/percival_server/static/assets/ibm-plex-mono-latin-ext-400-normal-DxDDqVQb.woff2 +0 -0
  55. percival_server-0.1.0/percival_server/static/assets/ibm-plex-mono-latin-ext-400-normal-uwM7KshN.woff +0 -0
  56. percival_server-0.1.0/percival_server/static/assets/ibm-plex-mono-latin-ext-500-normal-BhDX_RLI.woff +0 -0
  57. percival_server-0.1.0/percival_server/static/assets/ibm-plex-mono-latin-ext-500-normal-Dn-cqWhb.woff2 +0 -0
  58. percival_server-0.1.0/percival_server/static/assets/ibm-plex-mono-latin-ext-600-normal-5raxGPWx.woff2 +0 -0
  59. percival_server-0.1.0/percival_server/static/assets/ibm-plex-mono-latin-ext-600-normal-C5zKQMXr.woff +0 -0
  60. percival_server-0.1.0/percival_server/static/assets/ibm-plex-mono-vietnamese-400-normal-BFiUPp6R.woff2 +0 -0
  61. percival_server-0.1.0/percival_server/static/assets/ibm-plex-mono-vietnamese-400-normal-DLynVohM.woff +0 -0
  62. percival_server-0.1.0/percival_server/static/assets/ibm-plex-mono-vietnamese-500-normal-CevHOlzG.woff2 +0 -0
  63. percival_server-0.1.0/percival_server/static/assets/ibm-plex-mono-vietnamese-500-normal-QhwQXayn.woff +0 -0
  64. percival_server-0.1.0/percival_server/static/assets/ibm-plex-mono-vietnamese-600-normal-BifMsNUA.woff +0 -0
  65. percival_server-0.1.0/percival_server/static/assets/ibm-plex-mono-vietnamese-600-normal-D7hk9wLv.woff2 +0 -0
  66. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-cyrillic-400-normal-BPWuI_CM.woff2 +0 -0
  67. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-cyrillic-400-normal-CTUnhTqV.woff +0 -0
  68. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-cyrillic-500-normal-BEypoZQd.woff +0 -0
  69. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-cyrillic-500-normal-D1zm99-P.woff2 +0 -0
  70. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-cyrillic-600-normal-BuRiwhxF.woff +0 -0
  71. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-cyrillic-600-normal-DM9A4i1K.woff2 +0 -0
  72. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-cyrillic-ext-400-normal-BJItruJi.woff2 +0 -0
  73. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-cyrillic-ext-400-normal-C1SKuNhx.woff +0 -0
  74. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-cyrillic-ext-500-normal-Cfrm0EKX.woff2 +0 -0
  75. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-cyrillic-ext-500-normal-DujcQJCN.woff +0 -0
  76. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-cyrillic-ext-600-normal-CJ5MtzOJ.woff +0 -0
  77. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-cyrillic-ext-600-normal-jBHiQjEG.woff2 +0 -0
  78. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-greek-400-normal-DSasf_jt.woff +0 -0
  79. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-greek-400-normal-n6oPB5VF.woff2 +0 -0
  80. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-greek-500-normal-BwBTeJfm.woff +0 -0
  81. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-greek-500-normal-By04ZBDJ.woff2 +0 -0
  82. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-greek-600-normal-BJUfoJTU.woff +0 -0
  83. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-greek-600-normal-FZrtz_q-.woff2 +0 -0
  84. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-latin-400-normal-BU83cd9M.woff +0 -0
  85. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-latin-400-normal-CdZtFfYS.woff2 +0 -0
  86. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-latin-500-normal-BFLYOLbL.woff2 +0 -0
  87. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-latin-500-normal-Dh0NORqx.woff +0 -0
  88. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-latin-600-normal-BGOKnPO6.woff2 +0 -0
  89. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-latin-600-normal-ZykYOGKL.woff +0 -0
  90. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-latin-ext-400-normal-BHf956ki.woff2 +0 -0
  91. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-latin-ext-400-normal-CyDjbWPV.woff +0 -0
  92. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-latin-ext-500-normal-CgrnmiIU.woff +0 -0
  93. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-latin-ext-500-normal-DprGrCew.woff2 +0 -0
  94. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-latin-ext-600-normal-bp4QDljZ.woff +0 -0
  95. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-latin-ext-600-normal-dAZVOL-H.woff2 +0 -0
  96. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-vietnamese-400-normal-B97dYap6.woff2 +0 -0
  97. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-vietnamese-400-normal-OKGpH0gQ.woff +0 -0
  98. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-vietnamese-500-normal-V_-bWxzT.woff +0 -0
  99. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-vietnamese-500-normal-rpgY82Kd.woff2 +0 -0
  100. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-vietnamese-600-normal-D03elqKW.woff +0 -0
  101. percival_server-0.1.0/percival_server/static/assets/ibm-plex-sans-vietnamese-600-normal-DHNi5J5O.woff2 +0 -0
  102. percival_server-0.1.0/percival_server/static/assets/index-CFnV8aEP.css +1 -0
  103. percival_server-0.1.0/percival_server/static/assets/index-x7gmNg_p.js +70 -0
  104. percival_server-0.1.0/percival_server/static/index.html +15 -0
  105. percival_server-0.1.0/pyproject.toml +85 -0
  106. percival_server-0.1.0/tests/__init__.py +0 -0
  107. percival_server-0.1.0/tests/conftest.py +59 -0
  108. percival_server-0.1.0/tests/test_api.py +344 -0
  109. percival_server-0.1.0/tests/test_auth_api.py +130 -0
  110. percival_server-0.1.0/tests/test_bootstrap.py +94 -0
  111. percival_server-0.1.0/tests/test_config.py +85 -0
  112. percival_server-0.1.0/tests/test_connections.py +27 -0
  113. percival_server-0.1.0/tests/test_consumer.py +150 -0
  114. percival_server-0.1.0/tests/test_db.py +38 -0
  115. percival_server-0.1.0/tests/test_security.py +54 -0
  116. percival_server-0.1.0/tests/test_spa.py +30 -0
  117. percival_server-0.1.0/tests/test_store.py +132 -0
@@ -0,0 +1,30 @@
1
+ # python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ .venv/
9
+ venv/
10
+ .pytest_cache/
11
+ .mypy_cache/
12
+ .ruff_cache/
13
+ .coverage
14
+ htmlcov/
15
+
16
+ # node
17
+ node_modules/
18
+ web/dist/
19
+ *.tsbuildinfo
20
+
21
+ # local stuff
22
+ .env
23
+ *.sqlite3
24
+ *.db
25
+ .idea/
26
+ .vscode/
27
+ .DS_Store
28
+
29
+ # built ui, produced by `npm run build` in web/ (and by the docker build)
30
+ server/percival_server/static/
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.5
2
+ Name: percival-server
3
+ Version: 0.1.0
4
+ Summary: Self-hosted control panel for Celery: live schedules, run history, stale-worker safety.
5
+ Project-URL: Homepage, https://github.com/RijinRaju/percival
6
+ Project-URL: Source, https://github.com/RijinRaju/percival
7
+ Project-URL: Issues, https://github.com/RijinRaju/percival/issues
8
+ Author: Rijin Raju
9
+ License-Expression: MIT
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: Celery
12
+ Classifier: Framework :: FastAPI
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: alembic>=1.13
20
+ Requires-Dist: argon2-cffi>=23.1
21
+ Requires-Dist: celery-redbeat>=2.0
22
+ Requires-Dist: celery[redis]>=5.3
23
+ Requires-Dist: cryptography>=42
24
+ Requires-Dist: fastapi>=0.110
25
+ Requires-Dist: percival>=0.1.0
26
+ Requires-Dist: redis>=4.5
27
+ Requires-Dist: sqlalchemy>=2.0
28
+ Requires-Dist: uvicorn[standard]>=0.29
29
+ Provides-Extra: dev
30
+ Requires-Dist: fakeredis>=2.20; extra == 'dev'
31
+ Requires-Dist: httpx>=0.27; extra == 'dev'
32
+ Requires-Dist: mypy>=1.10; extra == 'dev'
33
+ Requires-Dist: pytest>=8; extra == 'dev'
34
+ Requires-Dist: ruff>=0.5; extra == 'dev'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # percival-server
38
+
39
+ The panel for [Percival](https://github.com/RijinRaju/percival), a
40
+ self-hosted control panel for Celery. It provides the web UI and API for
41
+ managing RedBeat schedules, run history, worker versions and the audit log.
42
+
43
+ Your Celery project needs the [`percival`](https://pypi.org/project/percival/)
44
+ client too.
45
+
46
+ ## Run it
47
+
48
+ The easiest way is the Docker image, which has the UI built in:
49
+
50
+ ```
51
+ docker run -d -p 8080:8080 -v percival:/data \
52
+ -e PERCIVAL_REDIS_URL=redis://redis:6379/0 \
53
+ -e PERCIVAL_ADMIN_PASSWORD=pick-a-long-one \
54
+ ghcr.io/rijinraju/percival
55
+ ```
56
+
57
+ Or from PyPI:
58
+
59
+ ```
60
+ pip install percival-server
61
+ PERCIVAL_REDIS_URL=redis://localhost:6379/0 PERCIVAL_ADMIN_PASSWORD=pick-a-long-one percival-server
62
+ ```
63
+
64
+ Then open http://localhost:8080 and log in as `admin`.
65
+
66
+ | env | default | |
67
+ |---|---|---|
68
+ | `PERCIVAL_REDIS_URL` | none | where RedBeat and the client keep their keys |
69
+ | `PERCIVAL_BROKER_URL` | the Redis URL | set it when the broker isn't Redis (`CELERY_BROKER_URL` works too) |
70
+ | `PERCIVAL_ADMIN_USERNAME` / `PERCIVAL_ADMIN_PASSWORD` | `admin` / none | creates the first admin if nobody exists yet |
71
+ | `PERCIVAL_SECRET_KEY` | generated into the data dir | |
72
+ | `PERCIVAL_DATA_DIR` | `./data` | SQLite database and the generated key |
73
+ | `PERCIVAL_DATABASE_URL` | SQLite in the data dir | Postgres works too |
74
+ | `PERCIVAL_HOST` / `PERCIVAL_PORT` | `127.0.0.1` / `8080` | |
75
+ | `PERCIVAL_SECURE_COOKIES` | on | turn it off only for plain http on localhost |
76
+
77
+ Every setting is optional. If you leave out the URLs or the admin, the first
78
+ visit asks for them.
79
+
80
+ Put the panel behind a reverse proxy with TLS if anyone besides you will use
81
+ it. The [full README](https://github.com/RijinRaju/percival#readme) has the
82
+ rest.
@@ -0,0 +1,46 @@
1
+ # percival-server
2
+
3
+ The panel for [Percival](https://github.com/RijinRaju/percival), a
4
+ self-hosted control panel for Celery. It provides the web UI and API for
5
+ managing RedBeat schedules, run history, worker versions and the audit log.
6
+
7
+ Your Celery project needs the [`percival`](https://pypi.org/project/percival/)
8
+ client too.
9
+
10
+ ## Run it
11
+
12
+ The easiest way is the Docker image, which has the UI built in:
13
+
14
+ ```
15
+ docker run -d -p 8080:8080 -v percival:/data \
16
+ -e PERCIVAL_REDIS_URL=redis://redis:6379/0 \
17
+ -e PERCIVAL_ADMIN_PASSWORD=pick-a-long-one \
18
+ ghcr.io/rijinraju/percival
19
+ ```
20
+
21
+ Or from PyPI:
22
+
23
+ ```
24
+ pip install percival-server
25
+ PERCIVAL_REDIS_URL=redis://localhost:6379/0 PERCIVAL_ADMIN_PASSWORD=pick-a-long-one percival-server
26
+ ```
27
+
28
+ Then open http://localhost:8080 and log in as `admin`.
29
+
30
+ | env | default | |
31
+ |---|---|---|
32
+ | `PERCIVAL_REDIS_URL` | none | where RedBeat and the client keep their keys |
33
+ | `PERCIVAL_BROKER_URL` | the Redis URL | set it when the broker isn't Redis (`CELERY_BROKER_URL` works too) |
34
+ | `PERCIVAL_ADMIN_USERNAME` / `PERCIVAL_ADMIN_PASSWORD` | `admin` / none | creates the first admin if nobody exists yet |
35
+ | `PERCIVAL_SECRET_KEY` | generated into the data dir | |
36
+ | `PERCIVAL_DATA_DIR` | `./data` | SQLite database and the generated key |
37
+ | `PERCIVAL_DATABASE_URL` | SQLite in the data dir | Postgres works too |
38
+ | `PERCIVAL_HOST` / `PERCIVAL_PORT` | `127.0.0.1` / `8080` | |
39
+ | `PERCIVAL_SECURE_COOKIES` | on | turn it off only for plain http on localhost |
40
+
41
+ Every setting is optional. If you leave out the URLs or the admin, the first
42
+ visit asks for them.
43
+
44
+ Put the panel behind a reverse proxy with TLS if anyone besides you will use
45
+ it. The [full README](https://github.com/RijinRaju/percival#readme) has the
46
+ rest.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+
6
+ import uvicorn
7
+
8
+ from percival.naming import ENV_PREFIX
9
+ from percival_server import logs
10
+ from percival_server.config import ConfigError, ServerConfig
11
+ from percival_server.main import create_app
12
+
13
+
14
+ def main() -> None:
15
+ logs.setup(os.environ.get(ENV_PREFIX + "LOG_LEVEL", "INFO"))
16
+ try:
17
+ app = create_app(ServerConfig.from_env())
18
+ except ConfigError as exc:
19
+ print(f"percival-server: {exc}", file=sys.stderr)
20
+ sys.exit(2)
21
+
22
+ uvicorn.run(
23
+ app,
24
+ host=os.environ.get(ENV_PREFIX + "HOST", "127.0.0.1"),
25
+ port=int(os.environ.get(ENV_PREFIX + "PORT", "8080")),
26
+ log_config=None,
27
+ proxy_headers=True,
28
+ )
29
+
30
+
31
+ if __name__ == "__main__":
32
+ main()
File without changes
@@ -0,0 +1,228 @@
1
+ """Users, API tokens and connection settings. Logged-in users only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException, Request, status
8
+ from pydantic import BaseModel, Field
9
+ from sqlalchemy import delete, func, select
10
+ from sqlalchemy.orm import Session
11
+
12
+ from percival_server import audit
13
+ from percival_server.auth.deps import ALL_SCOPES, Principal, require_user
14
+ from percival_server.auth.security import (
15
+ TOKEN_PREFIX,
16
+ hash_password,
17
+ hash_token,
18
+ new_api_token,
19
+ password_problem,
20
+ verify_password,
21
+ )
22
+ from percival_server.connections import ENV_FIELDS, ConnectionSettings
23
+ from percival_server.db import get_db
24
+ from percival_server.models import ApiToken, User
25
+ from percival_server.models import Session as UserSession
26
+
27
+ router = APIRouter(tags=["accounts"])
28
+
29
+
30
+ # users
31
+
32
+
33
+ class UserOut(BaseModel):
34
+ id: int
35
+ username: str
36
+ created_at: datetime
37
+ last_login_at: datetime | None
38
+
39
+
40
+ class NewUser(BaseModel):
41
+ username: str = Field(min_length=1, max_length=150)
42
+ password: str
43
+
44
+
45
+ class PasswordChange(BaseModel):
46
+ current_password: str
47
+ new_password: str
48
+
49
+
50
+ def _check_password(password: str) -> None:
51
+ problem = password_problem(password)
52
+ if problem:
53
+ raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, problem)
54
+
55
+
56
+ @router.get("/users", response_model=list[UserOut])
57
+ def list_users(db: Session = Depends(get_db), _: Principal = Depends(require_user)) -> list[User]:
58
+ return list(db.scalars(select(User).order_by(User.username)))
59
+
60
+
61
+ @router.post("/users", response_model=UserOut, status_code=status.HTTP_201_CREATED)
62
+ def create_user(
63
+ body: NewUser, db: Session = Depends(get_db), who: Principal = Depends(require_user)
64
+ ) -> User:
65
+ _check_password(body.password)
66
+ username = body.username.strip()
67
+ if db.scalar(select(User).where(User.username == username)):
68
+ raise HTTPException(status.HTTP_409_CONFLICT, "that username is taken")
69
+ user = User(username=username, password_hash=hash_password(body.password))
70
+ db.add(user)
71
+ audit.record(db, who, "user.create", username)
72
+ db.commit()
73
+ return user
74
+
75
+
76
+ @router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
77
+ def delete_user(
78
+ user_id: int, db: Session = Depends(get_db), who: Principal = Depends(require_user)
79
+ ) -> None:
80
+ user = db.get(User, user_id)
81
+ if user is None:
82
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "no such user")
83
+ if user.id == who.user_id:
84
+ raise HTTPException(status.HTTP_409_CONFLICT, "you can't delete yourself")
85
+ if (db.scalar(select(func.count()).select_from(User)) or 0) <= 1:
86
+ raise HTTPException(status.HTTP_409_CONFLICT, "can't delete the last user")
87
+ audit.record(db, who, "user.delete", user.username)
88
+ db.delete(user)
89
+ db.commit()
90
+
91
+
92
+ @router.post("/users/me/password", status_code=status.HTTP_204_NO_CONTENT)
93
+ def change_password(
94
+ body: PasswordChange, db: Session = Depends(get_db), who: Principal = Depends(require_user)
95
+ ) -> None:
96
+ user = db.get(User, who.user_id)
97
+ if user is None or not verify_password(user.password_hash, body.current_password):
98
+ raise HTTPException(status.HTTP_403_FORBIDDEN, "current password is wrong")
99
+ _check_password(body.new_password)
100
+ user.password_hash = hash_password(body.new_password)
101
+ # log out everywhere else
102
+ db.execute(delete(UserSession).where(UserSession.user_id == user.id))
103
+ audit.record(db, who, "user.password", user.username)
104
+ db.commit()
105
+
106
+
107
+ # tokens
108
+
109
+
110
+ class TokenOut(BaseModel):
111
+ id: int
112
+ name: str
113
+ prefix: str
114
+ scopes: list[str]
115
+ created_at: datetime
116
+ last_used_at: datetime | None
117
+ revoked_at: datetime | None
118
+
119
+
120
+ class NewToken(BaseModel):
121
+ name: str = Field(min_length=1, max_length=100)
122
+ scopes: list[str] = Field(default_factory=lambda: ["read"])
123
+
124
+
125
+ class CreatedToken(TokenOut):
126
+ token: str
127
+
128
+
129
+ def _token_out(t: ApiToken) -> dict[str, object]:
130
+ return {
131
+ "id": t.id,
132
+ "name": t.name,
133
+ "prefix": t.prefix,
134
+ "scopes": sorted(t.scope_set),
135
+ "created_at": t.created_at,
136
+ "last_used_at": t.last_used_at,
137
+ "revoked_at": t.revoked_at,
138
+ }
139
+
140
+
141
+ @router.get("/tokens", response_model=list[TokenOut])
142
+ def list_tokens(
143
+ db: Session = Depends(get_db), _: Principal = Depends(require_user)
144
+ ) -> list[dict[str, object]]:
145
+ rows = db.scalars(select(ApiToken).order_by(ApiToken.created_at.desc()))
146
+ return [_token_out(t) for t in rows]
147
+
148
+
149
+ @router.post("/tokens", response_model=CreatedToken, status_code=status.HTTP_201_CREATED)
150
+ def create_token(
151
+ body: NewToken, db: Session = Depends(get_db), who: Principal = Depends(require_user)
152
+ ) -> dict[str, object]:
153
+ scopes = set(body.scopes)
154
+ if not scopes or not scopes <= set(ALL_SCOPES):
155
+ raise HTTPException(
156
+ status.HTTP_422_UNPROCESSABLE_ENTITY, "scopes must be read and/or write"
157
+ )
158
+ if "write" in scopes:
159
+ scopes.add("read")
160
+
161
+ raw = new_api_token()
162
+ token = ApiToken(
163
+ name=body.name.strip(),
164
+ prefix=raw[: len(TOKEN_PREFIX) + 6],
165
+ token_hash=hash_token(raw),
166
+ scopes=",".join(sorted(scopes)),
167
+ created_by_id=who.user_id,
168
+ )
169
+ db.add(token)
170
+ audit.record(db, who, "token.create", token.name, after={"scopes": sorted(scopes)})
171
+ db.commit()
172
+ # the only time the full token is ever returned
173
+ return {**_token_out(token), "token": raw}
174
+
175
+
176
+ @router.delete("/tokens/{token_id}", status_code=status.HTTP_204_NO_CONTENT)
177
+ def revoke_token(
178
+ token_id: int, db: Session = Depends(get_db), who: Principal = Depends(require_user)
179
+ ) -> None:
180
+ token = db.get(ApiToken, token_id)
181
+ if token is None:
182
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "no such token")
183
+ if token.revoked_at is None:
184
+ token.revoked_at = datetime.now(timezone.utc)
185
+ audit.record(db, who, "token.revoke", token.name)
186
+ db.commit()
187
+
188
+
189
+ # connection settings
190
+
191
+
192
+ class ConnectionsIn(BaseModel):
193
+ # leave a url out to keep the stored one, so the masked value never has
194
+ # to round-trip through the browser
195
+ broker_url: str | None = None
196
+ redis_url: str | None = None
197
+ redbeat_key_prefix: str | None = None
198
+ key_prefix: str | None = None
199
+ retention_days: int | None = Field(default=None, ge=1, le=3650)
200
+
201
+
202
+ @router.get("/settings/connections")
203
+ def get_connections(request: Request, _: Principal = Depends(require_user)) -> dict[str, object]:
204
+ conns = request.app.state.connections
205
+ return {**conns.settings.masked(), "from_env": conns.from_env}
206
+
207
+
208
+ @router.put("/settings/connections")
209
+ def update_connections(
210
+ body: ConnectionsIn,
211
+ request: Request,
212
+ db: Session = Depends(get_db),
213
+ who: Principal = Depends(require_user),
214
+ ) -> dict[str, object]:
215
+ conns = request.app.state.connections
216
+ current: ConnectionSettings = conns.settings
217
+ changes = body.model_dump(exclude_none=True)
218
+ locked = sorted(set(changes) & ENV_FIELDS) if conns.from_env else []
219
+ if locked:
220
+ raise HTTPException(
221
+ status.HTTP_409_CONFLICT,
222
+ f"{', '.join(locked)} come from environment variables, change them there",
223
+ )
224
+ updated = ConnectionSettings(**{**current.__dict__, **changes})
225
+ conns.save(db, updated)
226
+ audit.record(db, who, "settings.update", before=current.masked(), after=updated.masked())
227
+ db.commit()
228
+ return {**updated.masked(), "from_env": conns.from_env}
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
4
+ from pydantic import BaseModel
5
+ from sqlalchemy import select
6
+ from sqlalchemy.orm import Session
7
+
8
+ from percival_server.auth.deps import Principal, current_principal
9
+ from percival_server.auth.ratelimit import FailureLimiter
10
+ from percival_server.auth.security import hash_password, needs_rehash, verify_password
11
+ from percival_server.auth.sessions import end_session, start_session
12
+ from percival_server.db import get_db
13
+ from percival_server.models import User
14
+
15
+ router = APIRouter(prefix="/auth", tags=["auth"])
16
+
17
+
18
+ class LoginLimits:
19
+ def __init__(self) -> None:
20
+ self.by_ip = FailureLimiter(max_failures=20, window_seconds=15 * 60)
21
+ self.by_username = FailureLimiter(max_failures=5, window_seconds=15 * 60)
22
+
23
+
24
+ # checked against when the username doesn't exist, so a wrong username takes
25
+ # as long as a wrong password and doesn't reveal which accounts exist
26
+ _DUMMY_HASH = hash_password("not-a-real-password-just-burning-time")
27
+
28
+
29
+ class LoginRequest(BaseModel):
30
+ username: str
31
+ password: str
32
+
33
+
34
+ class Me(BaseModel):
35
+ username: str
36
+ kind: str
37
+ csrf_token: str | None
38
+ scopes: list[str]
39
+
40
+
41
+ def _client_ip(request: Request) -> str:
42
+ return request.client.host if request.client else "unknown"
43
+
44
+
45
+ @router.post("/login", response_model=Me)
46
+ def login(
47
+ body: LoginRequest, request: Request, response: Response, db: Session = Depends(get_db)
48
+ ) -> Me:
49
+ limits: LoginLimits = request.app.state.login_limits
50
+ ip, name = _client_ip(request), body.username.strip().lower()
51
+ wait = max(limits.by_ip.retry_after(ip), limits.by_username.retry_after(name))
52
+ if wait:
53
+ raise HTTPException(
54
+ status.HTTP_429_TOO_MANY_REQUESTS,
55
+ "too many failed logins, try again later",
56
+ headers={"Retry-After": str(int(wait) + 1)},
57
+ )
58
+
59
+ user = db.scalar(select(User).where(User.username == body.username.strip()))
60
+ valid = verify_password(user.password_hash if user else _DUMMY_HASH, body.password)
61
+ if user is None or not valid:
62
+ limits.by_ip.fail(ip)
63
+ limits.by_username.fail(name)
64
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "wrong username or password")
65
+
66
+ limits.by_username.reset(name)
67
+ if needs_rehash(user.password_hash):
68
+ user.password_hash = hash_password(body.password)
69
+ csrf = start_session(db, user, request.app.state.config, response)
70
+ db.commit()
71
+ return Me(username=user.username, kind="user", csrf_token=csrf, scopes=["read", "write"])
72
+
73
+
74
+ @router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
75
+ def logout(request: Request, response: Response, db: Session = Depends(get_db)) -> None:
76
+ end_session(db, request, response)
77
+ db.commit()
78
+
79
+
80
+ @router.get("/me", response_model=Me)
81
+ def me(who: Principal = Depends(current_principal)) -> Me:
82
+ return Me(
83
+ username=who.name, kind=who.kind, csrf_token=who.csrf_token, scopes=sorted(who.scopes)
84
+ )
@@ -0,0 +1,175 @@
1
+ """Workers, beat and registered tasks: the state of the user's Celery setup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timedelta, timezone
6
+ from typing import Any
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException, Request, status
9
+ from sqlalchemy import func, select
10
+ from sqlalchemy.orm import Session
11
+
12
+ from percival_server import live
13
+ from percival_server.api.history import run_summary
14
+ from percival_server.auth.deps import Principal, require_read
15
+ from percival_server.connections import Connections
16
+ from percival_server.db import get_db
17
+ from percival_server.models import Run, WorkerSeen
18
+ from percival_server.schedules import store
19
+
20
+ router = APIRouter(tags=["cluster"])
21
+
22
+ # dead workers stay listed this long after their last heartbeat
23
+ FORGET_AFTER = timedelta(hours=24)
24
+
25
+
26
+ def _conns(request: Request) -> Connections:
27
+ conns: Connections = request.app.state.connections
28
+ return conns
29
+
30
+
31
+ def all_workers(conns: Connections, db: Session) -> list[dict[str, Any]]:
32
+ alive = live.read_workers(conns)
33
+ seen = {w["hostname"] for w in alive}
34
+ cutoff = datetime.now(timezone.utc) - FORGET_AFTER
35
+ dead: list[dict[str, Any]] = [
36
+ {
37
+ "hostname": row.hostname,
38
+ "code_version": row.code_version,
39
+ "queues": row.queues or [],
40
+ "pid": row.pid,
41
+ "started_at": row.started_at,
42
+ "last_seen": row.last_seen,
43
+ "alive": False,
44
+ }
45
+ for row in db.scalars(select(WorkerSeen).where(WorkerSeen.last_seen >= cutoff))
46
+ if row.hostname not in seen
47
+ ]
48
+ return sorted(alive, key=lambda w: w["hostname"]) + sorted(dead, key=lambda w: w["hostname"])
49
+
50
+
51
+ @router.get("/workers")
52
+ def workers(
53
+ request: Request, db: Session = Depends(get_db), _: Principal = Depends(require_read)
54
+ ) -> dict[str, Any]:
55
+ conns = _conns(request)
56
+ everyone = all_workers(conns, db)
57
+ for w in everyone:
58
+ w.pop("tasks", None) # can be long, /tasks has it
59
+ return {
60
+ "workers": everyone,
61
+ "versions": live.version_summary(everyone, _expected_version(conns)),
62
+ }
63
+
64
+
65
+ def _expected_version(conns: Connections) -> str | None:
66
+ required = [
67
+ store.serialize(entry, count=0)["required_version"]
68
+ for entry in store.list_entries(conns.celery())
69
+ ]
70
+ return live.expected_version(required, live.read_beat(conns))
71
+
72
+
73
+ @router.get("/status")
74
+ def overview(
75
+ request: Request, db: Session = Depends(get_db), _: Principal = Depends(require_read)
76
+ ) -> dict[str, Any]:
77
+ """Everything the header bar needs in one request."""
78
+ conns = _conns(request)
79
+ workers = live.read_workers(conns)
80
+ versions = live.version_summary(workers, _expected_version(conns))
81
+
82
+ affected = []
83
+ if versions["mismatch"]:
84
+ stale = {v["version"] for v in versions["versions"] if v["stale"]}
85
+ for entry in store.list_entries(conns.celery()):
86
+ data = store.serialize(entry, count=0)
87
+ queue = data["queue"] or live.DEFAULT_QUEUE
88
+ on_queue = [w for w in workers if queue in (w.get("queues") or [])]
89
+ if any(w.get("code_version") in stale for w in on_queue):
90
+ affected.append(
91
+ {
92
+ "name": entry.name,
93
+ "queue": queue,
94
+ "required_version": data["required_version"],
95
+ # without a required version a stale worker will just run it
96
+ "protected": bool(data["required_version"]),
97
+ }
98
+ )
99
+
100
+ since = datetime.now(timezone.utc) - timedelta(hours=24)
101
+ counts = dict(
102
+ db.execute(
103
+ select(Run.status, func.count()).where(Run.ts >= since).group_by(Run.status)
104
+ ).all()
105
+ )
106
+ return {
107
+ "beat": live.read_beat(conns),
108
+ "lock": live.read_lock(conns),
109
+ "workers_alive": len(workers),
110
+ "versions": versions,
111
+ "affected_schedules": affected,
112
+ "runs_24h": counts,
113
+ }
114
+
115
+
116
+ @router.get("/tasks")
117
+ def tasks(
118
+ request: Request, db: Session = Depends(get_db), _: Principal = Depends(require_read)
119
+ ) -> list[dict[str, Any]]:
120
+ conns = _conns(request)
121
+ meta = live.read_task_meta(conns)
122
+ workers = live.read_workers(conns)
123
+
124
+ names = set(meta)
125
+ hosts: dict[str, list[dict[str, Any]]] = {}
126
+ for w in workers:
127
+ for name in w.get("tasks") or []:
128
+ names.add(name)
129
+ hosts.setdefault(name, []).append(w)
130
+
131
+ latest = select(func.max(Run.id)).where(Run.status != "running").group_by(Run.task)
132
+ last = {r.task: r for r in db.scalars(select(Run).where(Run.id.in_(latest)))}
133
+ scheduled: dict[str, int] = {}
134
+ for entry in store.list_entries(conns.celery()):
135
+ scheduled[entry.task] = scheduled.get(entry.task, 0) + 1
136
+
137
+ out = []
138
+ for name in sorted(names):
139
+ m = meta.get(name, {})
140
+ on = hosts.get(name, [])
141
+ out.append(
142
+ {
143
+ "name": name,
144
+ "module": m.get("module"),
145
+ "signature": m.get("signature"),
146
+ "doc": (m.get("doc") or "").split("\n\n")[0],
147
+ "workers": sorted(w["hostname"] for w in on),
148
+ "versions": sorted({w.get("code_version") or "unknown" for w in on}),
149
+ "schedules": scheduled.get(name, 0),
150
+ "last_run": (
151
+ {"status": last[name].status, "ts": last[name].ts} if name in last else None
152
+ ),
153
+ }
154
+ )
155
+ return out
156
+
157
+
158
+ @router.get("/tasks/{name}")
159
+ def task_detail(
160
+ name: str, request: Request, db: Session = Depends(get_db), _: Principal = Depends(require_read)
161
+ ) -> dict[str, Any]:
162
+ conns = _conns(request)
163
+ meta = live.read_task_meta(conns).get(name)
164
+ on = [w for w in live.read_workers(conns) if name in (w.get("tasks") or [])]
165
+ if meta is None and not on:
166
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "no worker has published this task")
167
+ runs = db.scalars(select(Run).where(Run.task == name).order_by(Run.id.desc()).limit(20))
168
+ schedules = [e.name for e in store.list_entries(conns.celery()) if e.task == name]
169
+ return {
170
+ "name": name,
171
+ "meta": meta,
172
+ "workers": [{"hostname": w["hostname"], "code_version": w.get("code_version")} for w in on],
173
+ "schedules": schedules,
174
+ "recent_runs": [run_summary(r) for r in runs],
175
+ }