Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""add auto_update_interval_seconds to incarnation

Revision ID: a1b2c3d4e5f6
Revises: 00ee97d0b7a3
Create Date: 2026-05-06 00:00:00.000000+00:00

"""

import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision = "a1b2c3d4e5f6"
down_revision = "00ee97d0b7a3"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.add_column("incarnation", sa.Column("auto_update_interval_seconds", sa.Integer(), nullable=True))
op.execute("UPDATE incarnation SET auto_update_interval_seconds = 0")
with op.batch_alter_table("incarnation") as batch_op:
batch_op.alter_column("auto_update_interval_seconds", nullable=False)


def downgrade() -> None:
op.drop_column("incarnation", "auto_update_interval_seconds")
36 changes: 34 additions & 2 deletions src/foxops/__main__.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
import asyncio
from contextlib import asynccontextmanager, suppress

from fastapi import APIRouter, Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from starlette.responses import FileResponse

from foxops.dependencies import get_settings, static_token_auth_scheme
from foxops.database.engine import create_engine
from foxops.database.repositories.change.repository import ChangeRepository
from foxops.database.repositories.incarnation.repository import IncarnationRepository
from foxops.dependencies import build_hoster, get_settings, static_token_auth_scheme
from foxops.error_handlers import __error_handlers__
from foxops.logger import get_logger, setup_logging
from foxops.middlewares import request_id_middleware, request_time_middleware
from foxops.openapi import custom_openapi
from foxops.routers import auth, incarnations, not_found, version
from foxops.services.auto_update import AutoUpdateService
from foxops.services.change import ChangeService
from foxops.settings import DatabaseSettings

#: Holds the module logger instance
logger = get_logger(__name__)
Expand All @@ -19,11 +28,34 @@
FRONTEND_SUBDIRS = ["assets", "favicons"]


@asynccontextmanager
async def lifespan(app: FastAPI):
settings = get_settings()
db_settings = DatabaseSettings()
engine = create_engine(db_settings.url.get_secret_value())
hoster = build_hoster(settings)
change_service = ChangeService(
hoster=hoster,
incarnation_repository=IncarnationRepository(engine),
change_repository=ChangeRepository(engine),
)
auto_update = AutoUpdateService(
change_service=change_service,
change_repository=ChangeRepository(engine),
)
task = asyncio.create_task(auto_update.run_loop())
yield
task.cancel()
with suppress(asyncio.CancelledError):
await task
await engine.dispose()


def create_app():
settings = get_settings()
setup_logging(level=settings.log_level)

app = FastAPI()
app = FastAPI(lifespan=lifespan)

# Add middlewares
app.middleware("http")(request_id_middleware)
Expand Down
1 change: 1 addition & 0 deletions src/foxops/database/repositories/change/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class IncarnationWithChangesSummary(BaseModel):
incarnation_repository: str
target_directory: str
template_repository: str
auto_update_interval_seconds: int

revision: int
type: ChangeType
Expand Down
3 changes: 3 additions & 0 deletions src/foxops/database/repositories/change/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ async def create_incarnation_with_first_change(
requested_version: str,
requested_data: str,
template_data_full: str,
auto_update_interval_seconds: int = 0,
) -> ChangeInDB:
logger = self.log.bind(function="create_incarnation_with_first_change")
logger.debug(
Expand All @@ -106,6 +107,7 @@ async def create_incarnation_with_first_change(
incarnation_repository=incarnation_repository,
target_directory=target_directory,
template_repository=template_repository,
auto_update_interval_seconds=auto_update_interval_seconds,
)
.returning(incarnations.c.id)
)
Expand Down Expand Up @@ -193,6 +195,7 @@ def _incarnations_with_changes_summary_query(self):
incarnations.c.incarnation_repository,
incarnations.c.target_directory,
incarnations.c.template_repository,
incarnations.c.auto_update_interval_seconds,
alias_change.c.revision,
alias_change.c.type,
alias_change.c.requested_version,
Expand Down
1 change: 1 addition & 0 deletions src/foxops/database/repositories/incarnation/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ class IncarnationInDB(BaseModel):
incarnation_repository: str
target_directory: str
template_repository: str
auto_update_interval_seconds: int
model_config = ConfigDict(from_attributes=True)
13 changes: 12 additions & 1 deletion src/foxops/database/repositories/incarnation/repository.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import AsyncIterator

from sqlalchemy import delete, insert, select
from sqlalchemy import delete, insert, select, update
from sqlalchemy.exc import IntegrityError, NoResultFound
from sqlalchemy.ext.asyncio import AsyncEngine

Expand All @@ -21,6 +21,7 @@ async def create(
incarnation_repository: str,
target_directory: str,
template_repository: str,
auto_update_interval_seconds: int = 0,
) -> IncarnationInDB:
async with self.engine.begin() as conn:
query = (
Expand All @@ -29,6 +30,7 @@ async def create(
incarnation_repository=incarnation_repository,
target_directory=target_directory,
template_repository=template_repository,
auto_update_interval_seconds=auto_update_interval_seconds,
)
.returning(incarnations)
)
Expand Down Expand Up @@ -63,6 +65,15 @@ async def get_by_id(self, id_: int) -> IncarnationInDB:
else:
return IncarnationInDB.model_validate(row)

async def update_auto_update_interval(self, id_: int, interval_seconds: int) -> None:
query = (
update(incarnations).values(auto_update_interval_seconds=interval_seconds).where(incarnations.c.id == id_)
)
async with self.engine.begin() as conn:
result = await conn.execute(query)
if result.rowcount == 0:
raise IncarnationNotFoundError(f"could not find incarnation in DB with id: {id_}")

async def delete_by_id(self, id_: int) -> None:
query = delete(incarnations).where(incarnations.c.id == id_)

Expand Down
1 change: 1 addition & 0 deletions src/foxops/database/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
Column("incarnation_repository", String, nullable=False),
Column("target_directory", String, nullable=False),
Column("template_repository", String, nullable=False),
Column("auto_update_interval_seconds", Integer, nullable=False, server_default="0"),
UniqueConstraint("incarnation_repository", "target_directory", name="incarnation_identity"),
)

Expand Down
19 changes: 9 additions & 10 deletions src/foxops/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,28 +48,27 @@ def get_database_engine(request: Request, settings: DatabaseSettings = Depends(g
return async_engine


def get_hoster(request: Request, settings: Annotated[Settings, Depends(get_settings)]) -> Hoster:
if hasattr(request.app.state, "hoster"):
return request.app.state.hoster

hoster: Hoster
def build_hoster(settings: Settings) -> Hoster:
match settings.hoster_type:
case HosterType.LOCAL:
local_settings = LocalHosterSettings()

logger.warning(
"Using local hoster. This is for DEVELOPMENT use only!", directory=str(local_settings.directory)
)

hoster = LocalHoster(local_settings.directory)
return LocalHoster(local_settings.directory)
case HosterType.GITLAB:
gitlab_settings = GitlabHosterSettings()
logger.info("Using GitLab hoster", address=gitlab_settings.address)

hoster = GitlabHoster(gitlab_settings.address, gitlab_settings.token.get_secret_value())
return GitlabHoster(gitlab_settings.address, gitlab_settings.token.get_secret_value())
case _:
raise NotImplementedError(f"Unknown hoster type {settings.hoster_type}")


def get_hoster(request: Request, settings: Annotated[Settings, Depends(get_settings)]) -> Hoster:
if hasattr(request.app.state, "hoster"):
return request.app.state.hoster

hoster = build_hoster(settings)
request.app.state.hoster = hoster
return hoster

Expand Down
2 changes: 2 additions & 0 deletions src/foxops/models/incarnation.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,6 @@ class IncarnationWithDetails(IncarnationBasic):
template_data: TemplateData | None
template_data_full: TemplateData | None

auto_update_interval_seconds: int

model_config = ConfigDict(from_attributes=True)
15 changes: 13 additions & 2 deletions src/foxops/routers/incarnations.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ class CreateIncarnationRequest(BaseModel):

template_data: TemplateData

auto_update_interval_seconds: int = 0


@router.post(
"",
Expand Down Expand Up @@ -122,6 +124,7 @@ async def create_incarnation(
template_repository=request.template_repository,
template_repository_version=request.template_repository_version,
template_data=template_data,
auto_update_interval_seconds=request.auto_update_interval_seconds,
)
except ProvidedTemplateDataInvalidError as e:
response.status_code = status.HTTP_400_BAD_REQUEST
Expand Down Expand Up @@ -281,6 +284,7 @@ class UpdateIncarnationRequest(BaseModel):
template_data: TemplateData

automerge: bool
auto_update_interval_seconds: int = 0


@router.put(
Expand Down Expand Up @@ -321,7 +325,7 @@ async def update_incarnation(
reusing the previously set variable values), use the PATCH endpoint instead.
"""

