Skip to content
Draft
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
194 changes: 194 additions & 0 deletions .github/workflows/cloudflare-verification.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
name: Reusable Cloudflare verification

on:
workflow_call:
inputs:
factory_ref:
description: Factory commit to verify
required: false
type: string
default: main
infra_repository:
description: Dedicated private verification-infra repository
required: false
type: string
default: AgentWorkforce/factory-test-infra
infra_ref:
description: Verification-infra commit to use
required: false
type: string
default: main
run_real:
description: Provision a real dispatch namespace and Container
required: false
type: boolean
default: true
secrets:
infra_repository_token:
description: Read token for the private factory-test-infra repository
required: true
cloudflare_resource_json:
description: JSON Resource object containing accountId and apiToken
required: false

permissions:
contents: read

concurrency:
group: cloudflare-verification-${{ github.repository }}-${{ github.ref }}
cancel-in-progress: false

jobs:
miniflare:
name: Miniflare Track-C suite
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout Factory
uses: actions/checkout@v6
with:
ref: ${{ inputs.factory_ref }}
path: factory
persist-credentials: false

- name: Checkout factory-test-infra
uses: actions/checkout@v6
with:
repository: ${{ inputs.infra_repository }}
ref: ${{ inputs.infra_ref }}
token: ${{ secrets.infra_repository_token }}
path: factory-test-infra
persist-credentials: false

- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
cache-dependency-path: |
factory/package-lock.json
factory-test-infra/package-lock.json

- name: Install Factory
working-directory: factory
run: npm ci

- name: Install verification infrastructure
working-directory: factory-test-infra
run: npm ci

- name: Start isolated Miniflare environment
working-directory: factory-test-infra
env:
FACTORY_REPO_PATH: ${{ github.workspace }}/factory
FACTORY_VERIFICATION_MODE: miniflare
run: bash ci/verification-env/up.sh

- name: Run Track-C suites and guardrail assertions
working-directory: factory-test-infra
env:
FACTORY_REPO_PATH: ${{ github.workspace }}/factory
FACTORY_VERIFICATION_MODE: miniflare
run: bash ci/verification-env/run-suite.sh

- name: Tear down Miniflare environment
if: always()
working-directory: factory-test-infra
env:
FACTORY_REPO_PATH: ${{ github.workspace }}/factory
FACTORY_VERIFICATION_MODE: miniflare
run: bash ci/verification-env/down.sh

- name: Upload Miniflare evidence
if: always()
uses: actions/upload-artifact@v6
with:
name: factory-cloudflare-miniflare-${{ github.sha }}
path: factory-test-infra/artifacts/verification-env/
if-no-files-found: error

cloudflare:
name: Real dispatch namespace and Container
if: inputs.run_real
needs: miniflare
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Require real Cloudflare resource secret
env:
FACTORY_TEST_INFRA_RESOURCE_JSON: ${{ secrets.cloudflare_resource_json }}
run: test -n "$FACTORY_TEST_INFRA_RESOURCE_JSON"

- name: Checkout Factory
uses: actions/checkout@v6
with:
ref: ${{ inputs.factory_ref }}
path: factory
persist-credentials: false

- name: Checkout factory-test-infra
uses: actions/checkout@v6
with:
repository: ${{ inputs.infra_repository }}
ref: ${{ inputs.infra_ref }}
token: ${{ secrets.infra_repository_token }}
path: factory-test-infra
persist-credentials: false

- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
cache-dependency-path: |
factory/package-lock.json
factory-test-infra/package-lock.json

- name: Install dependencies
run: |
npm --prefix factory ci
npm --prefix factory-test-infra ci

- name: Materialize linked Cloudflare resource
env:
FACTORY_TEST_INFRA_RESOURCE_JSON: ${{ secrets.cloudflare_resource_json }}
run: |
install -m 600 /dev/null "$RUNNER_TEMP/factory-test-infra-resource.json"
printf '%s' "$FACTORY_TEST_INFRA_RESOURCE_JSON" > "$RUNNER_TEMP/factory-test-infra-resource.json"

- name: Provision real verification environment
working-directory: factory-test-infra
env:
FACTORY_REPO_PATH: ${{ github.workspace }}/factory
FACTORY_TEST_INFRA_RESOURCE_FILE: ${{ runner.temp }}/factory-test-infra-resource.json
FACTORY_VERIFICATION_MODE: cloudflare
run: bash ci/verification-env/up.sh

- name: Run Track-C suites and guardrail assertions
working-directory: factory-test-infra
env:
FACTORY_REPO_PATH: ${{ github.workspace }}/factory
FACTORY_TEST_INFRA_RESOURCE_FILE: ${{ runner.temp }}/factory-test-infra-resource.json
FACTORY_VERIFICATION_MODE: cloudflare
run: bash ci/verification-env/run-suite.sh

