Skip to content

Commit 1ee610f

Browse files
authored
Merge pull request #86 from grid-labs-tech/feature/organization-settings
2 parents c5b15d6 + 3676822 commit 1ee610f

57 files changed

Lines changed: 2063 additions & 836 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Makefile

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ help:
2121
@echo " make clean - Stop services and remove volumes"
2222
@echo " make build - Rebuild Docker images"
2323
@echo " make lint - Run linters for API and Portal (same as pipeline)"
24-
@echo " make api-lint - Run API linter (ruff check + format --check)"
24+
@echo " make api-lint - Run API linter (format then ruff check)"
2525
@echo " make portal-lint - Run Portal linter (eslint + tsc --noEmit)"
2626

2727
start:
@@ -104,10 +104,10 @@ test:
104104
# Lint (same checks as CI pipeline)
105105
api-lint:
106106
@echo "========================================="
107-
@echo "🔍 Linting API (ruff check + format)..."
107+
@echo "🔍 Linting API (format + ruff check)..."
108108
@echo "========================================="
109+
@cd api && uv tool run ruff format app/
109110
@cd api && uv tool run ruff check app/ --output-format=github
110-
@cd api && uv tool run ruff format app/ --check
111111

112112
portal-lint:
113113
@echo "========================================="

api/alembic/env.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from app.templates.infra.component_template_config_model import ( # noqa: F401
2626
ComponentTemplateConfig,
2727
)
28-
from app.settings.infra.settings_model import Settings # noqa: F401
28+
from app.environments.infra.environment_settings_model import EnvironmentSettings # noqa: F401
2929
from app.auth.infra.token_model import Token # noqa: F401
3030
from app.webapps.infra.application_component_model import ( # noqa: F401
3131
ApplicationComponent,
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""environment_settings_single_json
2+
3+
Revision ID: env_settings_single_json
4+
Revises: add_idp_user_social
5+
Create Date: 2026-03-08
6+
7+
Transform settings table: one row per environment, settings as JSON array.
8+
Remove key, description; rename value -> settings. No default settings injected.
9+
"""
10+
from typing import Sequence, Union
11+
import json
12+
import uuid as uuid_module
13+
from alembic import op
14+
import sqlalchemy as sa
15+
from sqlalchemy.dialects import postgresql
16+
17+
revision: str = "env_settings_single_json"
18+
down_revision: Union[str, None] = "add_idp_user_social"
19+
branch_labels: Union[str, Sequence[str], None] = None
20+
depends_on: Union[str, Sequence[str], None] = None
21+
22+
23+
def _json_type(val):
24+
"""Infer type string for a value."""
25+
if val is None:
26+
return "string"
27+
if isinstance(val, bool):
28+
return "boolean"
29+
if isinstance(val, (int, float)) and not isinstance(val, bool):
30+
return "number"
31+
if isinstance(val, list):
32+
return "list"
33+
if isinstance(val, dict):
34+
return "object"
35+
return "string"
36+
37+
38+
def upgrade() -> None:
39+
conn = op.get_bind()
40+
41+
op.create_table(
42+
"settings_new",
43+
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
44+
sa.Column("uuid", postgresql.UUID(as_uuid=True), nullable=False),
45+
sa.Column("environment_id", sa.Integer(), nullable=False),
46+
sa.Column("organization_id", sa.Integer(), nullable=False),
47+
sa.Column("settings", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
48+
sa.ForeignKeyConstraint(["environment_id"], ["environments.id"], ondelete="CASCADE"),
49+
sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"], ondelete="CASCADE"),
50+
sa.PrimaryKeyConstraint("id"),
51+
sa.UniqueConstraint("environment_id", name="uq_settings_environment_id"),
52+
sa.UniqueConstraint("uuid", name="uq_settings_new_uuid"),
53+
)
54+
op.create_index("ix_settings_new_organization_id", "settings_new", ["organization_id"], unique=False)
55+
56+
env_rows = conn.execute(sa.text("SELECT id, organization_id FROM environments")).fetchall()
57+
for seq_id, (env_id, org_id) in enumerate(env_rows, start=1):
58+
existing = conn.execute(
59+
sa.text("SELECT key, value, description FROM settings WHERE environment_id = :eid"),
60+
{"eid": env_id},
61+
).fetchall()
62+
if existing:
63+
items = [
64+
{
65+
"key": r[0],
66+
"value": r[1],
67+
"description": r[2] or "",
68+
"type": _json_type(r[1]),
69+
}
70+
for r in existing
71+
]
72+
settings_json = json.dumps(items)
73+
else:
74+
settings_json = "[]"
75+
new_uuid = str(uuid_module.uuid4())
76+
conn.execute(
77+
sa.text(
78+
"INSERT INTO settings_new (id, uuid, environment_id, organization_id, settings) "
79+
"VALUES (:id, CAST(:uuid AS uuid), :eid, :oid, CAST(:settings AS jsonb))"
80+
),
81+
{"id": seq_id, "uuid": new_uuid, "eid": env_id, "oid": org_id, "settings": settings_json},
82+
)
83+
84+
op.drop_table("settings")
85+
op.rename_table("settings_new", "settings")
86+
op.create_index(op.f("ix_settings_organization_id"), "settings", ["organization_id"], unique=False)
87+
88+
89+
def downgrade() -> None:
90+
op.drop_table("settings")
91+
op.create_table(
92+
"settings",
93+
sa.Column("id", sa.Integer(), nullable=False),
94+
sa.Column("uuid", postgresql.UUID(as_uuid=True), nullable=False),
95+
sa.Column("key", sa.String(), nullable=False),
96+
sa.Column("value", postgresql.JSON(), nullable=False),
97+
sa.Column("description", sa.String(), nullable=True),
98+
sa.Column("environment_id", sa.Integer(), nullable=False),
99+
sa.Column("organization_id", sa.Integer(), nullable=False),
100+
sa.ForeignKeyConstraint(["environment_id"], ["environments.id"]),
101+
sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"]),
102+
sa.PrimaryKeyConstraint("id"),
103+
sa.UniqueConstraint("key", "environment_id", name="uq_key_environment"),
104+
sa.UniqueConstraint("uuid"),
105+
)
106+
op.create_index(op.f("ix_settings_organization_id"), "settings", ["organization_id"], unique=False)

api/app/auth/api/identity_provider_dto.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from pydantic import BaseModel, Field, field_validator
1+
from pydantic import BaseModel, ConfigDict, Field, field_validator
22
from typing import Optional
33
from datetime import datetime
44

@@ -80,8 +80,7 @@ class IdentityProviderResponse(BaseModel):
8080
created_at: datetime
8181
updated_at: datetime
8282

83-
class Config:
84-
from_attributes = True
83+
model_config = ConfigDict(from_attributes=True)
8584

8685

8786
class IdentityProviderPublic(BaseModel):

api/app/cron/api/cron_handlers.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,16 @@
55
from app.shared.database.database import get_db
66
from app.cron.infra.cron_repository import CronRepository
77
from app.cron.core.cron_service import CronService
8+
from app.environments.infra.environment_settings_repository import (
9+
EnvironmentSettingsRepository,
10+
)
811
from app.cron.api.cron_dto import CronCreate, CronUpdate, Cron, CronJob, CronJobLogs
912
from app.cron.core.cron_validators import (
1013
CronNotFoundError,
1114
CronNotCronTypeError,
1215
InstanceNotFoundError,
1316
)
17+
from app.webapps.core.webapp_validators import EnvironmentSettingsValidationError
1418
from app.cron.core.cron_jobs_service import (
1519
get_cron_jobs_from_cluster,
1620
get_cron_job_logs_from_cluster,
@@ -42,7 +46,8 @@
4246
def get_cron_service(database_session: Session = Depends(get_db)) -> CronService:
4347
"""Dependency to get CronService instance."""
4448
cron_repository = CronRepository(database_session)
45-
return CronService(cron_repository, database_session)
49+
settings_repository = EnvironmentSettingsRepository(database_session)
50+
return CronService(cron_repository, database_session, settings_repository)
4651

4752

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

7883
try:
7984
return service.create_cron(cron)
80-
except (InstanceNotFoundError, ValueError) as e:
85+
except (InstanceNotFoundError, EnvironmentSettingsValidationError) as e:
86+
raise HTTPException(status_code=400, detail=str(e))
87+
except ValueError as e:
8188
raise HTTPException(status_code=400, detail=str(e))
8289
except Exception as e:
8390
raise HTTPException(status_code=400, detail=str(e))
@@ -155,7 +162,7 @@ def update_cron(
155162
return service.update_cron(uuid, cron)
156163
except (CronNotFoundError, CronNotCronTypeError) as e:
157164
raise HTTPException(status_code=404, detail=str(e))
158-
except Exception as e:
165+
except EnvironmentSettingsValidationError as e:
159166
raise HTTPException(status_code=400, detail=str(e))
160167

161168

api/app/cron/core/cron_service.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,21 +41,55 @@
4141
strip_secrets_from_settings,
4242
merge_secrets_for_update,
4343
)
44+
from app.environments.infra.environment_settings_repository import (
45+
EnvironmentSettingsRepository,
46+
)
47+
from app.environments.core.environment_settings_defaults import (
48+
get_environment_limits_from_settings,
49+
)
50+
from app.webapps.core.webapp_validators import (
51+
validate_webapp_settings_against_environment_limits,
52+
)
4453

4554

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

49-
def __init__(self, repository: CronRepository, database_session: Session):
58+
def __init__(
59+
self,
60+
repository: CronRepository,
61+
database_session: Session,
62+
settings_repository: EnvironmentSettingsRepository | None = None,
63+
):
5064
self.repository = repository
5165
self.db = database_session
66+
self.settings_repository = settings_repository
5267

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

5873
instance = self.repository.find_instance_by_uuid(dto.instance_uuid)
74+
75+
if self.settings_repository:
76+
settings_row = self.settings_repository.find_by_environment_id(
77+
instance.environment_id
78+
)
79+
limits = get_environment_limits_from_settings(
80+
settings_row.settings
81+
if settings_row and settings_row.settings
82+
else None
83+
)
84+
# Cron has no autoscaling; pass 1, 1 so max_pods check passes
85+
validate_webapp_settings_against_environment_limits(
86+
limits,
87+
dto.settings.cpu,
88+
dto.settings.memory,
89+
autoscaling_min=1,
90+
autoscaling_max=1,
91+
)
92+
5993
cluster = get_cluster_for_instance(self.db, instance)
6094

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

117+
if dto.settings is not None and self.settings_repository:
118+
settings_row = self.settings_repository.find_by_environment_id(
119+
cron.instance.environment_id
120+
)
121+
limits = get_environment_limits_from_settings(
122+
settings_row.settings
123+
if settings_row and settings_row.settings
124+
else None
125+
)
126+
validate_webapp_settings_against_environment_limits(
127+
limits,
128+
dto.settings.cpu,
129+
dto.settings.memory,
130+
autoscaling_min=1,
131+
autoscaling_max=1,
132+
)
133+
83134
# Check if there are any changes that require Kubernetes update
84135
has_changes = dto.settings is not None or dto.enabled is not None
85136

api/app/cron/infra/cron_repository.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
from app.shared.infra.cluster_instance_model import (
1010
ClusterInstance as ClusterInstanceModel,
1111
)
12-
from app.settings.infra.settings_model import Settings as SettingsModel
12+
from app.environments.infra.environment_settings_model import (
13+
EnvironmentSettings as EnvironmentSettingsModel,
14+
)
1315

1416

1517
class CronRepository:
@@ -89,12 +91,12 @@ def find_cluster_instance_by_component_id(
8991

9092
def find_settings_by_environment_id(
9193
self, environment_id: int
92-
) -> List[SettingsModel]:
93-
"""Find settings by environment ID."""
94+
) -> Optional[EnvironmentSettingsModel]:
95+
"""Find the single settings row for an environment."""
9496
return (
95-
self.db.query(SettingsModel)
96-
.filter(SettingsModel.environment_id == environment_id)
97-
.all()
97+
self.db.query(EnvironmentSettingsModel)
98+
.filter(EnvironmentSettingsModel.environment_id == environment_id)
99+
.first()
98100
)
99101

100102
def create(self, cron: ApplicationComponentModel) -> ApplicationComponentModel:

api/app/environments/api/environment_dto.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from pydantic import BaseModel, ConfigDict, model_validator
22
from uuid import UUID
33
from datetime import datetime
4-
from typing import Any
4+
from typing import Any, Union
55

66

77
class EnvironmentBase(BaseModel):
@@ -20,6 +20,29 @@ class Environment(EnvironmentBase):
2020
)
2121

2222

23+
class EnvironmentSettingItem(BaseModel):
24+
"""Single item in environment settings array."""
25+
26+
key: str
27+
value: Union[str, int, float, bool, list, dict]
28+
description: str = ""
29+
type: str = "string"
30+
31+
32+
class EnvironmentSettingsUpdate(BaseModel):
33+
"""
34+
Payload to update environment settings (idempotent).
35+
Only values are updated; key, description and type are read-only.
36+
Body is a flat object: setting key -> new value. Extra keys allowed.
37+
"""
38+
39+
model_config = ConfigDict(extra="allow")
40+
41+
def get_settings_dict(self) -> dict:
42+
"""Return key -> value dict for merging."""
43+
return self.model_dump(exclude_none=True)
44+
45+
2346
class EnvironmentWithClusters(Environment):
2447
name: str
2548
clusters: list

0 commit comments

Comments
 (0)