runtm-api 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. runtm_api-0.1.0/.gitignore +84 -0
  2. runtm_api-0.1.0/LICENSE +37 -0
  3. runtm_api-0.1.0/PKG-INFO +126 -0
  4. runtm_api-0.1.0/README.md +81 -0
  5. runtm_api-0.1.0/alembic/env.py +86 -0
  6. runtm_api-0.1.0/alembic/script.py.mako +28 -0
  7. runtm_api-0.1.0/alembic/versions/20251215_0001_initial_schema.py +176 -0
  8. runtm_api-0.1.0/alembic/versions/20251228_0002_telemetry_tables.py +163 -0
  9. runtm_api-0.1.0/alembic/versions/20251229_0003_discovery_json.py +46 -0
  10. runtm_api-0.1.0/alembic/versions/20251230_0004_multi_tenant.py +179 -0
  11. runtm_api-0.1.0/alembic/versions/20251231_0005_deploy_optimization.py +66 -0
  12. runtm_api-0.1.0/alembic/versions/20260101_0006_deployment_expiry.py +77 -0
  13. runtm_api-0.1.0/alembic/versions/20260102_0007_add_ready_at.py +47 -0
  14. runtm_api-0.1.0/alembic.ini +112 -0
  15. runtm_api-0.1.0/pyproject.toml +64 -0
  16. runtm_api-0.1.0/runtm_api/__init__.py +3 -0
  17. runtm_api-0.1.0/runtm_api/auth/__init__.py +29 -0
  18. runtm_api-0.1.0/runtm_api/auth/keys.py +126 -0
  19. runtm_api-0.1.0/runtm_api/auth/token.py +486 -0
  20. runtm_api-0.1.0/runtm_api/core/__init__.py +1 -0
  21. runtm_api-0.1.0/runtm_api/core/config.py +277 -0
  22. runtm_api-0.1.0/runtm_api/db/__init__.py +44 -0
  23. runtm_api-0.1.0/runtm_api/db/models.py +604 -0
  24. runtm_api-0.1.0/runtm_api/db/repository.py +281 -0
  25. runtm_api-0.1.0/runtm_api/db/session.py +82 -0
  26. runtm_api-0.1.0/runtm_api/main.py +118 -0
  27. runtm_api-0.1.0/runtm_api/middleware/__init__.py +17 -0
  28. runtm_api-0.1.0/runtm_api/middleware/proxy.py +207 -0
  29. runtm_api-0.1.0/runtm_api/routes/__init__.py +13 -0
  30. runtm_api-0.1.0/runtm_api/routes/deployments.py +1528 -0
  31. runtm_api-0.1.0/runtm_api/routes/health.py +29 -0
  32. runtm_api-0.1.0/runtm_api/routes/me.py +90 -0
  33. runtm_api-0.1.0/runtm_api/routes/telemetry.py +379 -0
  34. runtm_api-0.1.0/runtm_api/services/__init__.py +23 -0
  35. runtm_api-0.1.0/runtm_api/services/idempotency.py +97 -0
  36. runtm_api-0.1.0/runtm_api/services/policy.py +308 -0
  37. runtm_api-0.1.0/runtm_api/services/queue.py +73 -0
  38. runtm_api-0.1.0/runtm_api/services/rate_limit.py +325 -0
  39. runtm_api-0.1.0/runtm_api/services/telemetry.py +640 -0
  40. runtm_api-0.1.0/runtm_api/services/usage.py +221 -0
  41. runtm_api-0.1.0/runtm_api/telemetry.py +144 -0
  42. runtm_api-0.1.0/tests/__init__.py +1 -0
  43. runtm_api-0.1.0/tests/test_auth.py +29 -0
  44. runtm_api-0.1.0/tests/test_health.py +26 -0
  45. runtm_api-0.1.0/tests/test_multi_tenant.py +395 -0
  46. runtm_api-0.1.0/tests/test_policy.py +311 -0
