Skip to content
Merged
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
6 changes: 3 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ help:
@echo " make clean - Stop services and remove volumes"
@echo " make build - Rebuild Docker images"
@echo " make lint - Run linters for API and Portal (same as pipeline)"
@echo " make api-lint - Run API linter (ruff check + format --check)"
@echo " make api-lint - Run API linter (format then ruff check)"
@echo " make portal-lint - Run Portal linter (eslint + tsc --noEmit)"

start:
Expand Down Expand Up @@ -104,10 +104,10 @@ test:
# Lint (same checks as CI pipeline)
api-lint:
@echo "========================================="
@echo "🔍 Linting API (ruff check + format)..."
@echo "🔍 Linting API (format + ruff check)..."
@echo "========================================="
@cd api && uv tool run ruff format app/
@cd api && uv tool run ruff check app/ --output-format=github
@cd api && uv tool run ruff format app/ --check

portal-lint:
@echo "========================================="
Expand Down
2 changes: 1 addition & 1 deletion api/alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from app.templates.infra.component_template_config_model import ( # noqa: F401
ComponentTemplateConfig,
)
from app.settings.infra.settings_model import Settings # noqa: F401
from app.environments.infra.environment_settings_model import EnvironmentSettings # noqa: F401
from app.auth.infra.token_model import Token # noqa: F401
from app.webapps.infra.application_component_model import ( # noqa: F401
ApplicationComponent,
Expand Down
106 changes: 106 additions & 0 deletions api/alembic/versions/environment_settings_single_json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""environment_settings_single_json

Revision ID: env_settings_single_json
Revises: add_idp_user_social
Create Date: 2026-03-08

Transform settings table: one row per environment, settings as JSON array.
Remove key, description; rename value -> settings. No default settings injected.
"""
from typing import Sequence, Union
import json
import uuid as uuid_module
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

revision: str = "env_settings_single_json"
down_revision: Union[str, None] = "add_idp_user_social"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def _json_type(val):
"""Infer type string for a value."""
if val is None:
return "string"
if isinstance(val, bool):
return "boolean"
if isinstance(val, (int, float)) and not isinstance(val, bool):
return "number"
if isinstance(val, list):
return "list"
if isinstance(val, dict):
return "object"
return "string"


def upgrade() -> None:
conn = op.get_bind()

op.create_table(
"settings_new",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("uuid", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("environment_id", sa.Integer(), nullable=False),
sa.Column("organization_id", sa.Integer(), nullable=False),
sa.Column("settings", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.ForeignKeyConstraint(["environment_id"], ["environments.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("environment_id", name="uq_settings_environment_id"),
sa.UniqueConstraint("uuid", name="uq_settings_new_uuid"),
)
op.create_index("ix_settings_new_organization_id", "settings_new", ["organization_id"], unique=False)

env_rows = conn.execute(sa.text("SELECT id, organization_id FROM environments")).fetchall()
for seq_id, (env_id, org_id) in enumerate(env_rows, start=1):
existing = conn.execute(
sa.text("SELECT key, value, description FROM settings WHERE environment_id = :eid"),
{"eid": env_id},
).fetchall()
if existing:
items = [
{
"key": r[0],
"value": r[1],
"description": r[2] or "",
"type": _json_type(r[1]),
}
for r in existing
]
settings_json = json.dumps(items)
else:
settings_json = "[]"
new_uuid = str(uuid_module.uuid4())
conn.execute(
sa.text(
"INSERT INTO settings_new (id, uuid, environment_id, organization_id, settings) "
"VALUES (:id, CAST(:uuid AS uuid), :eid, :oid, CAST(:settings AS jsonb))"
),
{"id": seq_id, "uuid": new_uuid, "eid": env_id, "oid": org_id, "settings": settings_json},
)

op.drop_table("settings")
op.rename_table("settings_new", "settings")
op.create_index(op.f("ix_settings_organization_id"), "settings", ["organization_id"], unique=False)


def downgrade() -> None:
op.drop_table("settings")
op.create_table(
"settings",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("uuid", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("key", sa.String(), nullable=False),
sa.Column("value", postgresql.JSON(), nullable=False),
sa.Column("description", sa.String(), nullable=True),
sa.Column("environment_id", sa.Integer(), nullable=False),
sa.Column("organization_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(["environment_id"], ["environments.id"]),
sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("key", "environment_id", name="uq_key_environment"),
sa.UniqueConstraint("uuid"),
)
op.create_index(op.f("ix_settings_organization_id"), "settings", ["organization_id"], unique=False)
5 changes: 2 additions & 3 deletions api/app/auth/api/identity_provider_dto.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator
from typing import Optional
from datetime import datetime

Expand Down Expand Up @@ -80,8 +80,7 @@ class IdentityProviderResponse(BaseModel):
created_at: datetime
updated_at: datetime

class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)


class IdentityProviderPublic(BaseModel):
Expand Down
13 changes: 10 additions & 3 deletions api/app/cron/api/cron_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@
from app.shared.database.database import get_db
from app.cron.infra.cron_repository import CronRepository
from app.cron.core.cron_service import CronService
from app.environments.infra.environment_settings_repository import (
EnvironmentSettingsRepository,
)
from app.cron.api.cron_dto import CronCreate, CronUpdate, Cron, CronJob, CronJobLogs
from app.cron.core.cron_validators import (
CronNotFoundError,
CronNotCronTypeError,
InstanceNotFoundError,
)
from app.webapps.core.webapp_validators import EnvironmentSettingsValidationError
from app.cron.core.cron_jobs_service import (
get_cron_jobs_from_cluster,
get_cron_job_logs_from_cluster,
Expand Down Expand Up @@ -42,7 +46,8 @@
def get_cron_service(database_session: Session = Depends(get_db)) -> CronService:
"""Dependency to get CronService instance."""
cron_repository = CronRepository(database_session)
return CronService(cron_repository, database_session)
settings_repository = EnvironmentSettingsRepository(database_session)
return CronService(cron_repository, database_session, settings_repository)


@router.post("/", response_model=Cron)
Expand Down Expand Up @@ -77,7 +82,9 @@ def create_cron(

try:
return service.create_cron(cron)
except (InstanceNotFoundError, ValueError) as e:
except (InstanceNotFoundError, EnvironmentSettingsValidationError) as e:
raise HTTPException(status_code=400, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
Expand Down Expand Up @@ -155,7 +162,7 @@ def update_cron(
return service.update_cron(uuid, cron)
except (CronNotFoundError, CronNotCronTypeError) as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
except EnvironmentSettingsValidationError as e:
raise HTTPException(status_code=400, detail=str(e))


Expand Down
53 changes: 52 additions & 1 deletion api/app/cron/core/cron_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,21 +41,55 @@
strip_secrets_from_settings,
merge_secrets_for_update,
)
from app.environments.infra.environment_settings_repository import (
EnvironmentSettingsRepository,
)
from app.environments.core.environment_settings_defaults import (
get_environment_limits_from_settings,
)
from app.webapps.core.webapp_validators import (
validate_webapp_settings_against_environment_limits,
)


class CronService:
"""Business logic for crons. No direct database access."""

def __init__(self, repository: CronRepository, database_session: Session):
def __init__(
self,
repository: CronRepository,
database_session: Session,
settings_repository: EnvironmentSettingsRepository | None = None,
):
self.repository = repository
self.db = database_session
self.settings_repository = settings_repository

def create_cron(self, dto: CronCreate) -> Cron:
"""Create a new cron."""
validate_cron_create_dto(dto)
validate_instance_exists(self.repository, dto.instance_uuid)

instance = self.repository.find_instance_by_uuid(dto.instance_uuid)

if self.settings_repository:
settings_row = self.settings_repository.find_by_environment_id(
instance.environment_id
)
limits = get_environment_limits_from_settings(
settings_row.settings
if settings_row and settings_row.settings
else None
)
# Cron has no autoscaling; pass 1, 1 so max_pods check passes
validate_webapp_settings_against_environment_limits(
limits,
dto.settings.cpu,
dto.settings.memory,
autoscaling_min=1,
autoscaling_max=1,
)

cluster = get_cluster_for_instance(self.db, instance)

settings_dict = ensure_private_exposure_settings(dto.settings.model_dump())
Expand All @@ -80,6 +114,23 @@ def update_cron(self, uuid: UUID, dto: CronUpdate) -> Cron:
cron = self.repository.find_by_uuid(uuid)
validate_cron_type(cron)

if dto.settings is not None and self.settings_repository:
settings_row = self.settings_repository.find_by_environment_id(
cron.instance.environment_id
)
limits = get_environment_limits_from_settings(
settings_row.settings
if settings_row and settings_row.settings
else None
)
validate_webapp_settings_against_environment_limits(
limits,
dto.settings.cpu,
dto.settings.memory,
autoscaling_min=1,
autoscaling_max=1,
)

# Check if there are any changes that require Kubernetes update
has_changes = dto.settings is not None or dto.enabled is not None

Expand Down
14 changes: 8 additions & 6 deletions api/app/cron/infra/cron_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
from app.shared.infra.cluster_instance_model import (
ClusterInstance as ClusterInstanceModel,
)
from app.settings.infra.settings_model import Settings as SettingsModel
from app.environments.infra.environment_settings_model import (
EnvironmentSettings as EnvironmentSettingsModel,
)


class CronRepository:
Expand Down Expand Up @@ -89,12 +91,12 @@ def find_cluster_instance_by_component_id(

def find_settings_by_environment_id(
self, environment_id: int
) -> List[SettingsModel]:
"""Find settings by environment ID."""
) -> Optional[EnvironmentSettingsModel]:
"""Find the single settings row for an environment."""
return (
self.db.query(SettingsModel)
.filter(SettingsModel.environment_id == environment_id)
.all()
self.db.query(EnvironmentSettingsModel)
.filter(EnvironmentSettingsModel.environment_id == environment_id)
.first()
)

def create(self, cron: ApplicationComponentModel) -> ApplicationComponentModel:
Expand Down
25 changes: 24 additions & 1 deletion api/app/environments/api/environment_dto.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from pydantic import BaseModel, ConfigDict, model_validator
from uuid import UUID
from datetime import datetime
from typing import Any
from typing import Any, Union


class EnvironmentBase(BaseModel):
Expand All @@ -20,6 +20,29 @@ class Environment(EnvironmentBase):
)


class EnvironmentSettingItem(BaseModel):
"""Single item in environment settings array."""

key: str
value: Union[str, int, float, bool, list, dict]
description: str = ""
type: str = "string"


class EnvironmentSettingsUpdate(BaseModel):
"""
Payload to update environment settings (idempotent).
Only values are updated; key, description and type are read-only.
Body is a flat object: setting key -> new value. Extra keys allowed.
"""

model_config = ConfigDict(extra="allow")

def get_settings_dict(self) -> dict:
"""Return key -> value dict for merging."""
return self.model_dump(exclude_none=True)


class EnvironmentWithClusters(Environment):
name: str
clusters: list
Expand Down
Loading
Loading