Skip to content

Commit 709af1c

Browse files
committed
improvements: add scheduler and verify no running workflows
1)Add scheduler to mimic previous one running each 8 hours 2)Add modal window with verification that github not running already workflows, so we dont interfere with them. Signed-off-by: Denys Fedoryshchenko <denys.f@collabora.com>
1 parent d02069b commit 709af1c

File tree

8 files changed

+419
-41
lines changed

8 files changed

+419
-41
lines changed

CHANGELOG.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,16 @@
22

33
All notable changes to the KernelCI Staging Control application will be documented in this file.
44

5-
## [Unreleased]
5+
## [0.2.0]
6+
7+
### Added
8+
9+
- **Scheduler**: Added staging scheduler that will run staging at 0:00UTC 8:00UTC,16:00UTC
10+
- Will be run using fastapi-crons
11+
- Condition: If staging has run less than 1 hour ago - skip run
12+
- Appears dashboard as user run, under "virtual" user "scheduler"
13+
14+
## [0.1.0]
615

716
### Added
817
- **Staging Run Cancellation**: Added ability to cancel running staging runs

database.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
DEFAULT_ADMIN_EMAIL,
1313
SETTINGS_KEYS,
1414
)
15+
from scheduler_user import ensure_scheduler_user
1516

1617
SQLALCHEMY_DATABASE_URL = DATABASE_URL
1718

@@ -66,6 +67,13 @@ def run_migrations():
6667

6768
print("Database migration check completed")
6869

70+
# Ensure scheduler user exists for legacy databases migrated without init_db
71+
db = SessionLocal()
72+
try:
73+
ensure_scheduler_user(db)
74+
finally:
75+
db.close()
76+
6977

7078
def init_db():
7179
"""Initialize database and create default admin user"""
@@ -94,6 +102,8 @@ def init_db():
94102
db.commit()
95103
print("Default admin user created")
96104

105+
ensure_scheduler_user(db)
106+
97107
# Create default settings
98108
for setting_name, setting_key in SETTINGS_KEYS.items():
99109
setting = db.query(Settings).filter(Settings.key == setting_key).first()

main.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from sqlalchemy.orm import Session
2525
from pydantic import BaseModel
2626
import uvicorn
27+
from fastapi_crons import Crons
2728

2829
from config import (
2930
APP_TITLE,
@@ -63,6 +64,7 @@
6364
validate_single_running_staging,
6465
enforce_single_running_staging,
6566
)
67+
from scheduler import register_cron_jobs
6668

6769

6870
# Pydantic models for API requests
@@ -115,6 +117,8 @@ async def lifespan(app: FastAPI):
115117

116118
# Create app with lifespan
117119
app = FastAPI(title=APP_TITLE, lifespan=lifespan)
120+
crons = Crons(app)
121+
register_cron_jobs(crons)
118122

119123
# Mount static files
120124
app.mount("/static", StaticFiles(directory="templates"), name="static")
@@ -974,5 +978,63 @@ async def get_staging_status(
974978
}
975979

976980

981+
@app.get("/api/staging/check-workflow")
982+
async def check_workflow_status(
983+
current_user: User = Depends(get_current_user),
984+
db: Session = Depends(get_db),
985+
):
986+
"""Check if GitHub workflow is already running"""
987+
# Only allow admin/maintainer to check workflow status
988+
if current_user.role not in [UserRole.ADMIN, UserRole.MAINTAINER]:
989+
raise HTTPException(
990+
status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions"
991+
)
992+
993+
# Check if GitHub token is configured
994+
github_token = get_setting(GITHUB_TOKEN)
995+
if not github_token:
996+
return {
997+
"can_trigger": False,
998+
"reason": "GitHub token not configured",
999+
"running_workflows": [],
1000+
}
1001+
1002+
try:
1003+
from github_integration import GitHubWorkflowManager
1004+
1005+
github_manager = GitHubWorkflowManager(github_token)
1006+
running_workflows = await github_manager.get_running_workflows()
1007+
1008+
if running_workflows:
1009+
return {
1010+
"can_trigger": False,
1011+
"reason": f"GitHub workflow is already running ({len(running_workflows)} active)",
1012+
"running_workflows": running_workflows,
1013+
}
1014+
1015+
# Also check if there's a running staging in database
1016+
running_staging = validate_single_running_staging(db)
1017+
if running_staging:
1018+
return {
1019+
"can_trigger": False,
1020+
"reason": f"Staging run #{running_staging.id} is already running",
1021+
"running_workflows": [],
1022+
}
1023+
1024+
return {
1025+
"can_trigger": True,
1026+
"reason": "Ready to trigger new staging run",
1027+
"running_workflows": [],
1028+
}
1029+
1030+
except Exception as e:
1031+
print(f"Error checking workflow status: {e}")
1032+
return {
1033+
"can_trigger": False,
1034+
"reason": f"Error checking workflow status: {str(e)}",
1035+
"running_workflows": [],
1036+
}
1037+
1038+
9771039
if __name__ == "__main__":
9781040
uvicorn.run(app, host=HOST, port=PORT)

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ passlib[bcrypt]==1.7.4
88
aiofiles==23.2.1
99
httpx==0.25.2
1010
toml
11+
fastapi-crons==2.0.1

