Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
20 changes: 20 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
164 changes: 164 additions & 0 deletions docker-compose.lite.yml
Original file line number Diff line number Diff line change
@@ -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:
35 changes: 32 additions & 3 deletions scripts/cluster
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -366,8 +367,11 @@ get_compose_file() {
prod)
echo "${REPO_ROOT}/docker-compose.yml"
;;
lite)
echo "${REPO_ROOT}/docker-compose.lite.yml"
Comment thread
daryllimyt marked this conversation as resolved.
;;
*)
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
Expand All @@ -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.
Expand Down Expand Up @@ -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"
Expand All @@ -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 <<EOF
Redis: localhost:${REDIS_PORT_HOST}
Temporal: localhost:${TEMPORAL_PORT}
Temporal UI: http://localhost:${TEMPORAL_UI_PORT}
Expand Down Expand Up @@ -847,7 +873,10 @@ fi
# Get compose file for profile
COMPOSE_FILE=$(get_compose_file "$PROFILE")
COMPOSE_FILES=(-f "$COMPOSE_FILE")
if [[ "$CLUSTER_USE_SANDBOX_COMPOSE" == "true" ]]; then
# The sandbox overlay only patches executor/agent-executor, which the lite
# profile does not run. Layering it there would declare those services with
# neither an image nor a build context, which Compose rejects outright.
if [[ "$CLUSTER_USE_SANDBOX_COMPOSE" == "true" && "$PROFILE" != "lite" ]]; then
COMPOSE_FILES+=(-f "${REPO_ROOT}/docker-compose.sandbox.yml")
fi

Expand Down Expand Up @@ -1119,7 +1148,7 @@ fi

# Check for seed flags in up command. Dev profile seeds by default.
SEED_USER=false
if [[ "$COMMAND" == "up" && "$PROFILE" == "dev" ]]; then
if [[ "$COMMAND" == "up" && ("$PROFILE" == "dev" || "$PROFILE" == "lite") ]]; then
SEED_USER=true
fi
DETACHED_UP=false
Expand Down
1 change: 1 addition & 0 deletions tests/unit/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
REPO_ROOT / "docker-compose.yml",
REPO_ROOT / "docker-compose.dev.yml",
REPO_ROOT / "docker-compose.local.yml",
REPO_ROOT / "docker-compose.lite.yml",
)
ENV_EXAMPLE_FILES = (REPO_ROOT / ".env.example",)
DEPLOYMENT_ENV_FILES = (*COMPOSE_ENV_FILES, *ENV_EXAMPLE_FILES)
Expand Down
82 changes: 51 additions & 31 deletions tracecat/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,25 +176,41 @@ async def lifespan(app: FastAPI):
get_user_auth_secret()
assert_soft_delete_listener_registered()

# 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,
# 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()

Expand All @@ -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]
Expand Down Expand Up @@ -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()

Expand Down
Loading
Loading