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.
- percival_server/__init__.py +1 -0
- percival_server/__main__.py +32 -0
- percival_server/api/__init__.py +0 -0
- percival_server/api/accounts.py +228 -0
- percival_server/api/auth.py +84 -0
- percival_server/api/cluster.py +175 -0
- percival_server/api/history.py +146 -0
- percival_server/api/schedules.py +407 -0
- percival_server/api/setup.py +116 -0
- percival_server/audit.py +33 -0
- percival_server/auth/__init__.py +0 -0
- percival_server/auth/deps.py +126 -0
- percival_server/auth/ratelimit.py +40 -0
- percival_server/auth/security.py +58 -0
- percival_server/auth/sessions.py +47 -0
- percival_server/bootstrap.py +55 -0
- percival_server/config.py +167 -0
- percival_server/connections.py +208 -0
- percival_server/consumer.py +201 -0
- percival_server/crypto.py +33 -0
- percival_server/db.py +58 -0
- percival_server/live.py +150 -0
- percival_server/logs.py +41 -0
- percival_server/main.py +93 -0
- percival_server/migrations/env.py +34 -0
- percival_server/migrations/script.py.mako +27 -0
- percival_server/migrations/versions/0001_initial_schema.py +174 -0
- percival_server/models.py +173 -0
- percival_server/schedules/__init__.py +0 -0
- percival_server/schedules/spec.py +115 -0
- percival_server/schedules/store.py +177 -0
- percival_server/spa.py +38 -0
- percival_server/static/assets/ibm-plex-mono-cyrillic-400-normal-CJUKFyLa.woff +0 -0
- percival_server/static/assets/ibm-plex-mono-cyrillic-400-normal-DvQQgHTq.woff2 +0 -0
- percival_server/static/assets/ibm-plex-mono-cyrillic-500-normal-BtGHVI3Q.woff2 +0 -0
- percival_server/static/assets/ibm-plex-mono-cyrillic-500-normal-N1TV9ACq.woff +0 -0
- percival_server/static/assets/ibm-plex-mono-cyrillic-600-normal-C8N-B5AP.woff +0 -0
- percival_server/static/assets/ibm-plex-mono-cyrillic-600-normal-DqTcxLwU.woff2 +0 -0
- percival_server/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-B3hZxag_.woff2 +0 -0
- percival_server/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-ZyMXPjaT.woff +0 -0
- percival_server/static/assets/ibm-plex-mono-cyrillic-ext-500-normal-CmcxpzmF.woff2 +0 -0
- percival_server/static/assets/ibm-plex-mono-cyrillic-ext-500-normal-yqk7_qG0.woff +0 -0
- percival_server/static/assets/ibm-plex-mono-cyrillic-ext-600-normal-6-AWF9yE.woff2 +0 -0
- percival_server/static/assets/ibm-plex-mono-cyrillic-ext-600-normal-BZFL66GJ.woff +0 -0
- percival_server/static/assets/ibm-plex-mono-latin-400-normal-Dm_PoFIZ.woff2 +0 -0
- percival_server/static/assets/ibm-plex-mono-latin-400-normal-O6-GRVqx.woff +0 -0
- percival_server/static/assets/ibm-plex-mono-latin-500-normal-C_OblDzq.woff2 +0 -0
- percival_server/static/assets/ibm-plex-mono-latin-500-normal-zoSM89R3.woff +0 -0
- percival_server/static/assets/ibm-plex-mono-latin-600-normal-DeXYNBV7.woff +0 -0
- percival_server/static/assets/ibm-plex-mono-latin-600-normal-rgB1DpUr.woff2 +0 -0
- percival_server/static/assets/ibm-plex-mono-latin-ext-400-normal-DxDDqVQb.woff2 +0 -0
- percival_server/static/assets/ibm-plex-mono-latin-ext-400-normal-uwM7KshN.woff +0 -0
- percival_server/static/assets/ibm-plex-mono-latin-ext-500-normal-BhDX_RLI.woff +0 -0
- percival_server/static/assets/ibm-plex-mono-latin-ext-500-normal-Dn-cqWhb.woff2 +0 -0
- percival_server/static/assets/ibm-plex-mono-latin-ext-600-normal-5raxGPWx.woff2 +0 -0
- percival_server/static/assets/ibm-plex-mono-latin-ext-600-normal-C5zKQMXr.woff +0 -0
- percival_server/static/assets/ibm-plex-mono-vietnamese-400-normal-BFiUPp6R.woff2 +0 -0
- percival_server/static/assets/ibm-plex-mono-vietnamese-400-normal-DLynVohM.woff +0 -0
- percival_server/static/assets/ibm-plex-mono-vietnamese-500-normal-CevHOlzG.woff2 +0 -0
- percival_server/static/assets/ibm-plex-mono-vietnamese-500-normal-QhwQXayn.woff +0 -0
- percival_server/static/assets/ibm-plex-mono-vietnamese-600-normal-BifMsNUA.woff +0 -0
- percival_server/static/assets/ibm-plex-mono-vietnamese-600-normal-D7hk9wLv.woff2 +0 -0
- percival_server/static/assets/ibm-plex-sans-cyrillic-400-normal-BPWuI_CM.woff2 +0 -0
- percival_server/static/assets/ibm-plex-sans-cyrillic-400-normal-CTUnhTqV.woff +0 -0
- percival_server/static/assets/ibm-plex-sans-cyrillic-500-normal-BEypoZQd.woff +0 -0
- percival_server/static/assets/ibm-plex-sans-cyrillic-500-normal-D1zm99-P.woff2 +0 -0
- percival_server/static/assets/ibm-plex-sans-cyrillic-600-normal-BuRiwhxF.woff +0 -0
- percival_server/static/assets/ibm-plex-sans-cyrillic-600-normal-DM9A4i1K.woff2 +0 -0
- percival_server/static/assets/ibm-plex-sans-cyrillic-ext-400-normal-BJItruJi.woff2 +0 -0
- percival_server/static/assets/ibm-plex-sans-cyrillic-ext-400-normal-C1SKuNhx.woff +0 -0
- percival_server/static/assets/ibm-plex-sans-cyrillic-ext-500-normal-Cfrm0EKX.woff2 +0 -0
- percival_server/static/assets/ibm-plex-sans-cyrillic-ext-500-normal-DujcQJCN.woff +0 -0
- percival_server/static/assets/ibm-plex-sans-cyrillic-ext-600-normal-CJ5MtzOJ.woff +0 -0
- percival_server/static/assets/ibm-plex-sans-cyrillic-ext-600-normal-jBHiQjEG.woff2 +0 -0
- percival_server/static/assets/ibm-plex-sans-greek-400-normal-DSasf_jt.woff +0 -0
- percival_server/static/assets/ibm-plex-sans-greek-400-normal-n6oPB5VF.woff2 +0 -0
- percival_server/static/assets/ibm-plex-sans-greek-500-normal-BwBTeJfm.woff +0 -0
- percival_server/static/assets/ibm-plex-sans-greek-500-normal-By04ZBDJ.woff2 +0 -0
- percival_server/static/assets/ibm-plex-sans-greek-600-normal-BJUfoJTU.woff +0 -0
- percival_server/static/assets/ibm-plex-sans-greek-600-normal-FZrtz_q-.woff2 +0 -0
- percival_server/static/assets/ibm-plex-sans-latin-400-normal-BU83cd9M.woff +0 -0
- percival_server/static/assets/ibm-plex-sans-latin-400-normal-CdZtFfYS.woff2 +0 -0
- percival_server/static/assets/ibm-plex-sans-latin-500-normal-BFLYOLbL.woff2 +0 -0
- percival_server/static/assets/ibm-plex-sans-latin-500-normal-Dh0NORqx.woff +0 -0
- percival_server/static/assets/ibm-plex-sans-latin-600-normal-BGOKnPO6.woff2 +0 -0
- percival_server/static/assets/ibm-plex-sans-latin-600-normal-ZykYOGKL.woff +0 -0
- percival_server/static/assets/ibm-plex-sans-latin-ext-400-normal-BHf956ki.woff2 +0 -0
- percival_server/static/assets/ibm-plex-sans-latin-ext-400-normal-CyDjbWPV.woff +0 -0
- percival_server/static/assets/ibm-plex-sans-latin-ext-500-normal-CgrnmiIU.woff +0 -0
- percival_server/static/assets/ibm-plex-sans-latin-ext-500-normal-DprGrCew.woff2 +0 -0
- percival_server/static/assets/ibm-plex-sans-latin-ext-600-normal-bp4QDljZ.woff +0 -0
- percival_server/static/assets/ibm-plex-sans-latin-ext-600-normal-dAZVOL-H.woff2 +0 -0
- percival_server/static/assets/ibm-plex-sans-vietnamese-400-normal-B97dYap6.woff2 +0 -0
- percival_server/static/assets/ibm-plex-sans-vietnamese-400-normal-OKGpH0gQ.woff +0 -0
- percival_server/static/assets/ibm-plex-sans-vietnamese-500-normal-V_-bWxzT.woff +0 -0
- percival_server/static/assets/ibm-plex-sans-vietnamese-500-normal-rpgY82Kd.woff2 +0 -0
- percival_server/static/assets/ibm-plex-sans-vietnamese-600-normal-D03elqKW.woff +0 -0
- percival_server/static/assets/ibm-plex-sans-vietnamese-600-normal-DHNi5J5O.woff2 +0 -0
- percival_server/static/assets/index-CFnV8aEP.css +1 -0
- percival_server/static/assets/index-x7gmNg_p.js +70 -0
- percival_server/static/index.html +15 -0
- percival_server-0.1.0.dist-info/METADATA +82 -0
- percival_server-0.1.0.dist-info/RECORD +105 -0
- percival_server-0.1.0.dist-info/WHEEL +4 -0
- 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
|
+
}
|