scheduler.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""Background scheduler setup for automatic staging runs."""
2+
3+
import logging
4+
from datetime import datetime, timedelta
5+
6+
from fastapi_crons import Crons
7+
8+
from database import SessionLocal
9+
from db_constraints import (
10+
validate_single_running_staging,
11+
enforce_single_running_staging,
12+
)
13+
from discord_webhook import discord_webhook
14+
from models import (
15+
InitiatedVia,
16+
StagingRun,
17+
StagingRunStatus,
18+
)
19+
from scheduler_config import (
20+
SCHEDULER_AUTO_KERNEL_TREE,
21+
SCHEDULER_SKIP_WINDOW_SECONDS,
22+
SCHEDULER_USERNAME,
23+
)
24+
from scheduler_user import ensure_scheduler_user
25+
26+
logger = logging.getLogger(__name__)
27+
28+
CRON_EXPRESSION = "0 0,8,16 * * *" # 00:00, 08:00, 16:00 UTC
29+
30+
31+
async def _run_scheduled_staging() -> None:
32+
"""Execute a staging run if cooldown and concurrency constraints allow it."""
33+
db = SessionLocal()
34+
try:
35+
scheduler_user = ensure_scheduler_user(db)
36+
37+
now = datetime.utcnow()
38+
cooldown_start = now - timedelta(seconds=SCHEDULER_SKIP_WINDOW_SECONDS)
39+
40+
# Skip if another user triggered a run recently
41+
recent_manual_run = (
42+
db.query(StagingRun)
43+
.filter(StagingRun.start_time >= cooldown_start)
44+
.filter(StagingRun.user_id != scheduler_user.id)
45+
.order_by(StagingRun.start_time.desc())
46+
.first()
47+
)
48+
if recent_manual_run:
49+
logger.info(
50+
"Skipping scheduled staging: recent run #%s by %s at %s",
51+
recent_manual_run.id,
52+
recent_manual_run.user.username,
53+
recent_manual_run.start_time,
54+
)
55+
return
56+
57+
# Skip if a run is in progress
58+
running_staging = validate_single_running_staging(db)
59+
if running_staging:
60+
logger.info(
61+
"Skipping scheduled staging: run #%s is currently %s",
62+
running_staging.id,
63+
running_staging.status.value,
64+
)
65+
return
66+
67+
staging_run = StagingRun(
68+
user_id=scheduler_user.id,
69+
status=StagingRunStatus.RUNNING,
70+
initiated_via=InitiatedVia.CRON,
71+
kernel_tree=SCHEDULER_AUTO_KERNEL_TREE,
72+
)
73+
db.add(staging_run)
74+
db.flush()
75+
76+
if not enforce_single_running_staging(db, staging_run.id):
77+
logger.warning(
78+
"Scheduled staging run #%s cancelled due to concurrency enforcement",
79+
staging_run.id,
80+
)
81+
db.rollback()
82+
return
83+
84+
db.commit()
85+
db.refresh(staging_run)
86+
logger.info("Scheduled staging run #%s started", staging_run.id)
87+
88+
logger.info("Using virtual scheduler user '%s'", SCHEDULER_USERNAME)
89+
90+
if discord_webhook:
91+
try:
92+
await discord_webhook.send_staging_start(
93+
SCHEDULER_USERNAME, staging_run.id
94+
)
95+
except Exception as exc:
96+
logger.warning(
97+
"Discord notification failed for scheduler run #%s: %s",
98+
staging_run.id,
99+
exc,
100+
)
101+
102+
except Exception as exc:
103+
logger.error("Scheduler job failed: %s", exc)
104+
db.rollback()
105+
finally:
106+
db.close()
107+
108+
109+
def register_cron_jobs(crons: Crons) -> None:
110+
"""Attach scheduled staging jobs to the provided cron scheduler."""
111+
112+
@crons.cron(CRON_EXPRESSION, name="staging_scheduler")
113+
async def scheduled_staging_job():
114+
await _run_scheduled_staging()
115+
116+
logger.info("Registered scheduled staging job for expression '%s'", CRON_EXPRESSION)

scheduler_config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Configuration constants for the built-in staging scheduler."""
2+
3+
SCHEDULER_USERNAME = "scheduler"
4+
SCHEDULER_AUTO_KERNEL_TREE = "auto"
5+
SCHEDULER_SKIP_WINDOW_SECONDS = 3600 # 1 hour cool-down window

scheduler_user.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Helpers for managing the virtual scheduler user account."""
2+
3+
import secrets
4+
from sqlalchemy.orm import Session
5+
6+
from models import User, UserRole
7+
from scheduler_config import SCHEDULER_USERNAME
8+
9+
10+
def ensure_scheduler_user(db: Session) -> User:
11+
"""Return the scheduler user, creating it with a strong random password if missing."""
12+
user = db.query(User).filter(User.username == SCHEDULER_USERNAME).first()
13+
if user:
14+
return user
15+
16+
from auth import get_password_hash # Imported lazily to avoid circular dependency
17+
18+
random_secret = secrets.token_urlsafe(64)
19+
user = User(
20+
username=SCHEDULER_USERNAME,
21+
password_hash=get_password_hash(random_secret),
22+
role=UserRole.MAINTAINER,
23+
email=None,
24+
)
25+
db.add(user)
26+
db.commit()
27+
db.refresh(user)
28+
return user

0 commit comments

Comments
 (0)