percival-server 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.
Files changed (105) hide show
  1. percival_server/__init__.py +1 -0
  2. percival_server/__main__.py +32 -0
  3. percival_server/api/__init__.py +0 -0
  4. percival_server/api/accounts.py +228 -0
  5. percival_server/api/auth.py +84 -0
  6. percival_server/api/cluster.py +175 -0
  7. percival_server/api/history.py +146 -0
  8. percival_server/api/schedules.py +407 -0
  9. percival_server/api/setup.py +116 -0
  10. percival_server/audit.py +33 -0
  11. percival_server/auth/__init__.py +0 -0
  12. percival_server/auth/deps.py +126 -0
  13. percival_server/auth/ratelimit.py +40 -0
  14. percival_server/auth/security.py +58 -0
  15. percival_server/auth/sessions.py +47 -0
  16. percival_server/bootstrap.py +55 -0
  17. percival_server/config.py +167 -0
  18. percival_server/connections.py +208 -0
  19. percival_server/consumer.py +201 -0
  20. percival_server/crypto.py +33 -0
  21. percival_server/db.py +58 -0
  22. percival_server/live.py +150 -0
  23. percival_server/logs.py +41 -0
  24. percival_server/main.py +93 -0
  25. percival_server/migrations/env.py +34 -0
  26. percival_server/migrations/script.py.mako +27 -0
  27. percival_server/migrations/versions/0001_initial_schema.py +174 -0
  28. percival_server/models.py +173 -0
  29. percival_server/schedules/__init__.py +0 -0
  30. percival_server/schedules/spec.py +115 -0
  31. percival_server/schedules/store.py +177 -0
  32. percival_server/spa.py +38 -0
  33. percival_server/static/assets/ibm-plex-mono-cyrillic-400-normal-CJUKFyLa.woff +0 -0
  34. percival_server/static/assets/ibm-plex-mono-cyrillic-400-normal-DvQQgHTq.woff2 +0 -0
  35. percival_server/static/assets/ibm-plex-mono-cyrillic-500-normal-BtGHVI3Q.woff2 +0 -0
  36. percival_server/static/assets/ibm-plex-mono-cyrillic-500-normal-N1TV9ACq.woff +0 -0
  37. percival_server/static/assets/ibm-plex-mono-cyrillic-600-normal-C8N-B5AP.woff +0 -0
  38. percival_server/static/assets/ibm-plex-mono-cyrillic-600-normal-DqTcxLwU.woff2 +0 -0
  39. percival_server/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-B3hZxag_.woff2 +0 -0
  40. percival_server/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-ZyMXPjaT.woff +0 -0
  41. percival_server/static/assets/ibm-plex-mono-cyrillic-ext-500-normal-CmcxpzmF.woff2 +0 -0
  42. percival_server/static/assets/ibm-plex-mono-cyrillic-ext-500-normal-yqk7_qG0.woff +0 -0
  43. percival_server/static/assets/ibm-plex-mono-cyrillic-ext-600-normal-6-AWF9yE.woff2 +0 -0
  44. percival_server/static/assets/ibm-plex-mono-cyrillic-ext-600-normal-BZFL66GJ.woff +0 -0
  45. percival_server/static/assets/ibm-plex-mono-latin-400-normal-Dm_PoFIZ.woff2 +0 -0
  46. percival_server/static/assets/ibm-plex-mono-latin-400-normal-O6-GRVqx.woff +0 -0
  47. percival_server/static/assets/ibm-plex-mono-latin-500-normal-C_OblDzq.woff2 +0 -0
  48. percival_server/static/assets/ibm-plex-mono-latin-500-normal-zoSM89R3.woff +0 -0
  49. percival_server/static/assets/ibm-plex-mono-latin-600-normal-DeXYNBV7.woff +0 -0
  50. percival_server/static/assets/ibm-plex-mono-latin-600-normal-rgB1DpUr.woff2 +0 -0
  51. percival_server/static/assets/ibm-plex-mono-latin-ext-400-normal-DxDDqVQb.woff2 +0 -0
  52. percival_server/static/assets/ibm-plex-mono-latin-ext-400-normal-uwM7KshN.woff +0 -0
  53. percival_server/static/assets/ibm-plex-mono-latin-ext-500-normal-BhDX_RLI.woff +0 -0
  54. percival_server/static/assets/ibm-plex-mono-latin-ext-500-normal-Dn-cqWhb.woff2 +0 -0
  55. percival_server/static/assets/ibm-plex-mono-latin-ext-600-normal-5raxGPWx.woff2 +0 -0
  56. percival_server/static/assets/ibm-plex-mono-latin-ext-600-normal-C5zKQMXr.woff +0 -0
  57. percival_server/static/assets/ibm-plex-mono-vietnamese-400-normal-BFiUPp6R.woff2 +0 -0
  58. percival_server/static/assets/ibm-plex-mono-vietnamese-400-normal-DLynVohM.woff +0 -0
  59. percival_server/static/assets/ibm-plex-mono-vietnamese-500-normal-CevHOlzG.woff2 +0 -0
  60. percival_server/static/assets/ibm-plex-mono-vietnamese-500-normal-QhwQXayn.woff +0 -0
  61. percival_server/static/assets/ibm-plex-mono-vietnamese-600-normal-BifMsNUA.woff +0 -0
  62. percival_server/static/assets/ibm-plex-mono-vietnamese-600-normal-D7hk9wLv.woff2 +0 -0
  63. percival_server/static/assets/ibm-plex-sans-cyrillic-400-normal-BPWuI_CM.woff2 +0 -0
  64. percival_server/static/assets/ibm-plex-sans-cyrillic-400-normal-CTUnhTqV.woff +0 -0
  65. percival_server/static/assets/ibm-plex-sans-cyrillic-500-normal-BEypoZQd.woff +0 -0
  66. percival_server/static/assets/ibm-plex-sans-cyrillic-500-normal-D1zm99-P.woff2 +0 -0
  67. percival_server/static/assets/ibm-plex-sans-cyrillic-600-normal-BuRiwhxF.woff +0 -0
  68. percival_server/static/assets/ibm-plex-sans-cyrillic-600-normal-DM9A4i1K.woff2 +0 -0
  69. percival_server/static/assets/ibm-plex-sans-cyrillic-ext-400-normal-BJItruJi.woff2 +0 -0
  70. percival_server/static/assets/ibm-plex-sans-cyrillic-ext-400-normal-C1SKuNhx.woff +0 -0
  71. percival_server/static/assets/ibm-plex-sans-cyrillic-ext-500-normal-Cfrm0EKX.woff2 +0 -0
  72. percival_server/static/assets/ibm-plex-sans-cyrillic-ext-500-normal-DujcQJCN.woff +0 -0
  73. percival_server/static/assets/ibm-plex-sans-cyrillic-ext-600-normal-CJ5MtzOJ.woff +0 -0
  74. percival_server/static/assets/ibm-plex-sans-cyrillic-ext-600-normal-jBHiQjEG.woff2 +0 -0
  75. percival_server/static/assets/ibm-plex-sans-greek-400-normal-DSasf_jt.woff +0 -0
  76. percival_server/static/assets/ibm-plex-sans-greek-400-normal-n6oPB5VF.woff2 +0 -0
  77. percival_server/static/assets/ibm-plex-sans-greek-500-normal-BwBTeJfm.woff +0 -0
  78. percival_server/static/assets/ibm-plex-sans-greek-500-normal-By04ZBDJ.woff2 +0 -0
  79. percival_server/static/assets/ibm-plex-sans-greek-600-normal-BJUfoJTU.woff +0 -0
  80. percival_server/static/assets/ibm-plex-sans-greek-600-normal-FZrtz_q-.woff2 +0 -0
  81. percival_server/static/assets/ibm-plex-sans-latin-400-normal-BU83cd9M.woff +0 -0
  82. percival_server/static/assets/ibm-plex-sans-latin-400-normal-CdZtFfYS.woff2 +0 -0
  83. percival_server/static/assets/ibm-plex-sans-latin-500-normal-BFLYOLbL.woff2 +0 -0
  84. percival_server/static/assets/ibm-plex-sans-latin-500-normal-Dh0NORqx.woff +0 -0
  85. percival_server/static/assets/ibm-plex-sans-latin-600-normal-BGOKnPO6.woff2 +0 -0
  86. percival_server/static/assets/ibm-plex-sans-latin-600-normal-ZykYOGKL.woff +0 -0
  87. percival_server/static/assets/ibm-plex-sans-latin-ext-400-normal-BHf956ki.woff2 +0 -0
  88. percival_server/static/assets/ibm-plex-sans-latin-ext-400-normal-CyDjbWPV.woff +0 -0
  89. percival_server/static/assets/ibm-plex-sans-latin-ext-500-normal-CgrnmiIU.woff +0 -0
  90. percival_server/static/assets/ibm-plex-sans-latin-ext-500-normal-DprGrCew.woff2 +0 -0
  91. percival_server/static/assets/ibm-plex-sans-latin-ext-600-normal-bp4QDljZ.woff +0 -0
  92. percival_server/static/assets/ibm-plex-sans-latin-ext-600-normal-dAZVOL-H.woff2 +0 -0
  93. percival_server/static/assets/ibm-plex-sans-vietnamese-400-normal-B97dYap6.woff2 +0 -0
  94. percival_server/static/assets/ibm-plex-sans-vietnamese-400-normal-OKGpH0gQ.woff +0 -0
  95. percival_server/static/assets/ibm-plex-sans-vietnamese-500-normal-V_-bWxzT.woff +0 -0
  96. percival_server/static/assets/ibm-plex-sans-vietnamese-500-normal-rpgY82Kd.woff2 +0 -0
  97. percival_server/static/assets/ibm-plex-sans-vietnamese-600-normal-D03elqKW.woff +0 -0
  98. percival_server/static/assets/ibm-plex-sans-vietnamese-600-normal-DHNi5J5O.woff2 +0 -0
  99. percival_server/static/assets/index-CFnV8aEP.css +1 -0
  100. percival_server/static/assets/index-x7gmNg_p.js +70 -0
  101. percival_server/static/index.html +15 -0
  102. percival_server-0.1.0.dist-info/METADATA +82 -0
  103. percival_server-0.1.0.dist-info/RECORD +105 -0
  104. percival_server-0.1.0.dist-info/WHEEL +4 -0
  105. percival_server-0.1.0.dist-info/entry_points.txt +2 -0