@@ -0,0 +1,84 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ /build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ *.egg-info/
24
+ .installed.cfg
25
+ *.egg
26
+
27
+ # PyInstaller
28
+ *.manifest
29
+ *.spec
30
+
31
+ # Installer logs
32
+ pip-log.txt
33
+ pip-delete-this-directory.txt
34
+
35
+ # Unit test / coverage reports
36
+ htmlcov/
37
+ .tox/
38
+ .nox/
39
+ .coverage
40
+ .coverage.*
41
+ .cache
42
+ nosetests.xml
43
+ coverage.xml
44
+ *.cover
45
+ *.py,cover
46
+ .hypothesis/
47
+ .pytest_cache/
48
+
49
+ # Translations
50
+ *.mo
51
+ *.pot
52
+
53
+ # Environments
54
+ .env
55
+ .env.local
56
+ .env.*
57
+ .venv
58
+ env/
59
+ venv/
60
+ ENV/
61
+ env.bak/
62
+ venv.bak/
63
+
64
+ # IDE
65
+ .idea/
66
+ *.swp
67
+ *.swo
68
+ *~
69
+ .cursor/
70
+ .cursor/rules/
71
+
72
+ # macOS
73
+ .DS_Store
74
+
75
+ # Project specific
76
+ /artifacts/
77
+ *.log
78
+
79
+ # Internal planning docs
80
+ .plans/
81
+
82
+ # Alembic
83
+ packages/api/alembic/versions/*.pyc
84
+
@@ -0,0 +1,37 @@
1
+ # AGPL-3.0-or-later
2
+
3
+ Copyright (c) 2025 Runtm
4
+
5
+ This program is free software: you can redistribute it and/or modify
6
+ it under the terms of the GNU Affero General Public License as published by
7
+ the Free Software Foundation, either version 3 of the License, or
8
+ (at your option) any later version.
9
+
10
+ This program is distributed in the hope that it will be useful,
11
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ GNU Affero General Public License for more details.
14
+
15
+ You should have received a copy of the GNU Affero General Public License
16
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ ---
19
+
20
+ ## Full License Text
21
+
22
+ The full text of the GNU Affero General Public License v3.0 is available at:
23
+ https://www.gnu.org/licenses/agpl-3.0.txt
24
+
25
+ ### Summary
26
+
27
+ The AGPL-3.0 license requires that:
28
+
29
+ 1. **Source Code Access**: If you run a modified version of this software as a network service, you must make the complete source code available to users of that service.
30
+
31
+ 2. **Copyleft**: Any modifications must also be licensed under AGPL-3.0.
32
+
33
+ 3. **Attribution**: You must retain copyright notices and license information.
34
+
35
+ 4. **State Changes**: You must document any changes made to the source code.
36
+
37
+ This license ensures that improvements to hosted versions of Runtm remain open source and benefit the community.
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.4
2
+ Name: runtm-api
3
+ Version: 0.1.0
4
+ Summary: FastAPI control plane for Runtm
5
+ Project-URL: Homepage, https://runtm.com
6
+ Project-URL: Documentation, https://docs.runtm.com
7
+ Project-URL: Repository, https://github.com/runtm-ai/runtm
8
+ Project-URL: Issues, https://github.com/runtm-ai/runtm/issues
9
+ Author-email: Gustavo Trigos <gus@runtm.com>
10
+ Maintainer-email: Gustavo Trigos <gus@runtm.com>
11
+ License: AGPL-3.0-or-later
12
+ License-File: LICENSE
13
+ Keywords: api,control-plane,deployment,fastapi
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Framework :: FastAPI
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.9
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
25
+ Requires-Python: >=3.9
26
+ Requires-Dist: alembic<2.0,>=1.10
27
+ Requires-Dist: asyncpg<1.0,>=0.27
28
+ Requires-Dist: fastapi<1.0,>=0.100.0
29
+ Requires-Dist: psycopg2-binary<3.0,>=2.9
30
+ Requires-Dist: pydantic-settings<3.0,>=2.0
31
+ Requires-Dist: python-multipart<1.0,>=0.0.6
32
+ Requires-Dist: redis<6.0,>=4.5
33
+ Requires-Dist: rq<3.0,>=2.3.2
34
+ Requires-Dist: runtm-shared>=0.1.0
35
+ Requires-Dist: sqlalchemy<3.0,>=2.0
36
+ Requires-Dist: uvicorn[standard]<1.0,>=0.20.0
37
+ Provides-Extra: dev
38
+ Requires-Dist: httpx<1.0,>=0.24; extra == 'dev'
39
+ Requires-Dist: mypy<2.0,>=1.0; extra == 'dev'
40
+ Requires-Dist: pytest-asyncio<1.0,>=0.21; extra == 'dev'
41
+ Requires-Dist: pytest-cov<6.0,>=4.0; extra == 'dev'
42
+ Requires-Dist: pytest<9.0,>=7.0; extra == 'dev'
43
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
44
+ Description-Content-Type: text/markdown
45
+
46
+ # runtm-api
47
+
48
+ FastAPI control plane for Runtm. See the [API docs](https://docs.runtm.com/api/overview) for endpoint reference.
49
+
50
+ **Hosted API:** [app.runtm.com](https://app.runtm.com)
51
+
52
+ ## Endpoints
53
+
54
+ | Method | Path | Description |
55
+ |--------|------|-------------|
56
+ | POST | `/v0/deployments` | Create deployment (multipart: manifest + artifact.zip) |
57
+ | GET | `/v0/deployments/:id` | Get deployment status, URL, timestamps, error |
58
+ | GET | `/v0/deployments/:id/logs` | Get build/deploy logs |
59
+
60
+ ### Creating Deployments
61
+
62
+ **POST `/v0/deployments`**
63
+
64
+ Creates a new deployment or redeploys an existing one (based on manifest name).
65
+
66
+ **Query Parameters:**
67
+ - `new=true` - Force creation of new deployment instead of redeploying existing
68
+ - `tier=starter|standard|performance` - Override machine tier from manifest
69
+
70
+ **Request:**
71
+ - `manifest` (file) - `runtm.yaml` manifest file
72
+ - `artifact` (file) - `artifact.zip` containing project files
73
+
74
+ **Headers:**
75
+ - `Authorization: Bearer <token>` - API authentication token
76
+ - `Idempotency-Key: <key>` - Optional idempotency key for retries
77
+
78
+ **Example:**
79
+ ```bash
80
+ curl -X POST "http://localhost:8000/v0/deployments?tier=performance" \
81
+ -H "Authorization: Bearer $RUNTM_API_KEY" \
82
+ -F "manifest=@runtm.yaml" \
83
+ -F "artifact=@artifact.zip"
84
+ ```
85
+
86
+ **Machine Tiers:**
87
+
88
+ All deployments use **auto-stop** for cost savings (machines stop when idle and start automatically on traffic).
89
+
90
+ | Tier | CPUs | Memory | Est. Cost |
91
+ |------|------|--------|-----------|
92
+ | `starter` (default) | 1 shared | 256MB | ~$2/month* |
93
+ | `standard` | 1 shared | 512MB | ~$5/month* |
94
+ | `performance` | 2 shared | 1GB | ~$10/month* |
95
+
96
+ *Costs are estimates for 24/7 operation. With auto-stop, costs are much lower for low-traffic services.
97
+
98
+ ## Development
99
+
100
+ ```bash
101
+ # Install dependencies
102
+ pip install -e ".[dev]"
103
+
104
+ # Run migrations
105
+ alembic upgrade head
106
+
107
+ # Start server
108
+ uvicorn runtm_api.main:app --reload --host 0.0.0.0 --port 8000
109
+
110
+ # Run tests
111
+ pytest
112
+ ```
113
+
114
+ ## Environment Variables
115
+
116
+ | Variable | Description | Default |
117
+ |----------|-------------|---------|
118
+ | `DATABASE_URL` | PostgreSQL connection string | Required |
119
+ | `REDIS_URL` | Redis connection string | Required |
120
+ | `RUNTM_API_SECRET` | API authentication secret (what server validates against) | Required |
121
+ | `ARTIFACT_STORAGE_PATH` | Local artifact storage path | `/artifacts` |
122
+ | `ARTIFACT_STORAGE_BACKEND` | Storage backend: `local` or `s3` | `local` |
123
+ | `BUCKET_NAME` | S3/Tigris bucket name (when backend=s3) | - |
124
+ | `AWS_ENDPOINT_URL_S3` | S3 endpoint URL (e.g. `https://fly.storage.tigris.dev`) | - |
125
+ | `AUTH_MODE` | Auth mode: `single_tenant` or `multi_tenant` | `single_tenant` |
126
+
@@ -0,0 +1,81 @@
1
+ # runtm-api
2
+
3
+ FastAPI control plane for Runtm. See the [API docs](https://docs.runtm.com/api/overview) for endpoint reference.
4
+
5
+ **Hosted API:** [app.runtm.com](https://app.runtm.com)
6
+
7
+ ## Endpoints
8
+
9
+ | Method | Path | Description |
10
+ |--------|------|-------------|
11
+ | POST | `/v0/deployments` | Create deployment (multipart: manifest + artifact.zip) |
12
+ | GET | `/v0/deployments/:id` | Get deployment status, URL, timestamps, error |
13
+ | GET | `/v0/deployments/:id/logs` | Get build/deploy logs |
14
+
15
+ ### Creating Deployments
16
+
17
+ **POST `/v0/deployments`**
18
+
19
+ Creates a new deployment or redeploys an existing one (based on manifest name).
20
+
21
+ **Query Parameters:**
22
+ - `new=true` - Force creation of new deployment instead of redeploying existing
23
+ - `tier=starter|standard|performance` - Override machine tier from manifest
24
+
25
+ **Request:**
26
+ - `manifest` (file) - `runtm.yaml` manifest file
27
+ - `artifact` (file) - `artifact.zip` containing project files
28
+
29
+ **Headers:**
30
+ - `Authorization: Bearer <token>` - API authentication token
31
+ - `Idempotency-Key: <key>` - Optional idempotency key for retries
32
+
33
+ **Example:**
34
+ ```bash
35
+ curl -X POST "http://localhost:8000/v0/deployments?tier=performance" \
36
+ -H "Authorization: Bearer $RUNTM_API_KEY" \
37
+ -F "manifest=@runtm.yaml" \
38
+ -F "artifact=@artifact.zip"
39
+ ```
40
+
41
+ **Machine Tiers:**
42
+
43
+ All deployments use **auto-stop** for cost savings (machines stop when idle and start automatically on traffic).
44
+
45
+ | Tier | CPUs | Memory | Est. Cost |
46
+ |------|------|--------|-----------|
47
+ | `starter` (default) | 1 shared | 256MB | ~$2/month* |
48
+ | `standard` | 1 shared | 512MB | ~$5/month* |
49
+ | `performance` | 2 shared | 1GB | ~$10/month* |
50
+
51
+ *Costs are estimates for 24/7 operation. With auto-stop, costs are much lower for low-traffic services.
52
+
53
+ ## Development
54
+
55
+ ```bash
56
+ # Install dependencies
57
+ pip install -e ".[dev]"
58
+
59
+ # Run migrations
60
+ alembic upgrade head
61
+
62
+ # Start server
63
+ uvicorn runtm_api.main:app --reload --host 0.0.0.0 --port 8000
64
+
65
+ # Run tests
66
+ pytest
67
+ ```
68
+
69
+ ## Environment Variables
70
+
71
+ | Variable | Description | Default |
72
+ |----------|-------------|---------|
73
+ | `DATABASE_URL` | PostgreSQL connection string | Required |
74
+ | `REDIS_URL` | Redis connection string | Required |
75
+ | `RUNTM_API_SECRET` | API authentication secret (what server validates against) | Required |
76
+ | `ARTIFACT_STORAGE_PATH` | Local artifact storage path | `/artifacts` |
77
+ | `ARTIFACT_STORAGE_BACKEND` | Storage backend: `local` or `s3` | `local` |
78
+ | `BUCKET_NAME` | S3/Tigris bucket name (when backend=s3) | - |
79
+ | `AWS_ENDPOINT_URL_S3` | S3 endpoint URL (e.g. `https://fly.storage.tigris.dev`) | - |
80
+ | `AUTH_MODE` | Auth mode: `single_tenant` or `multi_tenant` | `single_tenant` |
81
+
@@ -0,0 +1,86 @@
1
+ """Alembic environment configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from logging.config import fileConfig
7
+
8
+ from sqlalchemy import engine_from_config, pool
9
+
10
+ from alembic import context
11
+
12
+ # Import Base and all models for autogenerate support
13
+ from runtm_api.db.models import Base
14
+
15
+ # this is the Alembic Config object, which provides
16
+ # access to the values within the .ini file in use.
17
+ config = context.config
18
+
19
+ # Interpret the config file for Python logging.
20
+ if config.config_file_name is not None:
21
+ fileConfig(config.config_file_name)
22
+
23
+ # Model metadata for 'autogenerate' support
24
+ target_metadata = Base.metadata
25
+
26
+
27
+ def get_url() -> str:
28
+ """Get database URL from environment or config."""
29
+ return os.environ.get(
30
+ "DATABASE_URL",
31
+ config.get_main_option("sqlalchemy.url", ""),
32
+ )
33
+
34
+
35
+ def run_migrations_offline() -> None:
36
+ """Run migrations in 'offline' mode.
37
+
38
+ This configures the context with just a URL
39
+ and not an Engine, though an Engine is acceptable
40
+ here as well. By skipping the Engine creation
41
+ we don't even need a DBAPI to be available.
42
+
43
+ Calls to context.execute() here emit the given string to the
44
+ script output.
45
+ """
46
+ url = get_url()
47
+ context.configure(
48
+ url=url,
49
+ target_metadata=target_metadata,
50
+ literal_binds=True,
51
+ dialect_opts={"paramstyle": "named"},
52
+ )
53
+
54
+ with context.begin_transaction():
55
+ context.run_migrations()
56
+
57
+
58
+ def run_migrations_online() -> None:
59
+ """Run migrations in 'online' mode.
60
+
61
+ In this scenario we need to create an Engine
62
+ and associate a connection with the context.
63
+ """
64
+ configuration = config.get_section(config.config_ini_section) or {}
65
+ configuration["sqlalchemy.url"] = get_url()
66
+
67
+ connectable = engine_from_config(
68
+ configuration,
69
+ prefix="sqlalchemy.",
70
+ poolclass=pool.NullPool,
71
+ )
72
+
73
+ with connectable.connect() as connection:
74
+ context.configure(
75
+ connection=connection,
76
+ target_metadata=target_metadata,
77
+ )
78
+
79
+ with context.begin_transaction():
80
+ context.run_migrations()
81
+
82
+
83
+ if context.is_offline_mode():
84
+ run_migrations_offline()
85
+ else:
86
+ run_migrations_online()
@@ -0,0 +1,28 @@
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Sequence, Union
10
+
11
+ import sqlalchemy as sa
12
+ from alembic import op
13
+ ${imports if imports else ""}
14
+
15
+ # revision identifiers, used by Alembic.
16
+ revision: str = ${repr(up_revision)}
17
+ down_revision: Union[str, None] = ${repr(down_revision)}
18
+ branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
19
+ depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
20
+
21
+
22
+ def upgrade() -> None:
23
+ ${upgrades if upgrades else "pass"}
24
+
25
+
26
+ def downgrade() -> None:
27
+ ${downgrades if downgrades else "pass"}
28
+
@@ -0,0 +1,176 @@
1
+ """Initial schema: deployments, provider_resources, idempotency_keys, build_logs
2
+
3
+ Revision ID: 0001
4
+ Revises:
5
+ Create Date: 2025-12-15
6
+
7
+ Full database schema for Runtm including:
8
+ - deployments: Core deployment records with version tracking for redeployments
9
+ - provider_resources: Fly.io/Cloud Run resource mappings
10
+ - idempotency_keys: Safe retry support
11
+ - build_logs: Build and deploy log storage
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Sequence
17
+
18
+ import sqlalchemy as sa
19
+ from sqlalchemy.dialects import postgresql
20
+
21
+ from alembic import op
22
+
23
+ # revision identifiers, used by Alembic.
24
+ revision: str = "0001"
25
+ down_revision: str | None = None
26
+ branch_labels: str | Sequence[str] | None = None
27
+ depends_on: str | Sequence[str] | None = None
28
+
29
+
30
+ def upgrade() -> None:
31
+ # =========================================================================
32
+ # Deployments table
33
+ # =========================================================================
34
+ # Core deployment records. Each deployment represents a single version
35
+ # of a project deployed to a URL. Redeployments create new records
36
+ # linked via previous_deployment_id.
37
+ op.create_table(
38
+ "deployments",
39
+ # Primary key (internal UUID)
40
+ sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
41
+ # Human-friendly deployment ID (e.g., dep_abc123def456)
42
+ sa.Column("deployment_id", sa.String(32), unique=True, nullable=False),
43
+ # Multi-tenant fields (nullable for single-tenant mode)
44
+ sa.Column("owner_id", sa.String(64), nullable=True),
45
+ sa.Column("api_key_id", sa.String(64), nullable=True),
46
+ # Deployment metadata
47
+ sa.Column("name", sa.String(63), nullable=False),
48
+ sa.Column("state", sa.String(32), nullable=False, default="queued"),
49
+ sa.Column("artifact_key", sa.String(256), nullable=False),
50
+ sa.Column("manifest_json", postgresql.JSONB, nullable=False),
51
+ sa.Column("error_message", sa.Text, nullable=True),
52
+ sa.Column("url", sa.String(256), nullable=True),
53
+ # Version tracking for redeployments (CI/CD support)
54
+ # - version: increments on each redeploy (1, 2, 3, ...)
55
+ # - is_latest: only one deployment per name should be true
56
+ # - previous_deployment_id: links to the deployment this replaced
57
+ sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
58
+ sa.Column("is_latest", sa.Boolean(), nullable=False, server_default="true"),
59
+ sa.Column("previous_deployment_id", sa.String(32), nullable=True),
60
+ # Timestamps
61
+ sa.Column(
62
+ "created_at",
63
+ sa.DateTime(timezone=True),
64
+ nullable=False,
65
+ server_default=sa.func.now(),
66
+ ),
67
+ sa.Column(
68
+ "updated_at",
69
+ sa.DateTime(timezone=True),
70
+ nullable=False,
71
+ server_default=sa.func.now(),
72
+ ),
73
+ )
74
+ op.create_index("ix_deployments_deployment_id", "deployments", ["deployment_id"])
75
+ op.create_index("ix_deployments_owner_id", "deployments", ["owner_id"])
76
+ op.create_index("ix_deployments_state", "deployments", ["state"])
77
+ op.create_index("ix_deployments_created_at", "deployments", ["created_at"])
78
+ op.create_index("ix_deployments_name", "deployments", ["name"])
79
+ op.create_index("ix_deployments_name_is_latest", "deployments", ["name", "is_latest"])
80
+
81
+ # Partial unique index: only one is_latest=true per (owner_id, name)
82
+ # This ensures at most one active deployment per name per owner
83
+ # Excludes destroyed/failed deployments from the constraint
84
+ # Note: SQLAlchemy with native_enum=False stores enum names (uppercase), not values
85
+ op.execute("""
86
+ CREATE UNIQUE INDEX ix_deployments_unique_active_name
87
+ ON deployments (COALESCE(owner_id, ''), name)
88
+ WHERE is_latest = true AND state NOT IN ('DESTROYED', 'FAILED')
89
+ """)
90
+
91
+ # =========================================================================
92
+ # Provider resources table
93
+ # =========================================================================
94
+ # Maps deployments to provider-specific resources (Fly.io machines, etc.)
95
+ op.create_table(
96
+ "provider_resources",
97
+ sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
98
+ sa.Column(
99
+ "deployment_id",
100
+ postgresql.UUID(as_uuid=True),
101
+ sa.ForeignKey("deployments.id", ondelete="CASCADE"),
102
+ nullable=False,
103
+ unique=True,
104
+ ),
105
+ sa.Column("provider", sa.String(32), nullable=False),
106
+ sa.Column("app_name", sa.String(128), nullable=False),
107
+ sa.Column("machine_id", sa.String(128), nullable=False),
108
+ sa.Column("region", sa.String(32), nullable=False),
109
+ sa.Column("image_ref", sa.String(256), nullable=False),
110
+ sa.Column(
111
+ "created_at",
112
+ sa.DateTime(timezone=True),
113
+ nullable=False,
114
+ server_default=sa.func.now(),
115
+ ),
116
+ )
117
+ op.create_index("ix_provider_resources_app_name", "provider_resources", ["app_name"])
118
+ op.create_index("ix_provider_resources_provider", "provider_resources", ["provider"])
119
+
120
+ # =========================================================================
121
+ # Idempotency keys table
122
+ # =========================================================================
123
+ # Maps Idempotency-Key headers to deployments for safe retries
124
+ op.create_table(
125
+ "idempotency_keys",
126
+ sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
127
+ sa.Column("key", sa.String(64), unique=True, nullable=False),
128
+ sa.Column(
129
+ "deployment_id",
130
+ postgresql.UUID(as_uuid=True),
131
+ sa.ForeignKey("deployments.id", ondelete="CASCADE"),
132
+ nullable=False,
133
+ ),
134
+ sa.Column(
135
+ "created_at",
136
+ sa.DateTime(timezone=True),
137
+ nullable=False,
138
+ server_default=sa.func.now(),
139
+ ),
140
+ sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
141
+ )
142
+ op.create_index("ix_idempotency_keys_key", "idempotency_keys", ["key"])
143
+ op.create_index("ix_idempotency_keys_expires_at", "idempotency_keys", ["expires_at"])
144
+
145
+ # =========================================================================
146
+ # Build logs table
147
+ # =========================================================================
148
+ # Stores build and deploy logs for each deployment
149
+ op.create_table(
150
+ "build_logs",
151
+ sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
152
+ sa.Column(
153
+ "deployment_id",
154
+ postgresql.UUID(as_uuid=True),
155
+ sa.ForeignKey("deployments.id", ondelete="CASCADE"),
156
+ nullable=False,
157
+ ),
158
+ sa.Column("log_type", sa.String(32), nullable=False),
159
+ sa.Column("content", sa.Text, nullable=False),
160
+ sa.Column(
161
+ "created_at",
162
+ sa.DateTime(timezone=True),
163
+ nullable=False,
164
+ server_default=sa.func.now(),
165
+ ),
166
+ )
167
+ op.create_index("ix_build_logs_deployment_id", "build_logs", ["deployment_id"])
168
+ op.create_index("ix_build_logs_log_type", "build_logs", ["log_type"])
169
+
170
+
171
+ def downgrade() -> None:
172
+ op.execute("DROP INDEX IF EXISTS ix_deployments_unique_active_name")
173
+ op.drop_table("build_logs")
174
+ op.drop_table("idempotency_keys")
175
+ op.drop_table("provider_resources")
176
+ op.drop_table("deployments")