Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
166 changes: 166 additions & 0 deletions README-ECS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# Platformatic World on ECS

Running workflow applications on AWS ECS instead of Kubernetes.

Everything in the [main README](README.md) still applies: runs are pinned to the deployment version that started them, and the workflow service is still the thing that pins them. What changes is what the platform can tell the application about itself.

## What is different on ECS

One filesystem check answered three questions on Kubernetes -- whether there is an identity to authenticate with, whether the service is multi-tenant, and whether ICC provisions the application. ECS answers them differently, so they are separate:

| | Kubernetes | ECS |
|---|---|---|
| Authentication | service account token, verified by the workflow service | **none** |
| Tenancy | several applications per workflow service | same |
| Provisioning | ICC assigns the application ID and version, and registers handlers | same |

**There is no authentication on ECS in this release.** ECS has no service account token, so the workflow service accepts requests from anything that can reach it -- which is what the rest of the internal control plane already does, machinist included. Data remains logically scoped by application in SQL, preventing accidental mixing, but this is not access isolation: a caller that can reach the service and knows another application ID can name it in the URL.

Treat the workflow service as an internal service. Put it in a security group that only application tasks and ICC can reach.

## Prerequisites

- An ECS cluster running Fargate tasks, with ICC and machinist deployed against it (`PLT_PROVIDER=ecs`).
- **A Cloud Map private DNS namespace**, and machinist configured with its id. This is not optional for workflow applications: it is how ICC learns the address to send workflow runs to. Without it, applications deploy and register, and no run ever reaches them.
- The workflow service itself, reachable from application tasks, with `DATABASE_URL` pointing at its PostgreSQL database.

## Configuration

### machinist

```
PLT_PROVIDER=ecs
PLT_ECS_REGION=us-east-1
PLT_ECS_CLUSTER=my-cluster
PLT_ECS_SUBNETS=subnet-a,subnet-b
PLT_ECS_SECURITY_GROUPS=sg-app
PLT_ECS_EXECUTION_ROLE_ARN=arn:aws:iam::123456789012:role/exec
PLT_ECS_TASK_ROLE_ARN=arn:aws:iam::123456789012:role/task
PLT_ECS_CLOUD_MAP_NAMESPACE_ID=ns-abc123 # required for workflow apps
PLT_ECS_LOG_GROUP=/plt/apps # optional
PLT_ECS_LISTENER_ARN=arn:aws:...:listener/.. # only for skew protection
```

machinist's own IAM permissions are listed in its README. Cloud Map addressing
depends on `servicediscovery:ListServices`, `CreateService`, `GetNamespace`,
`GetService`, `DeleteService`, `ListInstances`, and `DeregisterInstance`. It
also requires `ecs:DescribeTaskDefinition`.

machinist uses `GetService` to resolve the actual Cloud Map service name from
the registry ARN. It uses `DescribeTaskDefinition` to discover the application
port when an A-record registry and a service without a load balancer do not
expose one directly.

### ICC

```
PLT_WORKFLOW_URL=http://workflow.plt.local:3042
```

**This URL is handed to every workflow application**, as `PLT_WORLD_SERVICE_URL`. On Kubernetes the address ICC uses and the address a pod uses are the same, so this never came up; on ECS it has to be resolvable *from application tasks*, not only from ICC. A Cloud Map name in the same VPC is the straightforward choice.

If it is set to something only ICC's own network can resolve, every workflow application will start and fail on a URL it cannot reach, and it will look like an application bug.

### The application

Nothing. ICC injects all three variables the World client needs:

| Variable | Value |
|---|---|
| `PLT_WORLD_SERVICE_URL` | from ICC's `PLT_WORKFLOW_URL` |
| `PLT_WORLD_APP_ID` | the application name ICC registered |
| `PLT_WORLD_DEPLOYMENT_VERSION` | the version ICC assigned |

Setting `PLT_WORLD_SERVICE_URL` yourself in the deploy environment overrides the injected one, which is the escape hatch for an external workflow service.

`K8S_ADMIN_SERVICE_ACCOUNT` has no meaning on ECS and can be left unset.

## What happens when you deploy

1. ICC builds a provider-neutral workload spec and sends it to machinist.
2. machinist registers a Fargate task definition and creates one ECS service per version, tagged with the application name, the version, and `plt.dev/workflow`. It registers the service in Cloud Map, and -- if skew protection is on -- creates the version's target group and attaches it in the same call.
3. The task starts. The World client sees `ECS_CONTAINER_METADATA_URI_V4`, which ECS injects into every container, and knows it is on a managed platform: it does not self-register its handlers, and it waits for the assigned version rather than stamping runs `local`.
4. The task registers with ICC, which registers the application with the workflow service and then its queue handlers at the Cloud Map address:

