diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fd76f484dc..52c7634d0f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -87,6 +87,26 @@ You can then access the application at http://localhost:80. > [!IMPORTANT] > `--seed` creates a test user only. Superadmin is determined by `TRACECAT__AUTH_SUPERADMIN_EMAIL` in `.env` set via `./env.sh`, and the first signup or login with that email becomes the organization owner. +### Lite mode (control plane only) + +If you are working on control-plane features, the `lite` profile starts only +Postgres, the API, the UI, and Caddy: + +```bash +just cluster -p lite up -d +``` + +This drops Temporal, Redis, MinIO, and the worker/executor containers entirely, +so the stack boots in seconds. Auth, workspaces, secrets, settings, tables, +RBAC, workflow CRUD and the graph editor, registry action listing, and cases all +work normally. + +Workflows cannot execute in lite mode, and any feature needing Temporal, Redis, +or blob storage will error the same way it does when that service is down. The +flag only skips startup work; request-time code paths are untouched. + +The profile sets `TRACECAT__LITE_MODE=true`. + ## PR and Commit Message Guidelines We follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification for both pull requests and commit messages. diff --git a/docker-compose.lite.yml b/docker-compose.lite.yml new file mode 100644 index 0000000000..53301cf27e --- /dev/null +++ b/docker-compose.lite.yml @@ -0,0 +1,164 @@ +# Lite mode: control plane only. +# +# Postgres + API + UI + Caddy, with no Temporal, MinIO, Redis, or worker/executor +# containers. Intended for fast iteration on control-plane features (auth, +# workspaces, secrets, settings, tables, RBAC, workflow CRUD and the graph editor, +# cases). Workflows cannot execute in this stack. +# +# This is a standalone compose file, not an overlay on docker-compose.dev.yml. +# `depends_on` merges by union across `-f` files, so an overlay cannot remove the +# api -> temporal/minio/redis edges; and Compose auto-enables a profiled service +# when an active service depends on it, so native `profiles:` cannot either. +# +# Usage: just cluster -p lite up -d +services: + caddy: + image: caddy:2.10.2-alpine + restart: unless-stopped + ports: + - ${PUBLIC_APP_PORT}:${PUBLIC_APP_PORT} + environment: + - BASE_DOMAIN=${BASE_DOMAIN} + - ADDRESS=${ADDRESS} + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile + # The /s3/* and /mcp* routes point at containers this stack does not run. + # Caddy resolves reverse_proxy upstreams at request time, so those paths + # return 502 while everything else serves normally. + + api: + build: + context: . + dockerfile: Dockerfile + target: development + restart: unless-stopped + ports: + - ${API_PORT:-8000}:8000 + environment: + # Lite mode + TRACECAT__LITE_MODE: ${TRACECAT__LITE_MODE:-true} + # App + LOG_LEVEL: ${LOG_LEVEL} + TRACECAT__ALLOW_ORIGINS: ${TRACECAT__ALLOW_ORIGINS} + TRACECAT__API_ROOT_PATH: ${TRACECAT__API_ROOT_PATH} + TRACECAT__API_URL: ${TRACECAT__API_URL} + TRACECAT__APP_ENV: ${TRACECAT__APP_ENV} + TRACECAT__AWS_ASSUME_ROLE_ACCOUNT_ID: ${TRACECAT__AWS_ASSUME_ROLE_ACCOUNT_ID:-} + TRACECAT__AWS_ASSUME_ROLE_PRINCIPAL_ARN: ${TRACECAT__AWS_ASSUME_ROLE_PRINCIPAL_ARN:-} + TRACECAT__AUTH_ALLOWED_DOMAINS: ${TRACECAT__AUTH_ALLOWED_DOMAINS} + TRACECAT__AUTH_MIN_PASSWORD_LENGTH: ${TRACECAT__AUTH_MIN_PASSWORD_LENGTH} + TRACECAT__AUTH_TYPES: ${TRACECAT__AUTH_TYPES} + TRACECAT__AUTH_SUPERADMIN_EMAIL: ${TRACECAT__AUTH_SUPERADMIN_EMAIL} + TRACECAT__DEV_COOKIE_NAME: ${TRACECAT__DEV_COOKIE_NAME:-} + TRACECAT__DB_ENCRYPTION_KEY: ${TRACECAT__DB_ENCRYPTION_KEY} # Sensitive + TRACECAT__DB_SSLMODE: ${TRACECAT__DB_SSLMODE} + TRACECAT__DB_URI: ${TRACECAT__DB_URI} # Sensitive + TRACECAT__PUBLIC_API_URL: ${TRACECAT__PUBLIC_API_URL} + TRACECAT__PUBLIC_APP_URL: ${TRACECAT__PUBLIC_APP_URL} + TRACECAT__SERVICE_KEY: ${TRACECAT__SERVICE_KEY} # Sensitive + TRACECAT__SIGNING_SECRET: ${TRACECAT__SIGNING_SECRET} # Sensitive + OAUTH_CLIENT_ID: ${OAUTH_CLIENT_ID} + OAUTH_CLIENT_SECRET: ${OAUTH_CLIENT_SECRET} + OIDC_ISSUER: ${OIDC_ISSUER} + OIDC_CLIENT_ID: ${OIDC_CLIENT_ID} + OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET} + OIDC_SCOPES: ${OIDC_SCOPES} + USER_AUTH_SECRET: ${USER_AUTH_SECRET} + TRACECAT__FEATURE_FLAGS: ${TRACECAT__FEATURE_FLAGS} + TRACECAT__EE_MULTI_TENANT: ${TRACECAT__EE_MULTI_TENANT:-false} + # SAML SSO + SAML_IDP_METADATA_URL: ${SAML_IDP_METADATA_URL} + # Local registry + TRACECAT__LOCAL_REPOSITORY_PATH: ${TRACECAT__LOCAL_REPOSITORY_PATH} + TRACECAT__LOCAL_REPOSITORY_ENABLED: ${TRACECAT__LOCAL_REPOSITORY_ENABLED:-false} + # Temporal is not deployed here. The client retries connects with + # exponential backoff, so the stock 10 attempts would stall a request for + # ~6 minutes; one attempt turns that into a prompt error instead. + TEMPORAL__CONNECT_RETRIES: ${TEMPORAL__CONNECT_RETRIES:-1} + # No TEMPORAL__CLUSTER_*, REDIS_URL, MINIO_ROOT_*, or + # TRACECAT__BLOB_STORAGE_* vars: nothing in the control plane dials them. + volumes: + - ./tracecat:/app/tracecat + - ./packages:/app/packages + - ./alembic:/app/alembic + - ${TRACECAT__LOCAL_REPOSITORY_PATH}:/app/local_registry + depends_on: + migrations: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 5s + timeout: 5s + retries: 30 + start_period: 10s + + ui: + build: + context: ./frontend + dockerfile: Dockerfile + args: + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} + NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL} + NEXT_SERVER_API_URL: ${NEXT_SERVER_API_URL} + NODE_ENV: ${NODE_ENV} + volumes: + - ./frontend/src:/app/src + - ./frontend/.next:/app/.next + - ./frontend/node_modules:/app/node_modules + restart: unless-stopped + ports: + - ${UI_PORT:-3000}:3000 + environment: + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} + NEXT_PUBLIC_APP_ENV: ${NEXT_PUBLIC_APP_ENV} + NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL} + NEXT_PUBLIC_AUTH_TYPES: ${TRACECAT__AUTH_TYPES} + NEXT_SERVER_API_URL: ${NEXT_SERVER_API_URL} + NODE_ENV: ${NODE_ENV} + TRACECAT__SERVICE_KEY: ${TRACECAT__SERVICE_KEY} + attach: false + depends_on: + - api + + postgres_db: + image: postgres:16 + restart: unless-stopped + ports: + - ${PG_PORT:-5432}:5432 + shm_size: 128mb + environment: + POSTGRES_USER: ${TRACECAT__POSTGRES_USER} + POSTGRES_PASSWORD: ${TRACECAT__POSTGRES_PASSWORD} + volumes: + - core-db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d postgres"] + interval: 5s + timeout: 5s + retries: 12 + start_period: 5s + + migrations: + build: + context: . + dockerfile: Dockerfile + target: development + restart: "no" + environment: + LOG_LEVEL: ${LOG_LEVEL} + TRACECAT__DB_URI: ${TRACECAT__DB_URI} + TRACECAT__DB_SSLMODE: ${TRACECAT__DB_SSLMODE} + TRACECAT__FEATURE_FLAGS: ${TRACECAT__FEATURE_FLAGS} + volumes: + - ./tracecat:/app/tracecat + - ./packages:/app/packages + - ./alembic:/app/alembic + command: ["python3", "-m", "alembic", "upgrade", "head"] + depends_on: + postgres_db: + condition: service_healthy + +volumes: + # Same volume name as the dev stack, so a cluster slot's data carries over + # between `-p dev` and `-p lite` (Compose scopes volumes by COMPOSE_PROJECT_NAME). + core-db: diff --git a/scripts/cluster b/scripts/cluster index 5d3410f177..1050f30620 100755 --- a/scripts/cluster +++ b/scripts/cluster @@ -9,6 +9,7 @@ # dev docker-compose.dev.yml (default) # local docker-compose.local.yml # prod docker-compose.yml +# lite docker-compose.lite.yml (control plane only: no Temporal/Redis/MinIO/workers) # # Examples: # ./cluster up -d # Start next available cluster (auto-selects number) @@ -366,8 +367,11 @@ get_compose_file() { prod) echo "${REPO_ROOT}/docker-compose.yml" ;; + lite) + echo "${REPO_ROOT}/docker-compose.lite.yml" + ;; *) - echo "Error: Unknown profile '$profile'. Valid profiles: dev, local, prod" >&2 + echo "Error: Unknown profile '$profile'. Valid profiles: dev, local, prod, lite" >&2 exit 1 ;; esac @@ -386,6 +390,10 @@ Profiles: dev docker-compose.dev.yml (default) local docker-compose.local.yml prod docker-compose.yml + lite docker-compose.lite.yml + Control plane only: Postgres + API + UI + Caddy, with no Temporal, + Redis, MinIO, or worker/executor containers. Boots in seconds for + control-plane work; workflows cannot execute. Tenant mode: Always defaults to TRACECAT__EE_MULTI_TENANT=true for cluster development. @@ -562,6 +570,15 @@ build_env() { # --ee-multi-tenant) override it; .env and shell values are ignored so # fresh worktrees behave consistently. export TRACECAT__EE_MULTI_TENANT="${CLUSTER_EE_MULTI_TENANT:-true}" + + # Lite mode is decided by the profile, not by .env. build_env() is re-run + # after the .env file is sourced, so this export always wins. + if [[ "$PROFILE" == "lite" ]]; then + export TRACECAT__LITE_MODE=true + else + export TRACECAT__LITE_MODE=false + fi + FEATURE_FLAGS=$(cd "$REPO_ROOT" && uv run python -c "from tracecat.feature_flags.enums import FeatureFlag; print(','.join(f.value for f in FeatureFlag))" 2>/dev/null) || FEATURE_FLAGS="" if [[ -n "$FEATURE_FLAGS" ]]; then export TRACECAT__FEATURE_FLAGS="$FEATURE_FLAGS" @@ -578,6 +595,15 @@ Cluster ${WORKTREE_ID}-${cluster_num} port mappings: UI (Caddy): http://localhost:${PUBLIC_APP_PORT} API: http://localhost:${PUBLIC_APP_PORT}/api (internal: ${API_PORT}) PostgreSQL: localhost:${PG_PORT} +EOF + + # The lite profile runs no data-plane containers, so those ports are unbound. + if [[ "$PROFILE" == "lite" ]]; then + echo " (lite profile: no Redis, Temporal, MinIO or MCP)" + return + fi + + cat < 0: - await configure_bucket_lifecycle( - bucket=config.TRACECAT__BLOB_STORAGE_BUCKET_WORKFLOW, - expiration_days=config.TRACECAT__WORKFLOW_ARTIFACT_RETENTION_DAYS, + # Lite mode is a development-only profile that runs the control plane with no + # Temporal, blob storage, or Redis deployed. It skips the startup work that + # needs them; request-time behaviour is deliberately left alone. + lite_mode = config.TRACECAT__LITE_MODE + if lite_mode: + logger.info( + "Lite mode enabled: skipping data-plane startup", + skipped=[ + "temporal_search_attributes", + "blob_bucket_provisioning", + "case_trigger_consumer", + "case_duration_sync_consumer", + ], + note="Features needing Temporal, Redis or blob storage will error", ) + else: + # Temporal + # Run in background to avoid blocking startup + asyncio.create_task(add_temporal_search_attributes()) + logger.debug("Spawned lifespan task to add temporal search attributes") + + # Storage + await ensure_bucket_exists(config.TRACECAT__BLOB_STORAGE_BUCKET_ATTACHMENTS) + await ensure_bucket_exists(config.TRACECAT__BLOB_STORAGE_BUCKET_REGISTRY) + await ensure_bucket_exists(config.TRACECAT__BLOB_STORAGE_BUCKET_SKILLS) + if is_feature_enabled(FeatureFlag.AGENT_FS_PERSISTENCE): + await ensure_bucket_exists(config.TRACECAT__BLOB_STORAGE_BUCKET_AGENT) + + # Workflow bucket with lifecycle expiration + await ensure_bucket_exists(config.TRACECAT__BLOB_STORAGE_BUCKET_WORKFLOW) + if config.TRACECAT__WORKFLOW_ARTIFACT_RETENTION_DAYS > 0: + await configure_bucket_lifecycle( + bucket=config.TRACECAT__BLOB_STORAGE_BUCKET_WORKFLOW, + expiration_days=config.TRACECAT__WORKFLOW_ARTIFACT_RETENTION_DAYS, + ) await ensure_default_organization() @@ -215,19 +231,22 @@ async def lifespan(app: FastAPI): ) logger.debug("Spawned background task for platform catalog load") + # Both consumers read Redis streams, which lite mode does not deploy. case_trigger_task = None - if config.TRACECAT__CASE_TRIGGERS_ENABLED: + if config.TRACECAT__CASE_TRIGGERS_ENABLED and not lite_mode: case_trigger_task = asyncio.create_task( start_case_trigger_consumer(), name="case_trigger_consumer", ) logger.debug("Spawned background task for case trigger consumer") - case_duration_sync_task = asyncio.create_task( - start_case_duration_sync_consumer(), - name="case_duration_sync_consumer", - ) - logger.debug("Spawned background task for case duration sync consumer") + case_duration_sync_task = None + if not lite_mode: + case_duration_sync_task = asyncio.create_task( + start_case_duration_sync_consumer(), + name="case_duration_sync_consumer", + ) + logger.debug("Spawned background task for case duration sync consumer") logger.info( "Feature flags", feature_flags=[f.value for f in config.TRACECAT__FEATURE_FLAGS] @@ -299,13 +318,14 @@ async def lifespan(app: FastAPI): except Exception as e: logger.warning("Case trigger consumer stopped with error", error=e) - case_duration_sync_task.cancel() - try: - await case_duration_sync_task - except asyncio.CancelledError: - logger.debug("Case duration sync consumer task cancelled") - except Exception as e: - logger.warning("Case duration sync consumer stopped with error", error=e) + if case_duration_sync_task is not None: + case_duration_sync_task.cancel() + try: + await case_duration_sync_task + except asyncio.CancelledError: + logger.debug("Case duration sync consumer task cancelled") + except Exception as e: + logger.warning("Case duration sync consumer stopped with error", error=e) await close_storage_client_cache() diff --git a/tracecat/config.py b/tracecat/config.py index 83a0a63f43..66ba1348df 100644 --- a/tracecat/config.py +++ b/tracecat/config.py @@ -1106,6 +1106,25 @@ def _parse_auth_types() -> set[AuthType]: TRACECAT__EE_MULTI_TENANT = env_bool("TRACECAT__EE_MULTI_TENANT", default=False) """Whether multi-tenant features are enabled for Enterprise Edition.""" +# === Lite mode === # +TRACECAT__LITE_MODE = env_bool("TRACECAT__LITE_MODE", default=False) +"""Development-only: run the control plane without data-plane dependencies. + +When enabled, the API skips the startup work that requires Temporal, blob +storage, or Redis, so it boots and serves control-plane features (auth, +workspaces, secrets, settings, tables, RBAC, workflow CRUD, cases) with none of +them deployed. + +This flag is deliberately confined to startup. Request-time code paths are left +untouched: calls that need a missing dependency simply fail as they already do +when a service is down. Do not add guards to the client singletons or to request +handlers on the strength of this flag. + +Always read this as `config.TRACECAT__LITE_MODE` at call time. Never import the +name directly or bind it as a default argument, or it will be evaluated once at +import and never respond to the environment. +""" + # === Feature Flags === # TRACECAT__FEATURE_FLAGS: set[FeatureFlag] = set() for _flag in os.environ.get("TRACECAT__FEATURE_FLAGS", "").split(","): diff --git a/tracecat/registry/sync/jobs.py b/tracecat/registry/sync/jobs.py index 57eb980a86..ecfbedf988 100644 --- a/tracecat/registry/sync/jobs.py +++ b/tracecat/registry/sync/jobs.py @@ -16,6 +16,7 @@ from sqlalchemy.exc import DBAPIError from sqlalchemy.ext.asyncio import AsyncSession +from tracecat import config from tracecat.authz.seeding import seed_registry_scopes from tracecat.db.engine import get_async_session_bypass_rls_context_manager from tracecat.db.locks import ( @@ -328,22 +329,38 @@ async def _build_platform_registry_artifact( ) -> None: async with get_async_session_bypass_rls_context_manager() as session: sync_service = PlatformRegistrySyncService(session) - result = await sync_service._build_and_upload_artifacts( - origin=DEFAULT_REGISTRY_ORIGIN, - version_string=target_version, - commit_sha=None, - ) - logger.info( - "Platform registry artifact build completed", - target_version=target_version, - artifact_uri=result.artifact_uri, - ) + if config.TRACECAT__LITE_MODE: + # Startup-scoped lite-mode guard (like the lifespan skips in + # api/app.py): there is no blob storage to build into, but promotion + # must still run — on the upgrade path it happens only here, and + # skipping it would leave the action listing stale. The URI is + # deterministic and computed without I/O; nothing resolves it until + # workflow execution, which lite mode does not support anyway. + artifact_uri = sync_service._artifact_uri_for_version( + origin=DEFAULT_REGISTRY_ORIGIN, version_string=target_version + ) + logger.info( + "Skipping platform registry artifact build in lite mode", + target_version=target_version, + ) + else: + result = await sync_service._build_and_upload_artifacts( + origin=DEFAULT_REGISTRY_ORIGIN, + version_string=target_version, + commit_sha=None, + ) + artifact_uri = result.artifact_uri + logger.info( + "Platform registry artifact build completed", + target_version=target_version, + artifact_uri=artifact_uri, + ) if promote_version_id is not None: await _promote_platform_registry_version_after_artifact_build( session, target_version=target_version, version_id=promote_version_id, - artifact_uri=result.artifact_uri, + artifact_uri=artifact_uri, expected_current_version_id=expected_current_version_id, )