import logging from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from pathlib import Path from app.core.config import get_settings from app.db.pool import close_pool, get_pool, init_pool from app.routers.auth import router as auth_router from app.routers.admin_users import router as admin_users_router from app.routers.domain import router as domain_router from app.routers.profile import router as profile_router from app.routers.teams import router as teams_router from app.routers.ui import router as ui_router from app.routers.ws import router as ws_router from app.services.jsonlint_runtime import refresh_jsonlint_config_cache from app.services.yamllint_runtime import refresh_yamllint_config_cache _logger = logging.getLogger(__name__) @asynccontextmanager async def lifespan(_: FastAPI): await init_pool() pool = get_pool() async with pool.acquire() as conn: await conn.execute("alter table users add column if not exists username text") await conn.execute("alter table users add column if not exists role text not null default 'user'") await conn.execute( """ update users set username = split_part(email, '@', 1) || '_' || substr(id::text, 1, 8) where username is null or username = '' """ ) await conn.execute("create unique index if not exists users_username_key on users(username)") await conn.execute( """ with first_user as ( select id from users order by created_at asc limit 1 ) update users set role='admin' where id in (select id from first_user) """ ) await conn.execute( """ create table if not exists role_categories ( id uuid primary key default gen_random_uuid(), owner_id uuid references users(id) on delete cascade, name text not null, created_at timestamptz not null default now(), unique (owner_id, name) ) """ ) await conn.execute("alter table role_categories alter column owner_id drop not null") await conn.execute("create table if not exists app_config (key text primary key, value jsonb not null default '{}'::jsonb, updated_at timestamptz not null default now())") await conn.execute( """ create table if not exists role_files ( id uuid primary key default gen_random_uuid(), role_id uuid not null references ansible_roles(id) on delete cascade, path text not null, content text not null default '', created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (role_id, path) ) """ ) await conn.execute( """ do $$ begin if exists ( select 1 from information_schema.tables where table_schema='public' and table_name='ansible_roles' ) then alter table ansible_roles add column if not exists category_id uuid; end if; end $$; """ ) await conn.execute( """ do $$ begin if exists ( select 1 from information_schema.tables where table_schema='public' and table_name='ansible_roles' ) then -- Remove accidental starter-role duplicates before adding unique partial index. delete from ansible_roles r using ( select id from ( select id, row_number() over ( partition by owner_id, name, source_ref order by created_at asc, id asc ) as rn from ansible_roles where source_ref = 'starter' ) t where t.rn > 1 ) d where r.id = d.id; create unique index if not exists ux_ansible_roles_owner_name_starter on ansible_roles (owner_id, name) where source_ref = 'starter'; end if; end $$; """ ) await conn.execute( """ do $$ begin if not exists ( select 1 from pg_constraint where conname = 'ansible_roles_category_id_fkey' ) then alter table ansible_roles add constraint ansible_roles_category_id_fkey foreign key (category_id) references role_categories(id) on delete set null; end if; end $$; """ ) await conn.execute( """ do $$ begin if exists ( select 1 from information_schema.tables where table_schema='public' and table_name='ansible_roles' ) then alter table ansible_roles add column if not exists visibility text not null default 'personal'; alter table ansible_roles add column if not exists team_id uuid; alter table ansible_roles add column if not exists forked_from_id uuid; if not exists ( select 1 from pg_constraint where conname = 'ansible_roles_forked_from_id_fkey' ) then alter table ansible_roles add constraint ansible_roles_forked_from_id_fkey foreign key (forked_from_id) references ansible_roles(id) on delete set null; end if; update ansible_roles set visibility = 'public' where visibility is null or visibility = ''; if not exists ( select 1 from pg_constraint where conname = 'ansible_roles_visibility_check' ) then alter table ansible_roles add constraint ansible_roles_visibility_check check (visibility in ('public', 'team', 'personal')); end if; -- Remove auto-generated starter roles (no longer created for new users). delete from ansible_roles where source_ref = 'starter'; alter table ansible_roles alter column visibility set default 'personal'; end if; end $$; """ ) await conn.execute( """ do $$ begin if exists ( select 1 from information_schema.tables where table_schema='public' and table_name='ansible_roles' ) then alter table ansible_roles add column if not exists role_tags text[] not null default array[]::text[]; alter table ansible_roles add column if not exists os_families text[] not null default array[]::text[]; end if; end $$; """ ) await conn.execute( """ do $$ begin if exists ( select 1 from information_schema.tables where table_schema = 'public' and table_name = 'role_categories' ) then -- Duplicate rows with the same normalized name were possible because UNIQUE(owner_id, name) -- treats NULL owner_id as distinct per row. Point roles at the canonical category -- (earliest created_at, then smallest id) and remove extras. update ansible_roles ar set category_id = canon.canon_id from ( select c.id as dup_id, ( select c2.id from role_categories c2 where lower(trim(c2.name)) = lower(trim(c.name)) order by c2.created_at asc nulls last, c2.id asc limit 1 ) as canon_id from role_categories c ) canon where ar.category_id = canon.dup_id and canon.canon_id is not null and canon.dup_id <> canon.canon_id; delete from role_categories d where d.id in ( select c.id from role_categories c where c.id <> ( select c2.id from role_categories c2 where lower(trim(c2.name)) = lower(trim(c.name)) order by c2.created_at asc nulls last, c2.id asc limit 1 ) ); create unique index if not exists ux_role_categories_name_normalized on role_categories (lower(trim(name))); end if; end $$; """ ) await conn.execute( """ alter table users add column if not exists profile_bio text not null default ''; alter table users add column if not exists avatar_ext text; alter table users add column if not exists avatar_updated_at timestamptz; """ ) await conn.execute( """ alter table users add column if not exists is_active boolean not null default true; alter table users add column if not exists deleted_at timestamptz; alter table users add column if not exists updated_at timestamptz; update users set updated_at = created_at where updated_at is null; alter table users alter column updated_at set default now(); alter table users alter column updated_at set not null; """ ) await conn.execute( """ alter table users add column if not exists ban_until timestamptz; alter table users add column if not exists ban_reason text; alter table users add column if not exists banned_by_id uuid; """ ) await conn.execute( """ do $$ begin if not exists ( select 1 from pg_constraint where conname = 'users_banned_by_id_fkey' ) then alter table users add constraint users_banned_by_id_fkey foreign key (banned_by_id) references users(id) on delete set null; end if; end $$; """ ) await conn.execute( """ alter table users add column if not exists is_founder boolean not null default false; """ ) await conn.execute( """ update users set is_founder = true where id = (select id from users order by created_at asc limit 1); """ ) await conn.execute( """ update users set role = 'root' where role = 'super_admin'; """ ) await conn.execute( """ update users u set role = 'root' where u.id = (select id from users order by created_at asc limit 1) and not exists (select 1 from users u2 where u2.role = 'root'); """ ) await conn.execute( """ create table if not exists teams ( id uuid primary key default gen_random_uuid(), name text not null, description text not null default '', created_by uuid not null references users(id) on delete restrict, created_at timestamptz not null default now() ); """ ) await conn.execute( """ create table if not exists team_memberships ( team_id uuid not null references teams(id) on delete cascade, user_id uuid not null references users(id) on delete cascade, team_role text not null default 'member', status text not null default 'active', invited_by uuid references users(id) on delete set null, created_at timestamptz not null default now(), primary key (team_id, user_id), constraint team_memberships_team_role_check check (team_role in ('owner', 'member')), constraint team_memberships_status_check check (status in ('active', 'pending_join', 'invited')) ); """ ) await conn.execute( """ create index if not exists ix_team_memberships_user_id on team_memberships(user_id); """ ) await conn.execute( """ do $$ begin if exists ( select 1 from information_schema.columns where table_schema = 'public' and table_name = 'ansible_roles' and column_name = 'team_id' ) and not exists ( select 1 from pg_constraint where conname = 'ansible_roles_team_id_fkey' ) then alter table ansible_roles add constraint ansible_roles_team_id_fkey foreign key (team_id) references teams(id) on delete set null; end if; end $$; """ ) # Warm lint caches outside the migration connection so nested pool.acquire() cannot deadlock. try: await refresh_yamllint_config_cache() await refresh_jsonlint_config_cache() except Exception: _logger.exception( "Lint config cache warmup failed (app still starts; caches load on first use)." ) yield await close_pool() settings = get_settings() app = FastAPI(title=settings.app_name, lifespan=lifespan) _path_static = Path(__file__).resolve().parent / "static" _app_data = Path(settings.app_data_dir) _app_data.mkdir(parents=True, exist_ok=True) _avatar_dir = settings.avatar_storage_path _avatar_dir.mkdir(parents=True, exist_ok=True) app.mount("/static", StaticFiles(directory=str(_path_static)), name="static") app.mount("/media/avatars", StaticFiles(directory=str(_avatar_dir)), name="avatars_media") app.include_router(ui_router) app.include_router(auth_router) app.include_router(auth_router, prefix="/api/v1") app.include_router(profile_router, prefix="/api/v1") app.include_router(admin_users_router, prefix="/api/v1") app.include_router(teams_router, prefix="/api/v1") app.include_router(domain_router, prefix="/api/v1") app.include_router(ws_router) @app.get("/healthz", tags=["health"]) async def healthz() -> dict[str, str]: return {"status": "ok"}