- name: Tear down real verification environment
if: always()
working-directory: factory-test-infra
env:
FACTORY_REPO_PATH: ${{ github.workspace }}/factory
FACTORY_TEST_INFRA_RESOURCE_FILE: ${{ runner.temp }}/factory-test-infra-resource.json
FACTORY_VERIFICATION_MODE: cloudflare
run: bash ci/verification-env/down.sh

- name: Remove linked Cloudflare resource
if: always()
run: rm -f "$RUNNER_TEMP/factory-test-infra-resource.json"

- name: Upload real-environment evidence
if: always()
uses: actions/upload-artifact@v6
with:
name: factory-cloudflare-real-${{ github.sha }}
path: factory-test-infra/artifacts/verification-env/
if-no-files-found: error
123 changes: 123 additions & 0 deletions docs/cloudflare-environment-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Cloudflare verification environments

`CloudflareEnvironmentProvider` is Factory's Cloudflare-first implementation of
the substrate-neutral `EnvironmentProvider` port. It deliberately owns only the
Factory-side lifecycle. The dedicated private `AgentWorkforce/factory-test-infra`
repository owns account resources, the dispatch Worker, Container definitions,
usage monitoring, the scheduled reaper, and real-environment CI scripts.

## Credential boundary

Cloudflare credentials must not be placed in `factory.config.json` or read from
`process.env`. Configuration contains only a logical resource name and
source-control-safe policy:

```json
{
"environments": {
"cloudflare": {
"resource": "FactoryTestInfra",
"dispatchNamespacePrefix": "factory-verification",
"maxConcurrentEnvironments": 2,
"defaultTtlMs": 900000,
"maxTtlMs": 3600000,
"maxRunCostUsd": 1,
"workerLimits": { "cpuMs": 100, "subrequests": 100 },
"container": { "instanceType": "lite", "maxInstances": 3 }
}
}
}
```

At runtime, pass the linked SST `Resource.*`-style object explicitly:

```ts
import { CloudflareEnvironmentProvider } from '@agent-relay/factory/environments'

const provider = CloudflareEnvironmentProvider.fromResource({
config: factoryConfig.environments.cloudflare,
resources: {
FactoryTestInfra: Resource.FactoryTestInfra,
},
})
```

The linked resource has this shape:

```ts
{
accountId: string
apiToken: string
dispatcherUrlTemplate?: 'https://.../{namespace}/{script}'
}
```

The API token needs the narrow Workers Scripts read/write permissions used by
the Workers for Platforms dispatch namespace API. Never log, persist, return in
an `Environment`, or copy that object into Factory configuration.

## Lifecycle and isolation

Each provision call:

1. Validates TTL, requested cost reservation, Container instance count, and the
max-concurrent namespace cap before creating anything.
2. Creates a new dispatch namespace. A namespace reported as `trusted` is
rejected and immediately removed.
3. Uploads an inert `factory-environment` Worker containing ownership, creation,
expiration, and guardrail bindings. User workloads never receive the account
token or bindings belonging to another namespace.
4. Returns the namespace identity and non-secret limits through `Environment`.

Destroy and reaper paths read the metadata Worker's binding lease and require
its `FACTORY_ENVIRONMENT_ID` to equal the namespace name before deletion. A
missing, incomplete, expired in the wrong direction, or mismatched lease fails
closed. The provider reaper handles TTL and optional owner-liveness checks; the
separate infra Cron Trigger performs the same check independently.

Cloudflare documents that Workers for Platforms user Workers are untrusted by
default, which isolates their cache and request metadata. Binding isolation is
still a deployment responsibility: the infra renderer must create or select
per-environment D1/KV/R2/Queue/Hyperdrive resources and attach only that
environment's identifiers to scripts in its dispatch namespace.

## Guardrail contract

The sentinel Worker exposes these non-secret bindings for the infra control
plane and scheduled reaper:

- `FACTORY_ENVIRONMENT_ID`, `FACTORY_OWNER_ID`, `FACTORY_CUSTOMER_ID`
- `FACTORY_REPOSITORY`, `FACTORY_CREATED_AT`, `FACTORY_EXPIRES_AT`
- `FACTORY_MAX_RUN_COST_USD`, `FACTORY_CONTAINER_MAX_INSTANCES`
- `FACTORY_WORKER_CPU_MS`, `FACTORY_WORKER_SUBREQUESTS`

Worker CPU and subrequest limits are also attached to the metadata Worker at
upload. Cloudflare Container deployments must copy `maxInstances` to Wrangler's
production-enforced `containers[].max_instances`. The infra usage monitor must
abort and tear down a run when elapsed time or metered cost reaches its binding;
the Factory provider's TTL reaper and the Cron Trigger remain independent
backstops.