```
http://<service>.<namespace>:3042/.well-known/workflow/v1/flow
/.well-known/workflow/v1/step
/.well-known/workflow/v1/webhook
```

The handler identity is stable for the version:

```text
<namespace>/<deploymentVersion>
```

It does not identify an ECS task. A task replacement or a scale event leaves
the handler unchanged, while Cloud Map sends each request to a currently
healthy task belonging to that version's service.

ICC marks this registration as `serviceScoped`. The workflow service then
replaces obsolete machine-scoped rows for that version while leaving every
other active or expiring version independently routable.

5. Runs dispatch to that address, pinned to the version that started them. Each
active or expiring version retains its own handler and therefore executes
using its own code. The workflow service removes that handler only when ICC
explicitly expires the version.

## Checking it worked

```sh
# The version's Cloud Map service exists
aws servicediscovery list-services \
--filters Name=NAMESPACE_ID,Values=$PLT_ECS_CLOUD_MAP_NAMESPACE_ID \
--query 'Services[].Name'

# The ECS service carries the tags ICC identifies it by
aws ecs describe-services --cluster my-cluster --services my-app-v1 --include TAGS \
--query 'services[0].tags'

# The workflow service has handlers for the version, at a resolvable address
psql "$DATABASE_URL" -c \
"select deployment_version, workflow_url from workflow_queue_handlers
order by last_heartbeat desc limit 5"
```

If the handler endpoints read `*.svc.cluster.local`, ICC did not receive an address from machinist -- check `PLT_ECS_CLOUD_MAP_NAMESPACE_ID`.

## Known limitations

**No authentication.** As above. The workflow service trusts its network on ECS.

**Version labels are normalised.** ECS service names take letters, numbers, underscores and hyphens; a semantic version produces `my-app-v1.2.3`, which ECS rejects. machinist rewrites it and appends a short digest of the original, so `my-app-v1.2.3` becomes `my-app-v1-2-3-4f878d`. The version label itself is unchanged -- it is what runs are pinned to, and what `?dpl=` carries.

**Skew protection is query-only.** An ALB cannot set a response cookie, so cookie pinning is unavailable on ECS. See the skew protection documentation for what that means for your applications.

**One ECS service per version.** Target groups per load balancer is 100 and cannot be raised, which caps a single load balancer at roughly 33 applications with three live versions each.

**Cleanup is configurable.** With `PLT_SKEW_AUTO_CLEANUP=true`, ICC asks
machinist to delete an expired version's ECS service and the resources created
with it, including its Cloud Map service, target group, and private-image pull
secret. With the setting disabled, ICC only scales the ECS service to zero. A
zero-task service has no Fargate compute charge, but retained resources still
consume ECS, Cloud Map, and especially target-group quotas. Changing the setting
affects future expirations; it does not retroactively delete versions that are
already expired.

## Validation status

The complete path has been exercised on a real Fargate cluster with query-based
skew protection: ICC deployed a workflow application, machinist created its
versioned ECS service and Cloud Map registration, ICC registered a
version-scoped handler, and a 12-step workflow completed through that handler.
This repository supplies the Workflow service and World client parts of that
path; the matching ICC and machinist ECS support must be deployed as well.

## Troubleshooting

**The application logs `no application ID configured; assuming "next" from package.json`.** `PLT_WORLD_APP_ID` did not reach the task. The application is claiming a tenant named after its package, which is very unlikely to be the one ICC registered. Check that the deploy went through ICC rather than being created directly in ECS.

**Runs stay queued and never execute.** No handler is registered at a reachable address. Check the Cloud Map namespace is configured, then that the workflow service's security group allows it to reach application tasks on the application port.

**The application never appears as a workflow application in ICC.** ICC identifies one by the `plt.dev/workflow` tag on the ECS service. A service created outside ICC will not have it; ECS also does not propagate tags to tasks unless the service asks it to, which machinist sets when it creates one.

**`PLT_WORLD_SERVICE_URL environment variable is required` at startup.** ICC injects it only for applications it knows are workflow applications. Same cause as above.
41 changes: 22 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
# Platformatic World

Deployment-aware workflow orchestration for self-hosted Kubernetes environments.
Deployment-aware workflow orchestration for self-hosted Kubernetes and AWS ECS environments.

