@mizchi/k1c 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +150 -0
- package/dist/canary/dispatcher-template.d.ts +17 -0
- package/dist/canary/dispatcher-template.js +42 -0
- package/dist/canary/effects-cloudflare.d.ts +9 -0
- package/dist/canary/effects-cloudflare.js +66 -0
- package/dist/canary/rollout-command.d.ts +15 -0
- package/dist/canary/rollout-command.js +92 -0
- package/dist/canary/runtime.d.ts +59 -0
- package/dist/canary/runtime.js +138 -0
- package/dist/canary/state-machine.d.ts +72 -0
- package/dist/canary/state-machine.js +161 -0
- package/dist/cli/args.d.ts +51 -0
- package/dist/cli/args.js +239 -0
- package/dist/cli/canary-integration.d.ts +11 -0
- package/dist/cli/canary-integration.js +101 -0
- package/dist/cli/format.d.ts +4 -0
- package/dist/cli/format.js +44 -0
- package/dist/cli/main.d.ts +3 -0
- package/dist/cli/main.js +158 -0
- package/dist/cli/run.d.ts +16 -0
- package/dist/cli/run.js +246 -0
- package/dist/manifest/lower.d.ts +22 -0
- package/dist/manifest/lower.js +913 -0
- package/dist/manifest/parse.d.ts +22 -0
- package/dist/manifest/parse.js +106 -0
- package/dist/manifest/schemas.d.ts +10359 -0
- package/dist/manifest/schemas.js +309 -0
- package/dist/manifest/types.d.ts +246 -0
- package/dist/manifest/types.js +12 -0
- package/dist/providers/configmap.d.ts +8 -0
- package/dist/providers/configmap.js +29 -0
- package/dist/providers/custom-domain.d.ts +11 -0
- package/dist/providers/custom-domain.js +120 -0
- package/dist/providers/d1-database.d.ts +9 -0
- package/dist/providers/d1-database.js +106 -0
- package/dist/providers/dispatch-namespace.d.ts +8 -0
- package/dist/providers/dispatch-namespace.js +100 -0
- package/dist/providers/dns-record.d.ts +14 -0
- package/dist/providers/dns-record.js +136 -0
- package/dist/providers/errors.d.ts +8 -0
- package/dist/providers/errors.js +64 -0
- package/dist/providers/hyperdrive.d.ts +27 -0
- package/dist/providers/hyperdrive.js +168 -0
- package/dist/providers/index.d.ts +6 -0
- package/dist/providers/index.js +36 -0
- package/dist/providers/kv-namespace.d.ts +8 -0
- package/dist/providers/kv-namespace.js +90 -0
- package/dist/providers/logpush-job.d.ts +17 -0
- package/dist/providers/logpush-job.js +181 -0
- package/dist/providers/queue.d.ts +10 -0
- package/dist/providers/queue.js +124 -0
- package/dist/providers/r2-bucket.d.ts +11 -0
- package/dist/providers/r2-bucket.js +98 -0
- package/dist/providers/registry.d.ts +9 -0
- package/dist/providers/registry.js +22 -0
- package/dist/providers/secret.d.ts +8 -0
- package/dist/providers/secret.js +30 -0
- package/dist/providers/types.d.ts +69 -0
- package/dist/providers/types.js +12 -0
- package/dist/providers/vectorize.d.ts +11 -0
- package/dist/providers/vectorize.js +110 -0
- package/dist/providers/worker.d.ts +106 -0
- package/dist/providers/worker.js +430 -0
- package/dist/providers/workflow.d.ts +10 -0
- package/dist/providers/workflow.js +103 -0
- package/dist/reconciler/apply.d.ts +10 -0
- package/dist/reconciler/apply.js +114 -0
- package/dist/reconciler/fake-provider.d.ts +48 -0
- package/dist/reconciler/fake-provider.js +83 -0
- package/dist/reconciler/plan.d.ts +6 -0
- package/dist/reconciler/plan.js +124 -0
- package/dist/reconciler/topo.d.ts +10 -0
- package/dist/reconciler/topo.js +53 -0
- package/dist/reconciler/types.d.ts +54 -0
- package/dist/reconciler/types.js +8 -0
- package/package.json +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mizchi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# k1c
|
|
2
|
+
|
|
3
|
+
Experimental `kubectl apply`-style tool for Cloudflare. Pronounced **"kick"**.
|
|
4
|
+
|
|
5
|
+
This is a learning / proof-of-concept project. It is **not production-ready**, and its public surface is subject to breaking changes without notice.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
`k1c` parses a defined subset of Kubernetes manifests and applies them to a Cloudflare account via the official SDK. The motivation is to reuse `kubectl`-style declarative manifests for personal-scale Cloudflare projects without paying for a real Kubernetes control plane.
|
|
10
|
+
|
|
11
|
+
```yaml
|
|
12
|
+
apiVersion: cloudflare.k1c.io/v1alpha1
|
|
13
|
+
kind: R2Bucket
|
|
14
|
+
metadata: { name: media }
|
|
15
|
+
---
|
|
16
|
+
apiVersion: apps/v1
|
|
17
|
+
kind: Deployment
|
|
18
|
+
metadata: { name: api }
|
|
19
|
+
spec:
|
|
20
|
+
selector: { matchLabels: { app: api } }
|
|
21
|
+
template:
|
|
22
|
+
spec:
|
|
23
|
+
containers:
|
|
24
|
+
- name: api
|
|
25
|
+
image: ./dist/worker.js
|
|
26
|
+
volumeMounts:
|
|
27
|
+
- { name: bucket, mountPath: R2_MEDIA }
|
|
28
|
+
volumes:
|
|
29
|
+
- { name: bucket, r2BucketRef: { name: media } }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
$ K1C_ACCOUNT_ID=... CLOUDFLARE_API_TOKEN=... pnpm k1c apply -f manifest.yaml
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Status
|
|
37
|
+
|
|
38
|
+
| Manifest kind | Backed by | State |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| `Deployment` (single- or multi-container Pods) | Worker(s) wired by service bindings | working |
|
|
41
|
+
| `ConfigMap` / `Secret` | folded into Worker `vars` / `secrets` | working |
|
|
42
|
+
| `Service` (`ClusterIP` / `LoadBalancer`) | service binding / Custom Domain | working |
|
|
43
|
+
| `R2Bucket`, `KVNamespace`, `D1Database`, `Vectorize`, `Hyperdrive` (CRDs) | matching CF data services | working |
|
|
44
|
+
| `Queue` (CRD) + producer / consumer wiring | Cloudflare Queues + consumer | working |
|
|
45
|
+
| `DispatchNamespace` (CRD) | Workers for Platforms namespace | working |
|
|
46
|
+
| `Rollout` (Argo Rollouts subset, `blueGreen` / `canary.steps`) | Worker Versions, or WfP dispatcher with KV-stored canary state | working |
|
|
47
|
+
| `StatefulSet` → `DurableObject` class | Workers Durable Objects + migrations | working (greenfield only) |
|
|
48
|
+
| `CronJob` / `Job` | Worker + Cron Trigger / Workflow registration | working |
|
|
49
|
+
| `DNSRecord` (CRD) | DNS records | working |
|
|
50
|
+
| `LogpushJob` (CRD) | Logpush (zone- or account-scoped) | working |
|
|
51
|
+
| `ai` / `browser` / `version_metadata` / `analytics_engine` Worker bindings | annotation- / volume-driven | working |
|
|
52
|
+
| `Ingress` / `CustomHostname` / Zero Trust Access | — | not implemented (see [`TODO.md`](TODO.md)) |
|
|
53
|
+
|
|
54
|
+
See [`docs/resources.md`](docs/resources.md) for the full mapping and limitations,
|
|
55
|
+
and [`TODO.md`](TODO.md) for what's queued.
|
|
56
|
+
|
|
57
|
+
## Why this exists
|
|
58
|
+
|
|
59
|
+
I wanted a `kubectl apply` UX for personal Cloudflare projects but did not want to pay for a managed Kubernetes control plane (GKE minimum is roughly JPY 8,000 / month). `k1c` is the smallest tool that lets a Kubernetes-shaped manifest drive Cloudflare resources directly. See [ADR-0001](docs/adr/0001-project-goal.md) for the full reasoning.
|
|
60
|
+
|
|
61
|
+
The architecture is documented as [Architecture Decision Records](docs/adr/) (ADR-0001 through ADR-0007).
|
|
62
|
+
|
|
63
|
+
## Install
|
|
64
|
+
|
|
65
|
+
Once published:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
npm install -g @mizchi/k1c
|
|
69
|
+
# or:
|
|
70
|
+
pnpm dlx @mizchi/k1c apply -f manifest.yaml
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
From source (this repo):
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
pnpm install
|
|
77
|
+
pnpm test # 297 tests
|
|
78
|
+
pnpm typecheck
|
|
79
|
+
pnpm build # emits dist/
|
|
80
|
+
|
|
81
|
+
# Run the CLI in-repo (TypeScript via Node strip-types):
|
|
82
|
+
pnpm k1c apply -f examples/hello-worker.yaml [--dry-run]
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## CLI
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
k1c apply -f <manifest.yaml> [--dry-run | --watch]
|
|
89
|
+
k1c diff -f <manifest.yaml> [-o text|json]
|
|
90
|
+
k1c delete -f <manifest.yaml> [--cascade]
|
|
91
|
+
k1c get <kind> [name] [-n <namespace>] [-o text|json]
|
|
92
|
+
k1c describe <kind> <name> [-n <namespace>] [-o text|json]
|
|
93
|
+
k1c rollout {status|promote|abort} <ns>/<name> --dispatch <name>
|
|
94
|
+
k1c version
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Authentication is via two environment variables:
|
|
98
|
+
|
|
99
|
+
| Variable | Purpose |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `K1C_ACCOUNT_ID` | Cloudflare account id |
|
|
102
|
+
| `CLOUDFLARE_API_TOKEN` | API token with Workers Edit + R2 + KV permissions |
|
|
103
|
+
|
|
104
|
+
## Architecture in one screen
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
manifest.yaml
|
|
108
|
+
│
|
|
109
|
+
▼ src/manifest/parse.ts — YAML → typed K1cResource[] (validated by zod)
|
|
110
|
+
│
|
|
111
|
+
▼ src/manifest/lower.ts — resolves refs, folds ConfigMap/Secret into Workers,
|
|
112
|
+
│ generates DispatcherWorker / state KV for canary Rollouts
|
|
113
|
+
▼ src/reconciler/plan.ts — compares desired vs actual via providers, topological sort
|
|
114
|
+
│
|
|
115
|
+
▼ src/reconciler/apply.ts — executes operations (create/update/delete) with retry
|
|
116
|
+
│
|
|
117
|
+
▼ src/canary/runtime.ts — for canary Rollouts: read KV state, run state machine,
|
|
118
|
+
│ upload canary + rewrite weight + promote
|
|
119
|
+
▼ Cloudflare account
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Providers live under `src/providers/` and are uniform across resource types. The interface mirrors AWS CloudControl (CRUD + Status + List + Discovery); see [ADR-0006](docs/adr/0006-provider-interface.md).
|
|
123
|
+
|
|
124
|
+
## Limitations
|
|
125
|
+
|
|
126
|
+
This is experimental. In particular:
|
|
127
|
+
|
|
128
|
+
- Worker entrypoint content is not yet hashed at lower time, so editing only the JS file (without changing the manifest) does not currently trigger an update outside the canary path. Deferred (`docs/future-considerations.md`).
|
|
129
|
+
- Async polling for Custom Hostname SSL provisioning is not implemented.
|
|
130
|
+
- The reconciler model assumes a single Cloudflare account at a time.
|
|
131
|
+
- No real end-to-end tests against Cloudflare yet — provider behavior is validated through SDK mocks only.
|
|
132
|
+
|
|
133
|
+
## Releases
|
|
134
|
+
|
|
135
|
+
Versioning is automated via [release-please](https://github.com/googleapis/release-please-action):
|
|
136
|
+
|
|
137
|
+
- Conventional Commit messages on `main` (`feat:`, `fix:`, `chore:`, etc.) feed
|
|
138
|
+
into a release PR that bumps `package.json`, updates `CHANGELOG.md`, and
|
|
139
|
+
cuts a Git tag plus a GitHub Release.
|
|
140
|
+
- Merging that release PR triggers `.github/workflows/publish.yml`, which
|
|
141
|
+
publishes `@mizchi/k1c` to npm via [OIDC trusted publishing](https://docs.npmjs.com/trusted-publishers)
|
|
142
|
+
(no `NPM_TOKEN` involved) with `--provenance` SLSA attestation.
|
|
143
|
+
|
|
144
|
+
The npm package must be registered as a trusted publisher on `npmjs.com` once,
|
|
145
|
+
pointing at `mizchi/k1c` + the `publish.yml` workflow. After that the entire
|
|
146
|
+
flow (PR → merge → tag → npm) is hands-off.
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
MIT
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates the JavaScript source for a k1c dispatcher Worker.
|
|
3
|
+
*
|
|
4
|
+
* The dispatcher reads canary state from a bound KV namespace and routes each request
|
|
5
|
+
* to either the stable or canary script in a Workers for Platforms dispatch namespace.
|
|
6
|
+
* State schema and routing logic are documented in ADR-0007.
|
|
7
|
+
*/
|
|
8
|
+
export interface DispatcherTemplateOptions {
|
|
9
|
+
/** KV key under which the per-rollout state JSON is stored. */
|
|
10
|
+
readonly rolloutKey: string;
|
|
11
|
+
/** Script name of the stable variant inside the dispatch namespace. */
|
|
12
|
+
readonly stableName: string;
|
|
13
|
+
/** Script name of the canary variant inside the dispatch namespace. */
|
|
14
|
+
readonly canaryName: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function generateDispatcher(opts: DispatcherTemplateOptions): string;
|
|
17
|
+
//# sourceMappingURL=dispatcher-template.d.ts.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates the JavaScript source for a k1c dispatcher Worker.
|
|
3
|
+
*
|
|
4
|
+
* The dispatcher reads canary state from a bound KV namespace and routes each request
|
|
5
|
+
* to either the stable or canary script in a Workers for Platforms dispatch namespace.
|
|
6
|
+
* State schema and routing logic are documented in ADR-0007.
|
|
7
|
+
*/
|
|
8
|
+
export function generateDispatcher(opts) {
|
|
9
|
+
const ROLLOUT_KEY = JSON.stringify(opts.rolloutKey);
|
|
10
|
+
const STABLE_NAME = JSON.stringify(opts.stableName);
|
|
11
|
+
const CANARY_NAME = JSON.stringify(opts.canaryName);
|
|
12
|
+
return `// k1c dispatcher (generated)
|
|
13
|
+
// rolloutKey=${opts.rolloutKey}
|
|
14
|
+
const ROLLOUT_KEY = ${ROLLOUT_KEY};
|
|
15
|
+
const STABLE_NAME = ${STABLE_NAME};
|
|
16
|
+
const CANARY_NAME = ${CANARY_NAME};
|
|
17
|
+
|
|
18
|
+
export default {
|
|
19
|
+
async fetch(request, env) {
|
|
20
|
+
const target = await pickTarget(env);
|
|
21
|
+
return env.NAMESPACE.get(target).fetch(request);
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
async function pickTarget(env) {
|
|
26
|
+
const raw = await env.STATE.get(ROLLOUT_KEY);
|
|
27
|
+
if (!raw) return STABLE_NAME;
|
|
28
|
+
let state;
|
|
29
|
+
try {
|
|
30
|
+
state = JSON.parse(raw);
|
|
31
|
+
} catch (_e) {
|
|
32
|
+
return STABLE_NAME;
|
|
33
|
+
}
|
|
34
|
+
if (!state || state.status === 'idle' || !state.canaryScript) return STABLE_NAME;
|
|
35
|
+
const w = typeof state.weight === 'number' ? state.weight : 0;
|
|
36
|
+
if (w <= 0) return STABLE_NAME;
|
|
37
|
+
if (w >= 100) return CANARY_NAME;
|
|
38
|
+
return Math.random() * 100 < w ? CANARY_NAME : STABLE_NAME;
|
|
39
|
+
}
|
|
40
|
+
`;
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=dispatcher-template.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type Cloudflare from 'cloudflare';
|
|
2
|
+
import type { CanaryEffects } from './runtime.ts';
|
|
3
|
+
export interface BuildEffectsOptions {
|
|
4
|
+
readonly cloudflare: Cloudflare;
|
|
5
|
+
readonly accountId: string;
|
|
6
|
+
readonly managedByLabel: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function buildCloudflareEffects(opts: BuildEffectsOptions): CanaryEffects;
|
|
9
|
+
//# sourceMappingURL=effects-cloudflare.d.ts.map
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const MAIN_MODULE = 'worker.mjs';
|
|
2
|
+
function buildBindings(props) {
|
|
3
|
+
const out = [];
|
|
4
|
+
for (const [name, text] of Object.entries(props.vars ?? {})) {
|
|
5
|
+
out.push({ type: 'plain_text', name, text });
|
|
6
|
+
}
|
|
7
|
+
for (const [name, text] of Object.entries(props.secrets ?? {})) {
|
|
8
|
+
out.push({ type: 'secret_text', name, text });
|
|
9
|
+
}
|
|
10
|
+
for (const b of props.bindings ?? []) {
|
|
11
|
+
if (b.type === 'r2_bucket') {
|
|
12
|
+
out.push({ type: 'r2_bucket', name: b.name, bucket_name: b.bucketName });
|
|
13
|
+
}
|
|
14
|
+
else if (b.type === 'kv_namespace') {
|
|
15
|
+
out.push({ type: 'kv_namespace', name: b.name, namespace_id: b.namespaceId });
|
|
16
|
+
}
|
|
17
|
+
else if (b.type === 'service') {
|
|
18
|
+
out.push({ type: 'service', name: b.name, service: b.service });
|
|
19
|
+
}
|
|
20
|
+
else if (b.type === 'dispatch_namespace') {
|
|
21
|
+
out.push({ type: 'dispatch_namespace', name: b.name, namespace: b.dispatchNamespace });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
function buildMetadata(props, managedByLabel) {
|
|
27
|
+
return {
|
|
28
|
+
main_module: MAIN_MODULE,
|
|
29
|
+
compatibility_date: props.compatibilityDate,
|
|
30
|
+
...(props.compatibilityFlags !== undefined
|
|
31
|
+
? { compatibility_flags: [...props.compatibilityFlags] }
|
|
32
|
+
: {}),
|
|
33
|
+
bindings: buildBindings(props),
|
|
34
|
+
tags: [managedByLabel, 'k1c.io/role=canary'],
|
|
35
|
+
...(props.observability !== undefined
|
|
36
|
+
? { observability: { enabled: props.observability.enabled } }
|
|
37
|
+
: {}),
|
|
38
|
+
...(props.placement !== undefined ? { placement: { mode: props.placement.mode } } : {}),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export function buildCloudflareEffects(opts) {
|
|
42
|
+
const upload = async (dispatchNamespace, scriptName, content, properties) => {
|
|
43
|
+
const file = new File([content], MAIN_MODULE, {
|
|
44
|
+
type: 'application/javascript+module',
|
|
45
|
+
});
|
|
46
|
+
await opts.cloudflare.workersForPlatforms.dispatch.namespaces.scripts.update(dispatchNamespace, scriptName, {
|
|
47
|
+
account_id: opts.accountId,
|
|
48
|
+
metadata: buildMetadata(properties, opts.managedByLabel),
|
|
49
|
+
files: { [MAIN_MODULE]: file },
|
|
50
|
+
});
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
async uploadCanary(input) {
|
|
54
|
+
await upload(input.dispatchNamespace, input.scriptName, input.content, input.properties);
|
|
55
|
+
},
|
|
56
|
+
async promoteCanaryToStable(input) {
|
|
57
|
+
// Re-upload the canary content as the stable script, then remove the canary.
|
|
58
|
+
await upload(input.dispatchNamespace, input.stableScriptName, input.content, input.properties);
|
|
59
|
+
await opts.cloudflare.workersForPlatforms.dispatch.namespaces.scripts.delete(input.dispatchNamespace, input.canaryScriptName, { account_id: opts.accountId });
|
|
60
|
+
},
|
|
61
|
+
async removeCanary(input) {
|
|
62
|
+
await opts.cloudflare.workersForPlatforms.dispatch.namespaces.scripts.delete(input.dispatchNamespace, input.scriptName, { account_id: opts.accountId });
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=effects-cloudflare.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { RolloutStateClient } from './runtime.ts';
|
|
2
|
+
import type { RolloutState } from './state-machine.ts';
|
|
3
|
+
export type RolloutSubCommand = 'status' | 'promote' | 'abort';
|
|
4
|
+
export interface RolloutCommandInput {
|
|
5
|
+
readonly subCommand: RolloutSubCommand;
|
|
6
|
+
readonly target: string;
|
|
7
|
+
}
|
|
8
|
+
export interface RolloutCommandDeps {
|
|
9
|
+
readonly state: RolloutStateClient;
|
|
10
|
+
readonly out: (msg: string) => void;
|
|
11
|
+
readonly err: (msg: string) => void;
|
|
12
|
+
}
|
|
13
|
+
export declare function runRolloutCommand(input: RolloutCommandInput, deps: RolloutCommandDeps): Promise<number>;
|
|
14
|
+
export declare function formatStatus(state: RolloutState): string;
|
|
15
|
+
//# sourceMappingURL=rollout-command.d.ts.map
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export async function runRolloutCommand(input, deps) {
|
|
2
|
+
const parts = input.target.split('/');
|
|
3
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
4
|
+
deps.err(`invalid target "${input.target}"; expected <namespace>/<name>`);
|
|
5
|
+
return 2;
|
|
6
|
+
}
|
|
7
|
+
const key = `rollout/${parts[0]}/${parts[1]}`;
|
|
8
|
+
switch (input.subCommand) {
|
|
9
|
+
case 'status':
|
|
10
|
+
return runStatus(key, deps);
|
|
11
|
+
case 'promote':
|
|
12
|
+
return runPromote(key, deps);
|
|
13
|
+
case 'abort':
|
|
14
|
+
return runAbort(key, deps);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
async function runStatus(key, deps) {
|
|
18
|
+
const state = await readState(key, deps);
|
|
19
|
+
if (state === null) {
|
|
20
|
+
deps.out(`(no rollout state for ${key})`);
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
deps.out(formatStatus(state));
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
async function runPromote(key, deps) {
|
|
27
|
+
const state = await readState(key, deps);
|
|
28
|
+
if (state === null) {
|
|
29
|
+
deps.err(`no rollout state for ${key}; nothing to promote`);
|
|
30
|
+
return 1;
|
|
31
|
+
}
|
|
32
|
+
if (state.status !== 'paused') {
|
|
33
|
+
deps.err(`rollout ${key} is in status="${state.status}"; promote only applies to "paused"`);
|
|
34
|
+
return 1;
|
|
35
|
+
}
|
|
36
|
+
const next = {
|
|
37
|
+
...state,
|
|
38
|
+
currentStepIndex: state.currentStepIndex + 1,
|
|
39
|
+
// Force the next apply to treat any pending duration as elapsed.
|
|
40
|
+
lastAdvanceAt: new Date(0).toISOString(),
|
|
41
|
+
status: 'progressing',
|
|
42
|
+
};
|
|
43
|
+
await deps.state.write(key, JSON.stringify(next));
|
|
44
|
+
deps.out(`rollout ${key}: unpaused; the next \`k1c apply\` will advance to step ${next.currentStepIndex}`);
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
async function runAbort(key, deps) {
|
|
48
|
+
const state = await readState(key, deps);
|
|
49
|
+
if (state === null) {
|
|
50
|
+
deps.err(`no rollout state for ${key}; nothing to abort`);
|
|
51
|
+
return 1;
|
|
52
|
+
}
|
|
53
|
+
const next = {
|
|
54
|
+
...state,
|
|
55
|
+
canaryScript: null,
|
|
56
|
+
canaryHash: null,
|
|
57
|
+
weight: 0,
|
|
58
|
+
status: 'idle',
|
|
59
|
+
currentStepIndex: 0,
|
|
60
|
+
};
|
|
61
|
+
await deps.state.write(key, JSON.stringify(next));
|
|
62
|
+
deps.out(`rollout ${key}: aborted; dispatcher routes 100% to stable`);
|
|
63
|
+
deps.out('(canary script remains in dispatch namespace; the next `k1c apply` will not redeploy it)');
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
async function readState(key, deps) {
|
|
67
|
+
const raw = await deps.state.read(key);
|
|
68
|
+
if (raw === null)
|
|
69
|
+
return null;
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(raw);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
deps.err(`rollout state at ${key} is not valid JSON`);
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export function formatStatus(state) {
|
|
79
|
+
const lines = [
|
|
80
|
+
`status: ${state.status}`,
|
|
81
|
+
`currentStepIndex: ${state.currentStepIndex}`,
|
|
82
|
+
`weight: ${state.weight}%`,
|
|
83
|
+
`stableScript: ${state.stableScript}`,
|
|
84
|
+
`canaryScript: ${state.canaryScript ?? '(none)'}`,
|
|
85
|
+
`stableHash: ${state.stableHash}`,
|
|
86
|
+
`canaryHash: ${state.canaryHash ?? '(none)'}`,
|
|
87
|
+
`startedAt: ${state.startedAt ?? '(never)'}`,
|
|
88
|
+
`lastAdvanceAt: ${state.lastAdvanceAt ?? '(never)'}`,
|
|
89
|
+
];
|
|
90
|
+
return lines.join('\n');
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=rollout-command.js.map
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Rollout, ResourceRef } from '../manifest/types.ts';
|
|
2
|
+
import type { WorkerProperties } from '../providers/worker.ts';
|
|
3
|
+
import { type Action } from './state-machine.ts';
|
|
4
|
+
/**
|
|
5
|
+
* Reads/writes the JSON state document for a single rollout. Production wires this to
|
|
6
|
+
* `cloudflare.kv.namespaces.values.{get, update}`; tests pass an in-memory implementation.
|
|
7
|
+
*/
|
|
8
|
+
export interface RolloutStateClient {
|
|
9
|
+
read(key: string): Promise<string | null>;
|
|
10
|
+
write(key: string, value: string): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Side-effect hooks for the actions returned by the state machine. Implementations
|
|
14
|
+
* upload/remove canary scripts via the dispatch-namespace endpoint.
|
|
15
|
+
*/
|
|
16
|
+
export interface CanaryEffects {
|
|
17
|
+
uploadCanary(input: {
|
|
18
|
+
rolloutRef: ResourceRef;
|
|
19
|
+
scriptName: string;
|
|
20
|
+
dispatchNamespace: string;
|
|
21
|
+
content: Uint8Array;
|
|
22
|
+
properties: WorkerProperties;
|
|
23
|
+
}): Promise<void>;
|
|
24
|
+
promoteCanaryToStable(input: {
|
|
25
|
+
rolloutRef: ResourceRef;
|
|
26
|
+
stableScriptName: string;
|
|
27
|
+
canaryScriptName: string;
|
|
28
|
+
dispatchNamespace: string;
|
|
29
|
+
content: Uint8Array;
|
|
30
|
+
properties: WorkerProperties;
|
|
31
|
+
}): Promise<void>;
|
|
32
|
+
removeCanary(input: {
|
|
33
|
+
rolloutRef: ResourceRef;
|
|
34
|
+
scriptName: string;
|
|
35
|
+
dispatchNamespace: string;
|
|
36
|
+
}): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
export interface RolloutAdvanceInput {
|
|
39
|
+
readonly rollout: Rollout;
|
|
40
|
+
/** WorkerProperties of the user's stable Worker (resolved by lower). */
|
|
41
|
+
readonly stableProps: WorkerProperties;
|
|
42
|
+
/** Entrypoint bytes (already read from disk by caller). */
|
|
43
|
+
readonly entrypointContent: Uint8Array;
|
|
44
|
+
}
|
|
45
|
+
export interface RolloutAdvanceReport {
|
|
46
|
+
readonly label: string;
|
|
47
|
+
readonly previousStatus: string;
|
|
48
|
+
readonly nextStatus: string;
|
|
49
|
+
readonly nextWeight: number;
|
|
50
|
+
readonly actions: ReadonlyArray<Action>;
|
|
51
|
+
}
|
|
52
|
+
export interface RuntimeDeps {
|
|
53
|
+
readonly state: RolloutStateClient;
|
|
54
|
+
readonly effects: CanaryEffects;
|
|
55
|
+
readonly now: () => Date;
|
|
56
|
+
}
|
|
57
|
+
export declare function runCanaryAdvance(inputs: ReadonlyArray<RolloutAdvanceInput>, deps: RuntimeDeps): Promise<ReadonlyArray<RolloutAdvanceReport>>;
|
|
58
|
+
export declare function bundleHash(props: WorkerProperties, entrypointContent: Uint8Array): string;
|
|
59
|
+
//# sourceMappingURL=runtime.d.ts.map
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { advance, } from "./state-machine.js";
|
|
3
|
+
export async function runCanaryAdvance(inputs, deps) {
|
|
4
|
+
const reports = [];
|
|
5
|
+
for (const input of inputs) {
|
|
6
|
+
reports.push(await processRollout(input, deps));
|
|
7
|
+
}
|
|
8
|
+
return reports;
|
|
9
|
+
}
|
|
10
|
+
async function processRollout(input, deps) {
|
|
11
|
+
const ns = input.rollout.metadata.namespace ?? 'default';
|
|
12
|
+
const name = input.rollout.metadata.name;
|
|
13
|
+
const label = `${ns}/${name}`;
|
|
14
|
+
const stateKey = `rollout/${ns}/${name}`;
|
|
15
|
+
const stableScriptName = `k1c--${ns}--${name}--stable`;
|
|
16
|
+
const canaryScriptName = `k1c--${ns}--${name}--canary`;
|
|
17
|
+
const desiredHash = bundleHash(input.stableProps, input.entrypointContent);
|
|
18
|
+
const steps = extractCanarySteps(input.rollout);
|
|
19
|
+
const previous = await loadState(deps, stateKey);
|
|
20
|
+
const out = advance({
|
|
21
|
+
state: previous,
|
|
22
|
+
desiredHash,
|
|
23
|
+
steps,
|
|
24
|
+
stableScriptName,
|
|
25
|
+
canaryScriptName,
|
|
26
|
+
now: deps.now(),
|
|
27
|
+
});
|
|
28
|
+
await executeActions(input, out.actions, deps, {
|
|
29
|
+
canaryScriptName,
|
|
30
|
+
stableScriptName,
|
|
31
|
+
});
|
|
32
|
+
await deps.state.write(stateKey, JSON.stringify(out.nextState));
|
|
33
|
+
return {
|
|
34
|
+
label,
|
|
35
|
+
previousStatus: previous?.status ?? 'absent',
|
|
36
|
+
nextStatus: out.nextState.status,
|
|
37
|
+
nextWeight: out.nextState.weight,
|
|
38
|
+
actions: out.actions,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
async function loadState(deps, key) {
|
|
42
|
+
const raw = await deps.state.read(key);
|
|
43
|
+
if (raw === null)
|
|
44
|
+
return null;
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(raw);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async function executeActions(input, actions, deps, names) {
|
|
53
|
+
const dispatchNs = input.stableProps.dispatchNamespace;
|
|
54
|
+
if (dispatchNs === undefined) {
|
|
55
|
+
throw new Error(`Rollout ${input.rollout.metadata.name}: stable WorkerProperties is missing dispatchNamespace; canary runtime requires it`);
|
|
56
|
+
}
|
|
57
|
+
const ref = {
|
|
58
|
+
apiVersion: input.rollout.apiVersion,
|
|
59
|
+
kind: input.rollout.kind,
|
|
60
|
+
namespace: input.rollout.metadata.namespace ?? 'default',
|
|
61
|
+
name: input.rollout.metadata.name,
|
|
62
|
+
};
|
|
63
|
+
const canaryProps = {
|
|
64
|
+
...input.stableProps,
|
|
65
|
+
scriptName: names.canaryScriptName,
|
|
66
|
+
};
|
|
67
|
+
for (const action of actions) {
|
|
68
|
+
switch (action.kind) {
|
|
69
|
+
case 'init-stable':
|
|
70
|
+
case 'wait':
|
|
71
|
+
case 'set-weight':
|
|
72
|
+
// No script-side work: stable is uploaded by the standard apply path,
|
|
73
|
+
// and `set-weight` is realised by the dispatcher reading our updated state JSON.
|
|
74
|
+
break;
|
|
75
|
+
case 'upload-canary':
|
|
76
|
+
await deps.effects.uploadCanary({
|
|
77
|
+
rolloutRef: ref,
|
|
78
|
+
scriptName: names.canaryScriptName,
|
|
79
|
+
dispatchNamespace: dispatchNs,
|
|
80
|
+
content: input.entrypointContent,
|
|
81
|
+
properties: canaryProps,
|
|
82
|
+
});
|
|
83
|
+
break;
|
|
84
|
+
case 'promote-canary':
|
|
85
|
+
await deps.effects.promoteCanaryToStable({
|
|
86
|
+
rolloutRef: ref,
|
|
87
|
+
stableScriptName: names.stableScriptName,
|
|
88
|
+
canaryScriptName: names.canaryScriptName,
|
|
89
|
+
dispatchNamespace: dispatchNs,
|
|
90
|
+
content: input.entrypointContent,
|
|
91
|
+
properties: { ...input.stableProps, scriptName: names.stableScriptName },
|
|
92
|
+
});
|
|
93
|
+
break;
|
|
94
|
+
case 'remove-canary':
|
|
95
|
+
await deps.effects.removeCanary({
|
|
96
|
+
rolloutRef: ref,
|
|
97
|
+
scriptName: names.canaryScriptName,
|
|
98
|
+
dispatchNamespace: dispatchNs,
|
|
99
|
+
});
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function extractCanarySteps(rollout) {
|
|
105
|
+
if ('canary' in rollout.spec.strategy) {
|
|
106
|
+
return rollout.spec.strategy.canary.steps;
|
|
107
|
+
}
|
|
108
|
+
// blueGreen → equivalent to single 100% step (immediate cutover)
|
|
109
|
+
return [{ setWeight: 100 }];
|
|
110
|
+
}
|
|
111
|
+
export function bundleHash(props, entrypointContent) {
|
|
112
|
+
const h = createHash('sha256');
|
|
113
|
+
h.update(entrypointContent);
|
|
114
|
+
const bindingsKey = canonicalize({
|
|
115
|
+
vars: props.vars ?? {},
|
|
116
|
+
secrets: props.secrets ?? {},
|
|
117
|
+
bindings: props.bindings ?? [],
|
|
118
|
+
compatibilityDate: props.compatibilityDate,
|
|
119
|
+
compatibilityFlags: props.compatibilityFlags ?? [],
|
|
120
|
+
observability: props.observability,
|
|
121
|
+
placement: props.placement,
|
|
122
|
+
});
|
|
123
|
+
h.update(bindingsKey);
|
|
124
|
+
return h.digest('hex');
|
|
125
|
+
}
|
|
126
|
+
function canonicalize(value) {
|
|
127
|
+
return JSON.stringify(value, (_k, v) => {
|
|
128
|
+
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
|
|
129
|
+
const sorted = {};
|
|
130
|
+
for (const k of Object.keys(v).sort()) {
|
|
131
|
+
sorted[k] = v[k];
|
|
132
|
+
}
|
|
133
|
+
return sorted;
|
|
134
|
+
}
|
|
135
|
+
return v;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
//# sourceMappingURL=runtime.js.map
|