## Verification workflow

`.github/workflows/cloudflare-verification.yml` is reusable through
`workflow_call`. It checks out Factory and the private infra repository, runs the
infra repository's exact `ci/verification-env/up.sh`, `run-suite.sh`, and
`down.sh` contract against Miniflare and a real Cloudflare account, and always
runs teardown. The real job consumes a single JSON resource secret through a
temporary mode-0600 file and uploads guardrail evidence even when an assertion
fails.

The real job is intentionally not part of ordinary fork PR execution. A trusted
caller supplies the private-repository checkout token and Cloudflare resource
secret. The scripts must fail loudly for isolation, quota/cost refusal,
concurrency refusal, orphan reaping, and k6 load absorption; a successful HTTP
smoke test alone is not an acceptable result.

Primary Cloudflare references:

- <https://developers.cloudflare.com/api/resources/workers_for_platforms/subresources/dispatch/>
- <https://developers.cloudflare.com/workers/platform/infrastructure-as-code/>
- <https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/how-workers-for-platforms-works/>
- <https://developers.cloudflare.com/containers/get-started/>
- <https://developers.cloudflare.com/workers/testing/vitest-integration/>
43 changes: 33 additions & 10 deletions factory-test-infra/README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,36 @@
# Factory Kubernetes test infrastructure
# factory-test-infra handoff

`kubernetes-connections.example.yaml` is the source-control-safe shape for the
customer-to-cluster registry. Real credentials belong in the configured secret
manager. Only their references belong in Factory config.
This directory is a source-control-safe handoff note, not the environment data
plane. `@agent-relay/factory` is a library and must not own Cloudflare account
resources or operate verification workloads.

The provider creates the namespace RBAC, quota, limits, Pod Security labels, and
NetworkPolicies for every run; there is no mutable shared base chart to drift.
Managed EKS should provide a dedicated, tainted verification node group matching
the example selector/toleration and must run a NetworkPolicy-enforcing CNI.
The private `AgentWorkforce/factory-test-infra` repository is the data-plane
home. Its companion implementation for this change owns:

The shared managed connection is a fallback, not an EKS-fidelity claim. Its
mandatory caveat is surfaced on every resulting `Environment`.
```text
infra/ Terraform/SST account resources
containers/ bounded Container service definitions
workers/dispatcher/ dynamic Workers for Platforms dispatch Worker
workers/reaper/ Cron Trigger and leak alerting
templates/ Worker/Container + D1/KV/R2/Queue/Hyperdrive inputs
ci/verification-env/up.sh Miniflare or real environment provision
ci/verification-env/run-suite.sh
ci/verification-env/down.sh identity-checked teardown
.github/workflows/verification.yml
```

Its linked runtime resource must expose `accountId`, `apiToken`, and optionally
`dispatcherUrlTemplate`; see `docs/cloudflare-environment-provider.md`. Factory
configuration stores only the resource name and guardrail policy.

The CI scripts are a strict contract. `run-suite.sh` must make isolation,
Container quota, elapsed-time/cost budget, max-concurrency, orphan reaping, and
k6 load assertions fail loudly if a guardrail is removed. `down.sh` must be
idempotent and verify the namespace's sentinel Worker identity before deletion.
`.github/workflows/cloudflare-verification.yml` in this repository and the
dedicated repository's `verification.yml` invoke that contract against both
Miniflare and a real Cloudflare account.

`kubernetes-connections.example.yaml` remains the optional escape-hatch provider
configuration for stacks Cloudflare Containers cannot faithfully run. It is not
the default verification substrate and contains no credentials.
2 changes: 2 additions & 0 deletions src/__tests__/dist-entrypoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ describe('published dist entrypoints', () => {
expect(main.generateFeatureMap).toBeTypeOf('function')
expect(main.VerificationStackDescriptorSchema).toBeDefined()
expect(main.VerificationStackDeployer).toBeTypeOf('function')
expect(main.CloudflareEnvironmentProvider).toBeTypeOf('function')
expect(main.KubernetesEnvironmentProvider).toBeTypeOf('function')
expect(hosted.createHostedFactory).toBeTypeOf('function')
expect(hosted.DurableObjectHostedFactoryStateStore).toBeTypeOf('function')
expect(environments.CloudflareEnvironmentProvider).toBeTypeOf('function')
expect(environments.KubernetesEnvironmentProvider).toBeTypeOf('function')
expect(environments.KubectlEnvironmentProvider).toBeTypeOf('function')
expect(environments.VerificationPipeline).toBeTypeOf('function')
Expand Down
Loading