return await _create_change(
result = await _create_change(
incarnation_id=incarnation_id,
requested_version=request.template_repository_version,
requested_data=request.template_data,
Expand All @@ -330,6 +334,9 @@ async def update_incarnation(
response=response,
change_service=change_service,
)
if not isinstance(result, ApiError):
await change_service.set_auto_update_interval(incarnation_id, request.auto_update_interval_seconds)
return result


class PatchIncarnationRequest(BaseModel):
Expand All @@ -339,6 +346,7 @@ class PatchIncarnationRequest(BaseModel):
requested_data: TemplateData | None = None

automerge: bool
auto_update_interval_seconds: int | None = None

@model_validator(mode="after")
def check_either_version_or_data_change_requested(self) -> Self:
Expand Down Expand Up @@ -387,7 +395,7 @@ async def patch_incarnation(

requested_data = request.requested_data or {}

return await _create_change(
result = await _create_change(
incarnation_id=incarnation_id,
requested_version=request.requested_version,
requested_data=requested_data,
Expand All @@ -396,6 +404,9 @@ async def patch_incarnation(
response=response,
change_service=change_service,
)
if not isinstance(result, ApiError) and request.auto_update_interval_seconds is not None:
await change_service.set_auto_update_interval(incarnation_id, request.auto_update_interval_seconds)
return result


@router.delete(
Expand Down
61 changes: 61 additions & 0 deletions src/foxops/services/auto_update.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import asyncio
from datetime import datetime, timezone

from foxops.database.repositories.change.repository import ChangeRepository
from foxops.logger import get_logger
from foxops.services.change import (
ChangeRejectedDueToNoChanges,
ChangeRejectedDueToPreviousUnfinishedChange,
ChangeService,
)

logger = get_logger("auto_update")


class AutoUpdateService:
def __init__(self, change_service: ChangeService, change_repository: ChangeRepository) -> None:
self._change_service = change_service
self._change_repository = change_repository
self._last_run: dict[int, datetime] = {}

async def run_once(self) -> None:
now = datetime.now(timezone.utc)
async for incarnation in self._change_repository.list_incarnations_with_changes_summary():
if incarnation.auto_update_interval_seconds == 0:
continue

last_run = self._last_run.get(incarnation.id, datetime.min.replace(tzinfo=timezone.utc))
elapsed = (now - last_run).total_seconds()
if elapsed < incarnation.auto_update_interval_seconds:
continue

log = logger.bind(
incarnation_id=incarnation.id,
incarnation_repository=incarnation.incarnation_repository,
target_directory=incarnation.target_directory,
requested_version=incarnation.requested_version,
)
try:
await self._change_service.create_change_merge_request(
incarnation_id=incarnation.id,
requested_version=incarnation.requested_version,
requested_data={},
automerge=True,
patch=True,
)
log.info("auto-update triggered")
except ChangeRejectedDueToNoChanges:
log.debug("auto-update: already up to date")
except ChangeRejectedDueToPreviousUnfinishedChange:
log.debug("auto-update: previous change still in progress, will retry")
continue
except Exception:
log.exception("auto-update failed")
continue

self._last_run[incarnation.id] = now

async def run_loop(self, tick_seconds: int = 60) -> None:
while True:
await self.run_once()
await asyncio.sleep(tick_seconds)
Loading
Loading