@@ -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
+ }
@@ -0,0 +1,146 @@
1
+ """Run history and the audit log. Both are append-mostly and can get big, so
2
+ they page with ``before_id`` (newest first) instead of offsets."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from datetime import datetime
7
+ from typing import Any
8
+
9
+ from fastapi import APIRouter, Depends, HTTPException, Query, status
10
+ from sqlalchemy import Select, select
11
+ from sqlalchemy.orm import Session
12
+
13
+ from percival_server.auth.deps import Principal, require_read
14
+ from percival_server.db import get_db
15
+ from percival_server.models import AuditLog, Run
16
+
17
+ router = APIRouter(tags=["history"])
18
+
19
+ RUN_LIST_FIELDS = (
20
+ "id",
21
+ "task_id",
22
+ "task",
23
+ "schedule",
24
+ "status",
25
+ "worker",
26
+ "code_version",
27
+ "required_version",
28
+ "queue",
29
+ "args",
30
+ "kwargs",
31
+ "started_at",
32
+ "finished_at",
33
+ "duration",
34
+ "exc_type",
35
+ "exc_message",
36
+ "retries",
37
+ "ts",
38
+ )
39
+
40
+
41
+ def run_summary(run: Run) -> dict[str, Any]:
42
+ return {f: getattr(run, f) for f in RUN_LIST_FIELDS}
43
+
44
+
45
+ def _page(query: Select[Any], model: Any, before_id: int | None, limit: int) -> Select[Any]:
46
+ if before_id is not None:
47
+ query = query.where(model.id < before_id)
48
+ return query.order_by(model.id.desc()).limit(limit)
49
+
50
+
51
+ @router.get("/runs")
52
+ def list_runs(
53
+ schedule: str | None = None,
54
+ task: str | None = None,
55
+ run_status: list[str] | None = Query(None, alias="status"),
56
+ worker: str | None = None,
57
+ since: datetime | None = Query(None, alias="from"),
58
+ until: datetime | None = Query(None, alias="to"),
59
+ before_id: int | None = None,
60
+ limit: int = Query(50, ge=1, le=500),
61
+ db: Session = Depends(get_db),
62
+ _: Principal = Depends(require_read),
63
+ ) -> dict[str, Any]:
64
+ query = select(Run)
65
+ if schedule:
66
+ query = query.where(Run.schedule == schedule)
67
+ if task:
68
+ query = query.where(Run.task == task)
69
+ if run_status:
70
+ query = query.where(Run.status.in_(run_status))
71
+ if worker:
72
+ query = query.where(Run.worker == worker)
73
+ if since:
74
+ query = query.where(Run.ts >= since)
75
+ if until:
76
+ query = query.where(Run.ts < until)
77
+
78
+ rows = list(db.scalars(_page(query, Run, before_id, limit)))
79
+ return {
80
+ "items": [run_summary(r) for r in rows],
81
+ "next_before_id": rows[-1].id if len(rows) == limit else None,
82
+ }
83
+
84
+
85
+ @router.get("/runs/{run_id}")
86
+ def get_run(
87
+ run_id: int, db: Session = Depends(get_db), _: Principal = Depends(require_read)
88
+ ) -> dict[str, Any]:
89
+ run = db.get(Run, run_id)
90
+ if run is None:
91
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "no such run")
92
+ attempts = []
93
+ if run.task_id:
94
+ # the same message can show up more than once, e.g. blocked on a stale
95
+ # worker and then run on a fresh one
96
+ attempts = [
97
+ run_summary(r)
98
+ for r in db.scalars(select(Run).where(Run.task_id == run.task_id).order_by(Run.id))
99
+ ]
100
+ return {
101
+ **run_summary(run),
102
+ "traceback": run.traceback,
103
+ "extra": run.extra,
104
+ "attempts": attempts,
105
+ }
106
+
107
+
108
+ @router.get("/audit-log")
109
+ def audit_log(
110
+ action: str | None = None,
111
+ actor: str | None = None,
112
+ source: str | None = None,
113
+ target: str | None = None,
114
+ before_id: int | None = None,
115
+ limit: int = Query(50, ge=1, le=500),
116
+ db: Session = Depends(get_db),
117
+ _: Principal = Depends(require_read),
118
+ ) -> dict[str, Any]:
119
+ query = select(AuditLog)
120
+ if action:
121
+ # "schedule" matches schedule.create, schedule.pause, ...
122
+ query = query.where(AuditLog.action.startswith(action))
123
+ if actor:
124
+ query = query.where(AuditLog.actor == actor)
125
+ if source:
126
+ query = query.where(AuditLog.source == source)
127
+ if target:
128
+ query = query.where(AuditLog.target == target)
129
+ rows = list(db.scalars(_page(query, AuditLog, before_id, limit)))
130
+ return {
131
+ "items": [
132
+ {
133
+ "id": r.id,
134
+ "ts": r.ts,
135
+ "actor": r.actor,
136
+ "actor_type": r.actor_type,
137
+ "source": r.source,
138
+ "action": r.action,
139
+ "target": r.target,
140
+ "before": r.before,
141
+ "after": r.after,
142
+ }
143
+ for r in rows
144
+ ],
145
+ "next_before_id": rows[-1].id if len(rows) == limit else None,
146
+ }