Platformatic World solves the version-pinning problem for [Workflow DevKit](https://docs.platformatic.dev/): when new code deploys, in-flight workflow runs must continue executing on the code version that started them. The Vercel world handles this via Vercel's infrastructure. Platformatic World provides the same guarantees for self-hosted environments by routing queue messages through a central service that pins each run to its originating deployment version.

For ECS-specific deployment, discovery, and security details, see [Platformatic World on ECS](README-ECS.md).

## Architecture

```mermaid
Expand Down Expand Up @@ -35,32 +37,33 @@ graph LR

## Operating Modes

`@platformatic/world` and the Workflow Service run in **two distinct modes**.
The service auto-detects which one based on the presence of a Kubernetes
service-account token. Apps just point `PLT_WORLD_SERVICE_URL` at the
service URL and use the same SDK in both modes.
`@platformatic/world` and the Workflow Service run in three distinct modes.
They distinguish Kubernetes through its service-account token and ECS through
the task metadata endpoint injected into containers. Applications use the same
SDK in every mode.

| Aspect | Local mode (single-tenant) | Kubernetes mode (with ICC) |
|---|---|---|
| Triggered by | No K8s service-account token detected | K8s service-account token present at runtime |
| Authentication | None | K8s `TokenReview` per request |
| Apps | One implicit app (`default`) auto-provisioned | One app per K8s ServiceAccount binding, provisioned by ICC |
| Pod-to-handler registration | App calls `world.start()` on boot | ICC registers handlers via the admin API; `world.start()` is a no-op |
| Deployment version | Defaults to `local` (or `PLT_WORLD_DEPLOYMENT_VERSION`) | Auto-detected from the pod's `plt.dev/version` label |
| Admin API | Open (no auth) | Restricted to the configured admin ServiceAccount (e.g. `platformatic:icc`) |
| Run-pinning across deploys | Yes (every run records the version that started it) | Yes (same mechanism; ICC drives version lifecycle) |
| Aspect | Local mode | Kubernetes with ICC | ECS with ICC |
|---|---|---|---|
| Triggered by | No managed-platform signal | K8s service-account token | ECS task metadata endpoint |
| Authentication | None | K8s `TokenReview` per request | None; network-trusted |
| Apps | One implicit app (`default`) | Provisioned by ICC and bound to K8s ServiceAccounts | Provisioned by ICC and selected by URL |
| Handler registration | App calls `world.start()` | ICC registers the version's K8s Service | ICC registers the version's Cloud Map service |
| Deployment version | `local` or configured explicitly | Assigned by ICC | Assigned by ICC |
| Admin API | Open | Restricted to the configured admin ServiceAccount | Open inside the trusted network |
| Run-pinning across deploys | Yes | Yes; active and expiring versions retain their handlers | Yes; active and expiring versions retain their handlers |

**Local mode** is what you use for development, CI, and the e2e tests in
this repo. It runs the same code paths as production -- only the auth and
handler-registration entry points differ.

**Kubernetes mode** is the production deployment under
**Managed modes** are production deployments under
[ICC](https://github.com/platformatic/intelligent-command-center). ICC is
the control plane: it provisions apps, binds K8s ServiceAccounts to apps,
registers pod handler endpoints, and drives version lifecycle (drain /
expire). The service itself is identical between the two modes.
the control plane: it provisions applications, registers version-level service
endpoints, and drives version lifecycle (drain / expire). On Kubernetes it also
binds ServiceAccounts to applications for authentication. See the
[ECS guide](README-ECS.md) for the unauthenticated, network-trusted ECS model.

The diagram at the top shows the K8s-with-ICC mode. In local mode, replace
The diagram at the top shows a managed ICC mode. In local mode, replace
the ICC box with nothing -- the service runs standalone against PostgreSQL
and accepts unauthenticated traffic from apps on the same machine.

Expand Down
6 changes: 4 additions & 2 deletions packages/workflow/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @platformatic/workflow

Workflow orchestration service for [Vercel Workflow DevKit](https://useworkflow.dev) on self-hosted Kubernetes. Manages all workflow state (runs, steps, events, hooks, streams) and routes queue messages to the correct deployment version.
Workflow orchestration service for [Vercel Workflow DevKit](https://useworkflow.dev) on self-hosted Kubernetes and AWS ECS. Manages all workflow state (runs, steps, events, hooks, streams) and routes queue messages to the correct deployment version.

## Quick Start

Expand Down Expand Up @@ -33,6 +33,8 @@ Options:

**Multi-tenant** (Kubernetes) — K8s service account token present. All requests authenticated via K8s TokenReview API. Per-application isolation enforced at the SQL level.

**Multi-tenant** (ECS) — ECS task metadata endpoint present. Applications are scoped in SQL, but requests are unauthenticated and callers select the application in the URL. Keep the service reachable only from trusted security groups. See the repository's [ECS guide](../../README-ECS.md).

## API

All app-scoped endpoints are prefixed with `/api/v1/apps/:appId`.
Expand Down Expand Up @@ -63,7 +65,7 @@ All app-scoped endpoints are prefixed with `/api/v1/apps/:appId`.
| Method | Path | Description |
|---|---|---|
| `POST` | `/queue` | Enqueue a message (accepts `application/json` or `application/cbor`) |
| `POST` | `/handlers` | Register queue handler endpoints |
| `POST` | `/handlers` | Register queue handler endpoints (`serviceScoped: true` for an ICC-managed version Service) |
| `PUT` | `/runs/:runId/streams/:name` | Write stream chunks |
| `GET` | `/runs/:runId/streams` | List stream names |
| `GET` | `/runs/:runId/streams/:name/chunks` | Paginated stream chunks (`?limit`, `?cursor`) |
Expand Down
57 changes: 40 additions & 17 deletions packages/workflow/lib/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
import { createK8sTokenValidator } from './k8s-token.ts'
import { Unauthorized, Forbidden } from '../errors.ts'
import { Unauthorized, Forbidden, AppNotFound } from '../errors.ts'

export interface AuthConfig {
mode: 'k8s-token' | 'api-key' | 'both' | 'none'
defaultAppId?: number
// Present when the platform supplies an identity to verify. Authentication is
// enabled exactly when the means to perform it is supplied, so "authenticate
// but without the config to do so" is unrepresentable.
k8s?: {
apiServer: string
caCert?: string
adminServiceAccount?: string
saTokenPath?: string
}
// Resolve the tenant from the URL rather than pinning one application.
multiTenant: boolean
// Used when multiTenant is false.
defaultAppId?: number
}

declare module 'fastify' {
Expand All @@ -30,23 +36,46 @@ function isAdminPath (url: string): boolean {
)) || url.startsWith('/api/v1/versions/')
}

// Resolve an application named in the URL. Throws rather than leaving appId at
// its default, which would scope queries to application_id = 0 and make an
// unknown application look like an empty one.
async function resolveApp (app: FastifyInstance, appLabel: string): Promise<number> {
const result = await app.pg.query(
'SELECT id FROM workflow_applications WHERE app_id = $1',
[appLabel]
)
if (result.rows.length === 0) throw new AppNotFound(appLabel)
return result.rows[0].id
}

async function authPlugin (app: FastifyInstance, config: AuthConfig): Promise<void> {
app.decorateRequest('appId', 0)
app.decorateRequest('isAdmin', false)

// No-auth mode: set appId from config and skip all token parsing
if (config.mode === 'none') {
const validateK8s = config.k8s
? createK8sTokenValidator(app.pg, config.k8s, app.log)
: null

// Unauthenticated: every caller is admin. Tenancy still applies on managed
// platforms, where the client names its application in the URL and ICC is the
// one that registered it.
if (!validateK8s) {
app.addHook('onRequest', async (request: FastifyRequest) => {
request.appId = config.defaultAppId || 0
const url = request.url.split('?')[0]
if (PUBLIC_PATHS.has(url)) return

request.isAdmin = true

const appIdMatch = config.multiTenant
? url.match(/^\/api\/v1\/apps\/([^/]+)/)
: null
request.appId = appIdMatch
? await resolveApp(app, appIdMatch[1])
: config.defaultAppId || 0
})
return
}

const validateK8s = config.k8s
? createK8sTokenValidator(app.pg, config.k8s, app.log)
: null

app.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => {
const url = request.url.split('?')[0]

Expand Down Expand Up @@ -82,13 +111,7 @@ async function authPlugin (app: FastifyInstance, config: AuthConfig): Promise<vo
if (request.isAdmin && applicationIds.length === 0) {
const appIdMatch = url.match(/\/api\/v1\/apps\/([^/]+)/)
if (appIdMatch) {
const result = await app.pg.query(
'SELECT id FROM workflow_applications WHERE app_id = $1',
[appIdMatch[1]]
)
if (result.rows.length > 0) {
request.appId = result.rows[0].id
}
request.appId = await resolveApp(app, appIdMatch[1])
}
return
}
Expand Down
Loading
Loading