diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7ad9171..2425b39 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -35,6 +35,15 @@ repos: types: [rust] pass_filenames: false +- repo: https://github.com/python-jsonschema/check-jsonschema + rev: 0.37.4 + hooks: + - id: check-jsonschema + name: Validate Render Blueprint + files: ^(render\.yaml|schemas/render-blueprint\.schema\.json)$ + pass_filenames: false + args: [--schemafile, schemas/render-blueprint.schema.json, render.yaml] + ci: skip: [rustfmt] autofix_commit_msg: '[pre-commit.ci] Auto format from pre-commit.com hooks' diff --git a/Cargo.lock b/Cargo.lock index 7a7d66e..8eaa871 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,6 +24,7 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", + "serde_yml", "thiserror", "tokio", "tokio-tungstenite", diff --git a/Cargo.toml b/Cargo.toml index 82f3388..ac40857 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ rmcp-reqwest = { package = "reqwest", version = "0.13.2", default-features = fal rmcp = { version = "1.8", default-features = false } serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_yml = "0.0.12" thiserror = "2" tokio = { version = "1", features = ["full"] } tokio-util = "0.7" diff --git a/README.md b/README.md index 51e9767..96e0d45 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,9 @@ uv pip install -r docs/requirements.txt uv run mkdocs serve ``` +For deployment, see the [production container](docs/deploying/container.md) and +[Render Blueprint](docs/deploying/render.md) guides. + Design and migration decisions are tracked as ADRs in [docs/adr/](docs/adr/), with deeper design notes in [docs/design/](docs/design/). See the full [ROADMAP](ROADMAP.md) for where the project is heading, and [CONTRIBUTING](CONTRIBUTING.md) to get involved. ## 🗺️ Roadmap at a Glance diff --git a/crates/agentic-server-core/Cargo.toml b/crates/agentic-server-core/Cargo.toml index e84ec6a..49e9d96 100644 --- a/crates/agentic-server-core/Cargo.toml +++ b/crates/agentic-server-core/Cargo.toml @@ -44,7 +44,7 @@ uuid = { version = "1", features = ["v7", "serde"] } axum.workspace = true criterion = { workspace = true } serde_yaml = "0.9" -serde_yml = "0.0.12" +serde_yml.workspace = true tokio = { workspace = true, features = ["full"] } [[bench]] diff --git a/crates/agentic-server/Cargo.toml b/crates/agentic-server/Cargo.toml index 2d0f356..65e4f2f 100644 --- a/crates/agentic-server/Cargo.toml +++ b/crates/agentic-server/Cargo.toml @@ -30,6 +30,7 @@ criterion.workspace = true futures.workspace = true reqwest = { workspace = true, features = ["json"] } serde_json.workspace = true +serde_yml.workspace = true tokio = { workspace = true, features = ["test-util"] } tokio-tungstenite.workspace = true uuid = { version = "1", features = ["v7"] } diff --git a/crates/agentic-server/src/handler/http/models.rs b/crates/agentic-server/src/handler/http/models.rs index cc9251b..1568fcd 100644 --- a/crates/agentic-server/src/handler/http/models.rs +++ b/crates/agentic-server/src/handler/http/models.rs @@ -117,7 +117,17 @@ pub async fn ready(State(state): State) -> impl IntoResponse { return StatusCode::SERVICE_UNAVAILABLE; }; - match client.get(&url).send().await { + let mut request = client.get(&url); + if let Some(key) = state + .openai_api_key + .as_deref() + .map(str::trim) + .filter(|key| !key.is_empty()) + { + request = request.bearer_auth(key); + } + + match request.send().await { Ok(resp) if resp.status().is_success() => StatusCode::OK, Ok(resp) => { warn!("LLM backend not ready: {}", resp.status()); diff --git a/crates/agentic-server/src/lib.rs b/crates/agentic-server/src/lib.rs index 55f41a0..d9a74a2 100644 --- a/crates/agentic-server/src/lib.rs +++ b/crates/agentic-server/src/lib.rs @@ -1,2 +1,7 @@ +use std::time::Duration; + pub mod app; pub mod handler; + +/// Maximum time allowed for in-flight requests and `WebSockets` to drain during shutdown. +pub const GATEWAY_DRAIN_TIMEOUT: Duration = Duration::from_secs(8); diff --git a/crates/agentic-server/src/server.rs b/crates/agentic-server/src/server.rs index c9cd19f..a3c95c9 100644 --- a/crates/agentic-server/src/server.rs +++ b/crates/agentic-server/src/server.rs @@ -1,20 +1,18 @@ use std::future::Future; use std::pin::Pin; use std::sync::Arc; -use std::time::Duration; use agentic_core::config::Config; use agentic_core::error::Error; use agentic_core::executor::ExecutionContext; use agentic_core::proxy::ProxyState; use agentic_core::readiness::wait_llm_ready; +use agentic_server::GATEWAY_DRAIN_TIMEOUT; use agentic_server::app::{AppState, ServerConfig, WebSocketTracker, build_router}; use tokio::net::TcpListener; use tokio_util::sync::CancellationToken; use tracing::{info, warn}; -const GATEWAY_DRAIN_TIMEOUT: Duration = Duration::from_secs(8); - async fn build_state(config: &Config, shutdown_token: CancellationToken) -> Result { let proxy_state = ProxyState::new(config.clone())?; let exec_ctx = Arc::new(ExecutionContext::from_config(config).await?); diff --git a/crates/agentic-server/tests/health_test.rs b/crates/agentic-server/tests/health_test.rs index 72d6dcb..f441b6e 100644 --- a/crates/agentic-server/tests/health_test.rs +++ b/crates/agentic-server/tests/health_test.rs @@ -1,7 +1,12 @@ mod common; use agentic_core::config::Config; +use axum::Router; +use axum::http::HeaderMap; +use axum::routing::get; use common::{spawn_gateway, spawn_mock_llm, test_config, test_state}; +use http::StatusCode; +use tokio::net::TcpListener; fn test_config_no_key(llm_url: &str) -> Config { Config { @@ -10,6 +15,33 @@ fn test_config_no_key(llm_url: &str) -> Config { } } +async fn spawn_health_mock(status: StatusCode) -> (String, tokio::task::JoinHandle<()>) { + let app = Router::new().route("/health", get(move || async move { status })); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (format!("http://{addr}"), handle) +} + +async fn spawn_authenticated_health_mock() -> (String, tokio::task::JoinHandle<()>) { + let app = Router::new().route( + "/health", + get(|headers: HeaderMap| async move { + match headers + .get(http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + { + Some("Bearer test-key") => StatusCode::OK, + _ => StatusCode::UNAUTHORIZED, + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (format!("http://{addr}"), handle) +} + #[tokio::test] async fn test_health_returns_200() { let (llm_url, _h1) = spawn_mock_llm().await; @@ -20,10 +52,8 @@ async fn test_health_returns_200() { #[tokio::test] async fn test_health_returns_200_even_when_llm_down() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let dead_addr = listener.local_addr().unwrap(); - drop(listener); - let (gw_url, _h2) = spawn_gateway(test_state(&test_config_no_key(&format!("http://{dead_addr}")))).await; + let (llm_url, _h1) = spawn_health_mock(StatusCode::SERVICE_UNAVAILABLE).await; + let (gw_url, _h2) = spawn_gateway(test_state(&test_config_no_key(&llm_url))).await; let resp = reqwest::get(format!("{gw_url}/health")).await.unwrap(); assert_eq!(resp.status(), 200); } @@ -37,11 +67,17 @@ async fn test_ready_returns_200_when_llm_healthy() { } #[tokio::test] -async fn test_ready_returns_503_when_llm_unreachable() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let dead_addr = listener.local_addr().unwrap(); - drop(listener); - let (gw_url, _h2) = spawn_gateway(test_state(&test_config_no_key(&format!("http://{dead_addr}")))).await; +async fn test_ready_returns_503_when_llm_unhealthy() { + let (llm_url, _h1) = spawn_health_mock(StatusCode::SERVICE_UNAVAILABLE).await; + let (gw_url, _h2) = spawn_gateway(test_state(&test_config_no_key(&llm_url))).await; let resp = reqwest::get(format!("{gw_url}/ready")).await.unwrap(); assert_eq!(resp.status(), 503); } + +#[tokio::test] +async fn test_ready_forwards_configured_api_key() { + let (llm_url, _h1) = spawn_authenticated_health_mock().await; + let (gw_url, _h2) = spawn_gateway(test_state(&test_config(&llm_url))).await; + let resp = reqwest::get(format!("{gw_url}/ready")).await.unwrap(); + assert_eq!(resp.status(), 200); +} diff --git a/crates/agentic-server/tests/render_blueprint_test.rs b/crates/agentic-server/tests/render_blueprint_test.rs new file mode 100644 index 0000000..fd96d68 --- /dev/null +++ b/crates/agentic-server/tests/render_blueprint_test.rs @@ -0,0 +1,147 @@ +use std::fs; +use std::path::PathBuf; + +use serde::Deserialize; + +use agentic_server::GATEWAY_DRAIN_TIMEOUT; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Blueprint { + projects: Vec, +} + +#[derive(Debug, Deserialize)] +struct Project { + name: String, + environments: Vec, +} + +#[derive(Debug, Deserialize)] +struct Environment { + name: String, + networking: Networking, + services: Vec, + databases: Vec, +} + +#[derive(Debug, Deserialize)] +struct Networking { + isolation: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Service { + name: String, + #[serde(rename = "type")] + kind: String, + runtime: String, + plan: String, + dockerfile_path: String, + docker_context: String, + auto_deploy_trigger: String, + health_check_path: Option, + max_shutdown_delay_seconds: u16, + env_vars: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct EnvVar { + key: String, + value: Option, + sync: Option, + from_database: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DatabaseReference { + name: String, + property: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Database { + name: String, + plan: String, + ip_allow_list: Vec, +} + +fn load_blueprint() -> Blueprint { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../render.yaml"); + let yaml = fs::read_to_string(&path).unwrap_or_else(|error| panic!("read {}: {error}", path.display())); + serde_yml::from_str(&yaml).unwrap_or_else(|error| panic!("parse {}: {error}", path.display())) +} + +fn env_var<'a>(service: &'a Service, key: &str) -> &'a EnvVar { + service + .env_vars + .iter() + .find(|env_var| env_var.key == key) + .unwrap_or_else(|| panic!("{key} must be configured")) +} + +#[test] +fn render_blueprint_wires_the_gateway_to_managed_postgres() { + let blueprint = load_blueprint(); + let [project] = blueprint.projects.as_slice() else { + panic!("blueprint must define exactly one project"); + }; + let [environment] = project.environments.as_slice() else { + panic!("project must define exactly one environment"); + }; + let [service] = environment.services.as_slice() else { + panic!("blueprint must define exactly one service"); + }; + let [database] = environment.databases.as_slice() else { + panic!("blueprint must define exactly one database"); + }; + + assert_eq!(project.name, "agentic-api"); + assert_eq!(environment.name, "production"); + assert_eq!(environment.networking.isolation, "enabled"); + + assert_eq!(service.name, "agentic-api"); + assert_eq!(service.kind, "pserv"); + assert_eq!(service.runtime, "docker"); + assert_eq!(service.plan, "starter"); + assert_eq!(service.dockerfile_path, "./Dockerfile"); + assert_eq!(service.docker_context, "."); + assert_eq!(service.auto_deploy_trigger, "checksPass"); + assert_eq!( + service.health_check_path, None, + "private services only support TCP health checks" + ); + assert!(u64::from(service.max_shutdown_delay_seconds) > GATEWAY_DRAIN_TIMEOUT.as_secs()); + + assert_eq!(env_var(service, "GATEWAY_PORT").value.as_deref(), Some("9000")); + + let llm_api_base = env_var(service, "LLM_API_BASE"); + assert_eq!(llm_api_base.sync, Some(false)); + assert_eq!(llm_api_base.value, None); + + let upstream_key = env_var(service, "OPENAI_API_KEY"); + assert_eq!(upstream_key.sync, Some(false)); + assert_eq!(upstream_key.value, None); + + let skip_llm_ready_check = env_var(service, "SKIP_LLM_READY_CHECK"); + assert_eq!(skip_llm_ready_check.sync, Some(false)); + assert_eq!(skip_llm_ready_check.value, None); + + let database_url = env_var(service, "DATABASE_URL") + .from_database + .as_ref() + .expect("DATABASE_URL must reference managed Postgres"); + assert_eq!(database_url.name, database.name); + assert_eq!(database_url.property, "connectionString"); + + assert_eq!(database.name, "agentic-api-postgres"); + assert_eq!(database.plan, "basic-256mb"); + assert!( + database.ip_allow_list.is_empty(), + "managed Postgres must not accept public connections" + ); +} diff --git a/docs/deploying/container.md b/docs/deploying/container.md index ecb736d..1e1e067 100644 --- a/docs/deploying/container.md +++ b/docs/deploying/container.md @@ -30,7 +30,7 @@ The image starts `agentic-server` in standalone mode. At minimum, set `LLM_API_B | `GATEWAY_PORT` | `9000` | Listen port | | `DATABASE_URL` | `sqlite://./agentic_api.db` | SQLite or PostgreSQL persistence URL | | `OPENAI_API_KEY` | none | Credential sent to the upstream service when the client does not supply one | -| `SKIP_LLM_READY_CHECK` | `false` | Skip the startup probe for hosted providers without `/health` | +| `SKIP_LLM_READY_CHECK` | `false` | Skip the startup probe for providers without `/health` | | `CORS_ALLOWED_ORIGINS` | none | Comma-separated browser origins | The container entrypoint rejects percent-encoded SQLite paths because SQLx decodes them before opening the database. Use a literal filesystem path or PostgreSQL instead. diff --git a/docs/deploying/render.md b/docs/deploying/render.md new file mode 100644 index 0000000..a5d4105 --- /dev/null +++ b/docs/deploying/render.md @@ -0,0 +1,109 @@ +# Deploy agentic-api on Render + +The repository includes a Render Blueprint that builds the production container, creates a managed PostgreSQL +database, and connects the gateway to an external OpenAI-compatible inference service. Because the gateway does not +authenticate inbound callers yet, the Blueprint creates a private service with no public URL in a network-isolated +production environment. The default Blueprint uses paid `starter` compute and `basic-256mb` PostgreSQL plans. +Network isolation requires a Pro workspace or higher; review current Render pricing before creating it. + +## Create the Blueprint + +1. In the Render Dashboard, choose **New > Blueprint** and connect this repository. +2. Select the repository's `render.yaml`. +3. Enter `LLM_API_BASE`, including the scheme and host of the external inference service. +4. Enter `OPENAI_API_KEY` when the inference service requires one. For an unauthenticated vLLM endpoint, leave it + empty or remove the variable before creating the Blueprint. +5. Enter `false` for `SKIP_LLM_READY_CHECK` when the inference service exposes `/health`, or `true` when it does not. + Render preserves this operator-supplied value on later Blueprint syncs. +6. Review the proposed resources and create the Blueprint. + +Render builds the existing `Dockerfile` with BuildKit and starts its `docker-entrypoint.sh`. The service binds to +`0.0.0.0:9000`. Render does not assign it a public `onrender.com` URL, and the production environment blocks private +network traffic from other environments in the workspace. `DATABASE_URL` comes from the managed database's private +`connectionString`; no database credential is committed to the repository. The database blocks public connections +because its `ipAllowList` is empty. + +The gateway applies its storage schema during startup. A failed database connection or schema initialization prevents +the process from listening, so Render does not promote that deploy. + +## Configure readiness + +Render private services support TCP health checks, so Render verifies that the gateway is listening on port `9000` +but does not call `/ready`. By default, the gateway still calls `/health` before it starts listening; +failure to reach a healthy inference service therefore prevents the deploy from becoming ready. Configure the +authenticated edge to call `/ready` for upstream monitoring. That endpoint forwards the configured upstream bearer +credential when present and returns `503 Service Unavailable` when the inference service is unhealthy. It does not +check PostgreSQL; use the persistence smoke test below for that dependency. + +Some hosted OpenAI-compatible providers do not expose `/health`. For those providers, set +`SKIP_LLM_READY_CHECK=true` during Blueprint creation. This skips startup polling only. Because `/ready` still calls +`/health`, monitor such providers with an authenticated `/v1/models` or small Responses API request instead. + +The Blueprint gives Render 30 seconds to stop an old instance. The gateway uses the first eight seconds to drain HTTP +requests and WebSockets before closing remaining connections. + +## Add an authenticated edge + +The gateway does not authenticate inbound callers yet. `OPENAI_API_KEY` is an upstream credential and is not a client +password. Keep the gateway private and put a separate authenticated web service or another identity-aware proxy in +front of it. Move that edge into the same network-isolated `agentic-api` production environment so it can reach the +gateway; services outside the environment cannot bypass it over the private network. The edge must authenticate every +`/v1/*` HTTP and WebSocket request before forwarding it. Do not convert the gateway itself to a public `web` service +until it has an application identity boundary; otherwise anonymous callers could spend the upstream credential or +write stored state. + +Keep every secret in Render environment variables or an environment group. Never place credentials in `render.yaml`, +Docker build arguments, or the repository. + +## Verify the deployment + +From a machine with `curl` and `jq` that can reach the authenticated edge, set the edge's public URL and the client +credential it requires: + +```console +export AGENTIC_API_URL=https://agentic-api.example.com +export AGENTIC_API_AUTH='Authorization: Bearer replace-me' +curl --fail --header "$AGENTIC_API_AUTH" "$AGENTIC_API_URL/health" +curl --fail --header "$AGENTIC_API_AUTH" "$AGENTIC_API_URL/ready" +``` + +Exercise persistence with a stored response. Replace the model name with one served by the configured inference +backend: + +```console +first_response_id=$( + curl --fail --silent --show-error "$AGENTIC_API_URL/v1/responses" \ + --header "$AGENTIC_API_AUTH" \ + --header "Content-Type: application/json" \ + --data '{"model":"Qwen/Qwen3-30B-A3B-FP8","input":"Reply with READY","store":true}' | + jq --exit-status --raw-output .id +) + +curl --fail --silent --show-error "$AGENTIC_API_URL/v1/responses" \ + --header "$AGENTIC_API_AUTH" \ + --header "Content-Type: application/json" \ + --data "$(jq --null-input --arg id "$first_response_id" '{ + model: "Qwen/Qwen3-30B-A3B-FP8", + input: "What word did you return?", + previous_response_id: $id, + store: true + }')" +``` + +Redeploy the service, then repeat the continuation using the same previous response ID. A successful continuation +confirms that state survived the replacement of the gateway instance. + +External WebSocket clients connect to the authenticated edge with `wss`; the edge can proxy to the private service +over Render's private network. Render can replace instances during deploys and does not guarantee that a reconnect +reaches the same instance, so clients should send keepalive pings and reconnect with backoff. Server-sent event +streams use the same authenticated HTTPS edge and need no additional Render process. + +## Production considerations + +- Keep the gateway, managed PostgreSQL, and inference service in nearby regions to reduce request and persistence + latency. +- Configure PostgreSQL backups and retention in Render according to the application's recovery objectives. +- The Blueprint intentionally does not attach a persistent disk. Gateway state belongs in PostgreSQL, and Render's + service filesystem is ephemeral. +- Render is the simpler single-service path. Use Kubernetes when the deployment needs custom ingress policy, + independent migration jobs, advanced autoscaling, or cluster-level network controls. diff --git a/mkdocs.yaml b/mkdocs.yaml index f50ee25..7075fe3 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -96,6 +96,7 @@ nav: - Getting Started: developing/getting-started.md - Deploying: - Container: deploying/container.md + - Render: deploying/render.md - Community: community/index.md extra: diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..3086857 --- /dev/null +++ b/render.yaml @@ -0,0 +1,34 @@ +# yaml-language-server: $schema=https://render.com/schema/render.yaml.json + +projects: + - name: agentic-api + environments: + - name: production + networking: + isolation: enabled + services: + - type: pserv + name: agentic-api + runtime: docker + plan: starter + dockerfilePath: ./Dockerfile + dockerContext: . + autoDeployTrigger: checksPass + maxShutdownDelaySeconds: 30 + envVars: + - key: GATEWAY_PORT + value: "9000" + - key: DATABASE_URL + fromDatabase: + name: agentic-api-postgres + property: connectionString + - key: LLM_API_BASE + sync: false + - key: OPENAI_API_KEY + sync: false + - key: SKIP_LLM_READY_CHECK + sync: false + databases: + - name: agentic-api-postgres + plan: basic-256mb + ipAllowList: [] diff --git a/schemas/README.md b/schemas/README.md new file mode 100644 index 0000000..5ff4c66 --- /dev/null +++ b/schemas/README.md @@ -0,0 +1,12 @@ +# Vendored schemas + +`render-blueprint.schema.json` is a validation-equivalent snapshot of Render's official +[`render.yaml` schema](https://render.com/schema/render.yaml.json), fetched on 2026-07-23. Descriptive-only fields were +removed to keep the repository copy compact; validation keywords and constraints are unchanged. + +To update it, download the official schema, review the upstream diff, remove the `description`, `title`, and +`deprecated` annotations, and run: + +```console +SKIP=no-commit-to-branch pre-commit run check-jsonschema --all-files +``` diff --git a/schemas/render-blueprint.schema.json b/schemas/render-blueprint.schema.json new file mode 100644 index 0000000..b0196d8 --- /dev/null +++ b/schemas/render-blueprint.schema.json @@ -0,0 +1 @@ +{"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://render.com/schema/render.yaml.json","type":"object","definitions":{"autoDeployTrigger":{"type":"string","enum":["off","commit","checksPass"]},"buildFilter":{"type":"object","properties":{"paths":{"type":"array","items":{"type":"string"}},"ignoredPaths":{"type":"array","items":{"type":"string"}}}},"diskSizeGB":{"type":"integer","minimum":1},"storageAutoscalingEnabled":{"type":"boolean"},"connectionPool":{"type":"string","enum":["pgbouncer","none"]},"databaseEnvVarProperty":{"type":"string","enum":["connectionString","connectionPoolString","host","port","user","password","database"]},"serviceEnvVarProperty":{"type":"string","enum":["host","port","hostport","connectionString"]},"cronService":{"type":"object","properties":{"type":{"type":"string","const":"cron"},"name":{"type":"string"},"region":{"$ref":"#/definitions/region"},"plan":{"$ref":"#/definitions/plan"},"runtime":{"$ref":"#/definitions/runtime"},"schedule":{"type":"string"},"buildCommand":{"type":"string"},"startCommand":{"type":"string"},"dockerCommand":{"type":"string"},"dockerfilePath":{"type":"string"},"dockerContext":{"type":"string"},"registryCredential":{"$ref":"#/definitions/registryCredential"},"repo":{"type":"string"},"branch":{"type":"string"},"image":{"$ref":"#/definitions/image"},"envVars":{"type":"array","items":{"$ref":"#/definitions/envVar"}},"buildFilter":{"$ref":"#/definitions/buildFilter"},"rootDir":{"type":"string"},"autoDeploy":{"type":"boolean"},"autoDeployTrigger":{"$ref":"#/definitions/autoDeployTrigger"},"preDeployCommand":{"type":"string"}},"required":["type","name","runtime","schedule"],"additionalProperties":false},"database":{"type":"object","properties":{"name":{"type":"string"},"databaseName":{"type":"string"},"user":{"type":"string"},"region":{"$ref":"#/definitions/region"},"plan":{"$ref":"#/definitions/plan"},"diskSizeGB":{"$ref":"#/definitions/diskSizeGB"},"storageAutoscalingEnabled":{"$ref":"#/definitions/storageAutoscalingEnabled"},"connectionPool":{"$ref":"#/definitions/connectionPool"},"previewPlan":{"$ref":"#/definitions/plan"},"previewDiskSizeGB":{"$ref":"#/definitions/diskSizeGB"},"postgresMajorVersion":{"type":"string","enum":["10","11","12","13","14","15","16","17","18"]},"highAvailability":{"type":"object","properties":{"enabled":{"type":"boolean"}},"required":["enabled"],"additionalProperties":false},"ipAllowList":{"$ref":"#/definitions/ipAllowList"},"readReplicas":{"type":"array","items":{"$ref":"#/definitions/readReplica"}}},"required":["name"],"additionalProperties":false},"disk":{"type":"object","properties":{"name":{"type":"string"},"mountPath":{"type":"string"},"sizeGB":{"type":"integer","minimum":1}},"required":["name","mountPath"],"additionalProperties":false},"environment":{"type":"object","allOf":[{"$ref":"#/definitions/resources"},{"type":"object","properties":{"name":{"type":"string"},"networking":{"type":"object","properties":{"isolation":{"type":"string","enum":["enabled","disabled"],"default":"disabled"}},"additionalProperties":false},"permissions":{"type":"object","properties":{"protection":{"type":"string","enum":["enabled","disabled"],"default":"disabled"}},"additionalProperties":false}},"required":["name"]}],"unevaluatedProperties":false},"envVar":{"anyOf":[{"$ref":"#/definitions/envVarFromKeyValue"},{"$ref":"#/definitions/envVarFromDatabase"},{"$ref":"#/definitions/envVarFromService"},{"$ref":"#/definitions/envVarFromGroup"}]},"envVarFromDatabase":{"type":"object","properties":{"key":{"type":"string"},"fromDatabase":{"type":"object","properties":{"name":{"type":"string"},"property":{"$ref":"#/definitions/databaseEnvVarProperty"}},"required":["name","property"]}},"required":["key","fromDatabase"],"additionalProperties":false},"envVarFromKeyValue":{"type":"object","properties":{"key":{"type":"string"},"value":{"anyOf":[{"type":"string"},{"type":"number"}]},"generateValue":{"type":"boolean"},"sync":{"type":"boolean"},"previewValue":{"anyOf":[{"type":"string"},{"type":"number"}]}},"required":["key"],"additionalProperties":false},"envVarFromService":{"type":"object","properties":{"key":{"type":"string"},"fromService":{"type":"object","properties":{"type":{"$ref":"#/definitions/serviceType"},"name":{"type":"string"},"property":{"$ref":"#/definitions/serviceEnvVarProperty"},"envVarKey":{"type":"string"}},"required":["name","type"]}},"required":["key","fromService"],"additionalProperties":false},"envVarFromGroup":{"type":"object","properties":{"fromGroup":{"type":"string"}},"required":["fromGroup"],"additionalProperties":false},"envVarGroup":{"properties":{"name":{"type":"string"},"envVars":{"type":"array","items":{"$ref":"#/definitions/envVarFromKeyValue"}}},"required":["name","envVars"],"additionalProperties":false},"header":{"type":"object","properties":{"path":{"type":"string"},"name":{"type":"string"},"value":{"type":"string"}},"required":["path","name","value"]},"image":{"type":"object","properties":{"url":{"type":"string"},"creds":{"$ref":"#/definitions/registryCredential"}},"required":["url"],"additionalProperties":false},"readReplica":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"],"additionalProperties":false},"ipAllowList":{"type":"array","items":{"type":"object","properties":{"source":{"type":"string"}},"required":["source"]}},"numInstances":{"type":"integer","minimum":1},"plan":{"type":"string","enum":["free","starter","standard","pro","pro plus","pro max","pro ultra","basic-256mb","basic-1gb","basic-4gb","pro-4gb","pro-8gb","pro-16gb","pro-32gb","pro-64gb","pro-128gb","pro-192gb","pro-256gb","pro-384gb","pro-512gb","accelerated-16gb","accelerated-32gb","accelerated-64gb","accelerated-128gb","accelerated-256gb","accelerated-384gb","accelerated-512gb","accelerated-768gb","accelerated-1024gb"]},"previewsGeneration":{"type":"string","enum":["automatic","manual","off"]},"project":{"type":"object","properties":{"name":{"type":"string"},"environments":{"type":"array","items":{"$ref":"#/definitions/environment"}}},"required":["name","environments"],"additionalProperties":false},"servicePreviews":{"type":"object","properties":{"generation":{"$ref":"#/definitions/previewsGeneration"},"plan":{"$ref":"#/definitions/plan"},"numInstances":{"$ref":"#/definitions/numInstances"}},"additionalProperties":false},"staticServicePreviews":{"type":"object","properties":{"generation":{"$ref":"#/definitions/previewsGeneration"}},"additionalProperties":false},"rootPreviews":{"type":"object","properties":{"generation":{"$ref":"#/definitions/previewsGeneration"},"expireAfterDays":{"type":"integer","minimum":1}},"additionalProperties":false},"redisServer":{"type":"object","properties":{"type":{"type":"string","enum":["keyvalue","redis"]},"name":{"type":"string"},"region":{"$ref":"#/definitions/region"},"ipAllowList":{"$ref":"#/definitions/ipAllowList"},"plan":{"$ref":"#/definitions/plan"},"previewPlan":{"$ref":"#/definitions/plan"},"maxmemoryPolicy":{"type":"string","enum":["allkeys-lru","volatile-lru","allkeys-lfu","volatile-lfu","allkeys-random","volatile-random","volatile-ttl","noeviction"]},"persistenceMode":{"type":"string","enum":["journal-snapshot","snapshot","off"]}},"required":["type","name","ipAllowList"],"additionalProperties":false},"region":{"type":"string","enum":["oregon","ohio","frankfurt","singapore","virginia"]},"registryCredential":{"properties":{"fromRegistryCreds":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"],"additionalProperties":false}},"required":["fromRegistryCreds"],"additionalProperties":false},"resources":{"type":"object","properties":{"databases":{"type":"array","items":{"$ref":"#/definitions/database"}},"envVarGroups":{"type":"array","items":{"$ref":"#/definitions/envVarGroup"}},"services":{"type":"array","items":{"anyOf":[{"$ref":"#/definitions/redisServer"},{"$ref":"#/definitions/cronService"},{"$ref":"#/definitions/serverService"},{"$ref":"#/definitions/staticService"}]}}}},"route":{"type":"object","properties":{"type":{"type":"string","enum":["redirect","rewrite"]},"source":{"type":"string"},"destination":{"type":"string"}},"required":["type","source","destination"]},"runtime":{"type":"string","enum":["docker","elixir","go","image","node","python","ruby","rust","static"]},"serverService":{"type":"object","properties":{"type":{"type":"string","enum":["web","worker","pserv"]},"name":{"type":"string"},"region":{"$ref":"#/definitions/region"},"plan":{"$ref":"#/definitions/plan"},"runtime":{"$ref":"#/definitions/runtime"},"repo":{"type":"string"},"branch":{"type":"string"},"image":{"$ref":"#/definitions/image"},"rootDir":{"type":"string"},"dockerCommand":{"type":"string"},"dockerContext":{"type":"string"},"dockerfilePath":{"type":"string"},"numInstances":{"$ref":"#/definitions/numInstances"},"healthCheckPath":{"type":"string"},"scaling":{"type":"object","properties":{"minInstances":{"type":"integer","minimum":1},"maxInstances":{"type":"integer","minimum":1},"targetMemoryPercent":{"type":"integer","minimum":1,"maximum":90},"targetCPUPercent":{"type":"integer","minimum":1,"maximum":90}}},"buildCommand":{"type":"string"},"startCommand":{"type":"string"},"preDeployCommand":{"type":"string"},"registryCredential":{"$ref":"#/definitions/registryCredential"},"domain":{"type":"string"},"domains":{"type":"array","items":{"type":"string"}},"envVars":{"type":"array","items":{"$ref":"#/definitions/envVar"}},"autoDeploy":{"type":"boolean"},"autoDeployTrigger":{"$ref":"#/definitions/autoDeployTrigger"},"initialDeployHook":{"type":"string"},"disk":{"$ref":"#/definitions/disk"},"buildFilter":{"$ref":"#/definitions/buildFilter"},"previews":{"$ref":"#/definitions/servicePreviews"},"pullRequestPreviewsEnabled":{"type":"boolean"},"previewPlan":{"$ref":"#/definitions/plan"},"maintenanceMode":{"type":"object","properties":{"enabled":{"type":"boolean"},"uri":{"type":"string","format":"uri"}},"additionalProperties":false},"maxShutdownDelaySeconds":{"type":"integer","minimum":1,"maximum":300},"ipAllowList":{"$ref":"#/definitions/ipAllowList"},"renderSubdomainPolicy":{"type":"string","enum":["enabled","disabled"]}},"required":["type","name","runtime"],"additionalProperties":false},"serviceType":{"type":"string","enum":["web","cron","worker","pserv","static","dpg","job","redis","keyvalue"]},"staticService":{"type":"object","properties":{"type":{"type":"string","const":"web"},"name":{"type":"string"},"runtime":{"type":"string","const":"static"},"buildCommand":{"type":"string"},"staticPublishPath":{"type":"string"},"previews":{"$ref":"#/definitions/staticServicePreviews"},"pullRequestPreviewsEnabled":{"type":"boolean"},"buildFilter":{"$ref":"#/definitions/buildFilter"},"headers":{"type":"array","items":{"$ref":"#/definitions/header"}},"routes":{"type":"array","items":{"$ref":"#/definitions/route"}},"envVars":{"type":"array","items":{"$ref":"#/definitions/envVar"}},"rootDir":{"type":"string"},"repo":{"type":"string"},"branch":{"type":"string"},"domain":{"type":"string"},"domains":{"type":"array","items":{"type":"string"}},"autoDeploy":{"type":"boolean"},"autoDeployTrigger":{"$ref":"#/definitions/autoDeployTrigger"},"preDeployCommand":{"type":"string"},"ipAllowList":{"$ref":"#/definitions/ipAllowList"},"renderSubdomainPolicy":{"type":"string","enum":["enabled","disabled"]}},"required":["type","name","runtime"],"additionalProperties":false}},"allOf":[{"$ref":"#/definitions/resources"},{"type":"object","properties":{"previews":{"$ref":"#/definitions/rootPreviews"},"previewsEnabled":{"type":"boolean"},"previewsExpireAfterDays":{"type":"integer","minimum":1},"projects":{"type":"array","items":{"$ref":"#/definitions/project"}},"ungrouped":{"type":"object","allOf":[{"$ref":"#/definitions/resources"}],"unevaluatedProperties":false},"version":{"type":"string","const":"1"}}}],"unevaluatedProperties":false}