@astrale-domains/services 0.5.1 → 0.6.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/README.md +149 -51
- package/dist/scaffold/create-service.mjs +94 -0
- package/dist/scaffold/template/.env.example +4 -0
- package/dist/scaffold/template/README.md +75 -0
- package/dist/scaffold/template/gitignore +6 -0
- package/dist/scaffold/template/scripts/build.mjs +20 -0
- package/dist/scaffold/template/scripts/deploy.mjs +65 -0
- package/dist/scaffold/template/scripts/service.mjs +69 -0
- package/dist/scaffold/template/src/account.js +31 -0
- package/dist/scaffold/template/src/index.js +50 -0
- package/dist/scaffold/template/src/job.js +22 -0
- package/dist/schema/index.d.ts +2 -2
- package/dist/schema/index.js +1 -1
- package/dist/schema/modules/deployment/classes/cloudflare-deployment.d.ts +41 -20
- package/dist/schema/modules/deployment/classes/deployment.d.ts +54 -25
- package/dist/schema/modules/deployment/classes/deployment.js +20 -9
- package/dist/schema/modules/deployment/functions/retire-deployment.d.ts +5 -5
- package/dist/schema/modules/deployment/index.d.ts +2 -2
- package/dist/schema/modules/deployment/index.js +1 -1
- package/dist/schema/modules/deployment/types/deployment-hosting.d.ts +19 -49
- package/dist/schema/modules/deployment/types/deployment-hosting.js +10 -10
- package/dist/schema/modules/deployment/types/deployment-request.d.ts +45 -93
- package/dist/schema/modules/deployment/types/deployment-request.js +60 -30
- package/dist/schema/modules/deployment/types/deployment-response.d.ts +14 -22
- package/dist/schema/modules/deployment/types/deployment-response.js +5 -2
- package/dist/schema/modules/deployment/types/deployment-secret.d.ts +2 -2
- package/dist/schema/modules/deployment/types/deployment-secret.js +2 -2
- package/dist/schema/modules/deployment/types/deployment-summary.d.ts +13 -112
- package/dist/schema/modules/deployment/types/deployment-summary.js +16 -18
- package/dist/schema/modules/deployment/types/index.d.ts +10 -12
- package/dist/schema/modules/deployment/types/index.js +5 -6
- package/dist/schema/modules/deployment/types/{frozen-configuration.d.ts → legacy-deployment.d.ts} +24 -11
- package/dist/schema/modules/deployment/types/{frozen-configuration.js → legacy-deployment.js} +34 -10
- package/dist/schema/modules/deployment/types/legacy-migration.d.ts +17 -0
- package/dist/schema/modules/deployment/types/legacy-migration.js +17 -0
- package/dist/schema/modules/deployment/types/worker-configuration.d.ts +21 -0
- package/dist/schema/modules/deployment/types/worker-configuration.js +21 -0
- package/dist/schema/modules/deployment/types/worker-record.d.ts +12 -0
- package/dist/schema/modules/deployment/types/worker-record.js +15 -0
- package/dist/schema/modules/service/classes/cloudflare-service.d.ts +72 -135
- package/dist/schema/modules/service/classes/cloudflare-service.js +6 -1
- package/dist/schema/modules/service/classes/service.d.ts +17 -7
- package/dist/schema/modules/service/classes/service.js +14 -8
- package/dist/schema/modules/service/functions/deploy-service.d.ts +44 -100
- package/dist/schema/modules/service/functions/deploy-service.js +6 -5
- package/dist/schema/modules/service/functions/describe-hosting.d.ts +0 -1
- package/dist/schema/modules/service/functions/index.d.ts +1 -0
- package/dist/schema/modules/service/functions/index.js +1 -0
- package/dist/schema/modules/service/functions/list-service-deployments.d.ts +12 -36
- package/dist/schema/modules/service/functions/migrate-legacy-deployments.d.ts +17 -0
- package/dist/schema/modules/service/functions/migrate-legacy-deployments.js +15 -0
- package/dist/schema/modules/service/functions/promote-service.d.ts +2 -2
- package/dist/schema/modules/service/functions/set-service-secret.d.ts +2 -2
- package/dist/schema/modules/service/functions/transfer-service.d.ts +2 -2
- package/dist/schema/modules/service/index.d.ts +1 -1
- package/dist/schema/modules/service/index.js +1 -1
- package/dist/schema/schema.d.ts +158 -177
- package/package.json +11 -7
- package/dist/schema/modules/deployment/types/content/index.d.ts +0 -1
- package/dist/schema/modules/deployment/types/content/index.js +0 -1
- package/dist/schema/modules/deployment/types/content/limits.d.ts +0 -7
- package/dist/schema/modules/deployment/types/content/limits.js +0 -7
- package/dist/schema/modules/deployment/types/deployment-record.d.ts +0 -35
- package/dist/schema/modules/deployment/types/deployment-record.js +0 -31
- package/dist/schema/modules/deployment/types/service-deployment-artifact-module.d.ts +0 -26
- package/dist/schema/modules/deployment/types/service-deployment-artifact-module.js +0 -10
- package/dist/schema/modules/deployment/types/service-deployment-assets.d.ts +0 -49
- package/dist/schema/modules/deployment/types/service-deployment-assets.js +0 -16
- package/dist/schema/modules/deployment/types/service-deployment-content.d.ts +0 -19
- package/dist/schema/modules/deployment/types/service-deployment-content.js +0 -24
package/README.md
CHANGED
|
@@ -1,25 +1,35 @@
|
|
|
1
1
|
# Services
|
|
2
2
|
|
|
3
|
-
`services.astrale.ai` hosts Services:
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
`services.astrale.ai` hosts Services: Cloudflare Workers an Identity owns. A Service is the stable
|
|
4
|
+
identity of one project in one environment. Every deploy adds an immutable deployment of it, and
|
|
5
|
+
the Service's URL serves the one it currently points at, like a project and its deployments.
|
|
6
|
+
|
|
7
|
+
Services is agnostic of what it hosts. An Astrale Domain release (`astrale-domain deploy` with
|
|
8
|
+
`@astrale-os/adapter-astrale`) and a generic Worker (a cron job, an API, a
|
|
9
|
+
[`create-astrale-service`](#generic-services) project) are the same thing to it: a Worker build,
|
|
10
|
+
the configuration it freezes, the secrets it binds and, when it publishes one, an opaque record.
|
|
11
|
+
Every Domain fact (origin, release, Schema revision, Publication, issuer) is the deployer's.
|
|
6
12
|
Provider protocols stay behind a neutral SDK Integration, and the graph keeps only admitted
|
|
7
13
|
lifecycle evidence.
|
|
8
14
|
|
|
9
15
|
## Model
|
|
10
16
|
|
|
11
|
-
- **`Service` / `CloudflareService`**: one `(
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
`state` is `active`, or
|
|
17
|
+
- **`Service` / `CloudflareService`**: one `(project, environment)`. A project is any name
|
|
18
|
+
(`PROJECT_NAME` of `@astrale-os/sdk/deployment/worker`); a Domain deployer names its Domain's
|
|
19
|
+
origin. Its `name` is the opaque 16-character `serviceName(project, environment)`, unique among
|
|
20
|
+
Services, the DNS label of `url`, `https://<name>.<routing domain>`. `state` is `active`, or
|
|
21
|
+
`deleting` while its deletion retires its deployments.
|
|
15
22
|
- **`service_owned_by`**: every Service has exactly one owner, an Identity: a user, a group or a
|
|
16
23
|
service account. It is chosen once, when the first deploy creates the Service, and changes only
|
|
17
24
|
by `transfer`.
|
|
18
|
-
- **`Deployment` / `CloudflareDeployment`**: one immutable release of a Service
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
`
|
|
25
|
+
- **`Deployment` / `CloudflareDeployment`**: one immutable release of a Service, served by one
|
|
26
|
+
Workers for Platforms script at its own URL, `https://<label>.<routing domain>`, where `label` is
|
|
27
|
+
`<name>-<content16>` and the content covers the build digest and the configuration digest. It
|
|
28
|
+
keeps generic facts only: `buildDigest`, its `configuration` (`WorkerConfigurationV1`) and
|
|
29
|
+
`configurationDigest`, the `record` it publishes if any (opaque bytes and their media type), its
|
|
30
|
+
key id when it asked for a signing identity, the script's code hash, the keyed digests of its
|
|
31
|
+
secrets, and its lifecycle: `deploying` → `active` → `retiring` → `retired`, or `failed`. A
|
|
32
|
+
retired deployment is never served again.
|
|
23
33
|
- **`deployment_of`**: the Service a deployment is a version of. A deployment has no owner of its
|
|
24
34
|
own: it belongs to whoever owns its Service.
|
|
25
35
|
- **`current_deployment`**: the deployment the Service's URL serves and its crons run on. Promoting
|
|
@@ -42,13 +52,13 @@ caller in three groups also reaches the Services of each.
|
|
|
42
52
|
Service of the group, deletion included.
|
|
43
53
|
- `OwnDeployment` gives a deployment to the owner of its Service.
|
|
44
54
|
|
|
45
|
-
Every Method is `authenticated`: a deployer's identities on the Admin instance hold
|
|
46
|
-
Services' Functions, which an `authorized` Method requires of every non-Root caller.
|
|
47
|
-
asks the Kernel `Identity.can` for the caller on its own session before it reads
|
|
48
|
-
who does not own the Service finds none (`SERVICE_NOT_FOUND`,
|
|
49
|
-
of another owner's Service is refused
|
|
50
|
-
deployers `can_use` on these Methods,
|
|
51
|
-
checks go.
|
|
55
|
+
Every Method but `migrate` is `authenticated`: a deployer's identities on the Admin instance hold
|
|
56
|
+
no `can_use` on Services' Functions, which an `authorized` Method requires of every non-Root caller.
|
|
57
|
+
Each handler asks the Kernel `Identity.can` for the caller on its own session before it reads
|
|
58
|
+
anything: a caller who does not own the Service finds none (`SERVICE_NOT_FOUND`,
|
|
59
|
+
`DEPLOYMENT_NOT_FOUND`), and a deploy of another owner's Service is refused
|
|
60
|
+
`service-owned-elsewhere`. The day the Admin instance grants deployers `can_use` on these Methods,
|
|
61
|
+
they become `authorized` with the Policy and the handler checks go.
|
|
52
62
|
|
|
53
63
|
`list` reads Services under the Services Domain credential and keeps those the Kernel answers
|
|
54
64
|
`OwnService` for, in batches: a caller-mode read does not reach the Identity end of
|
|
@@ -57,39 +67,48 @@ checks go.
|
|
|
57
67
|
|
|
58
68
|
## Methods
|
|
59
69
|
|
|
60
|
-
Call them on the Admin instance (`astrale call ... --admin`).
|
|
70
|
+
Call them on the Admin instance (`astrale call ... --admin`). Their wire contract is
|
|
71
|
+
`@astrale-os/sdk/deployment/worker`'s.
|
|
61
72
|
|
|
62
73
|
| Method | On | What it does |
|
|
63
74
|
| --- | --- | --- |
|
|
64
|
-
| `deploy` | `CloudflareService` (static) | Hosts one release (`
|
|
65
|
-
| `hosting` | `CloudflareService` (static) | The routing domain Services and deployments are addressed under; names are always opaque. |
|
|
66
|
-
| `list` | `CloudflareService` (static) | The deployments of the Services the caller owns, narrowed to
|
|
75
|
+
| `deploy` | `CloudflareService` (static) | Hosts one Worker release (`WorkerDeploymentRequestV1`) as a deployment of the Service of its project and environment, creating the Service on its first deploy, and by default (`promote` absent or `true`) makes the Service serve it. Answers `WorkerDeploymentResponseV1`. |
|
|
76
|
+
| `hosting` | `CloudflareService` (static) | The routing domain Services and deployments are addressed under (`WorkerHostingV1`); names are always opaque. |
|
|
77
|
+
| `list` | `CloudflareService` (static) | The deployments of the Services the caller owns (`WorkerDeploymentSummaryV1`), narrowed to a `project` and an `environment` when given, each with `current` and the record it publishes. |
|
|
67
78
|
| `promote` | `CloudflareService` | Points the Service at one of its active deployments, named by its label. |
|
|
68
79
|
| `transfer` | `CloudflareService` | Gives the Service to another owner the caller is or acts as. |
|
|
69
80
|
| `setSecret`, `secrets` | `CloudflareService` | Rewrites one declared secret of the current deployment in place; lists the names it binds, never a value. |
|
|
70
81
|
| `setSchedule`, `schedules` | `CloudflareService` | Replaces or reads the Service's UTC cron set; each cron fires on the current deployment. |
|
|
71
82
|
| `logs` | `CloudflareService` | Recent logs of the current deployment, or of one deployment named by `label`. |
|
|
72
83
|
| `delete` | `CloudflareService` | Stops routing the Service, retires every deployment of it, then deletes it and frees its name. |
|
|
84
|
+
| `migrate` | `CloudflareService` (static, Root) | Once, after this revision is installed: rewrites the Services and deployments stored before Services hosted any Worker (see [Migration](#migration-to-the-agnostic-model)). |
|
|
73
85
|
| `retire` | `CloudflareDeployment` | Withdraws one deployment for good. The current deployment is never retired alone. |
|
|
74
86
|
|
|
75
87
|
## Deploying
|
|
76
88
|
|
|
77
|
-
`
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
`
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
the
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
89
|
+
`deploy` is a non-durable Action: its input holds the secrets, and no Execution, step result or
|
|
90
|
+
journal entry keeps them.
|
|
91
|
+
|
|
92
|
+
1. Before any read, the SDK's `decodeWorkerDeploymentRequest` admits the request: every file
|
|
93
|
+
decoded within its bounds, the configuration canonical, a record exactly when the configuration
|
|
94
|
+
asks for one, the secrets exactly the names it declares. It recomputes the build digest from
|
|
95
|
+
the uploaded bytes (`build-mismatch`) and the label from the Service name, the build and the
|
|
96
|
+
configuration (`address-mismatch`); anything else is `invalid-deployment`. A configuration that
|
|
97
|
+
asks for the router where this environment has none is `hosting-unavailable`.
|
|
98
|
+
2. It reads the Service of the name under the Services Domain credential and proves the caller's
|
|
99
|
+
authority on the caller's own session: `OwnService` on an existing Service, `ActAs` on the owner
|
|
100
|
+
it names for a new one.
|
|
101
|
+
3. A new label is created under a graph lease. The provider uploads it create-only with the host
|
|
102
|
+
bindings (its URL as `WORKER_URL`, the recomputed `ASTRALE_BUILD_DIGEST`, the version
|
|
103
|
+
metadata `ASTRALE_VERSION_METADATA`), the configuration's variables, a signing key generated
|
|
104
|
+
for this deployment alone (`ASTRALE_SIGNING_IDENTITY`) only when it asks `signingIdentity`, the
|
|
105
|
+
router (`ROUTER`) only when it asks `router`, and the request's secrets. When the request names
|
|
106
|
+
readiness paths, each must answer 200 from the uploaded version (`x-astrale-worker-version`);
|
|
107
|
+
none skips the probe. The label is then activated through the routing KV `record:<label>` with
|
|
108
|
+
the record the deployment publishes, served byte for byte with its media type at
|
|
109
|
+
`/.well-known/astrale/deployment.json`, or a placeholder when it publishes none. The graph
|
|
110
|
+
records it active with its code hash, its key id when it has one, and keyed HMAC-SHA256 digests
|
|
111
|
+
of its secrets.
|
|
93
112
|
4. The same release again is the same label: `deploy` answers `reused`. Its secrets are compared
|
|
94
113
|
with the recorded digests: `unchanged`, or rewritten in place on that deployment alone,
|
|
95
114
|
`updated`.
|
|
@@ -97,28 +116,49 @@ result or journal entry keeps them.
|
|
|
97
116
|
served, and the routing KV `current:<name>` names its label. A routing write that failed is
|
|
98
117
|
answered `service-unavailable`; deploying or promoting again converges.
|
|
99
118
|
|
|
100
|
-
|
|
101
|
-
|
|
119
|
+
The names the host binds itself (`WORKER_HOST_BINDINGS`: `WORKER_URL`, `ASTRALE_BUILD_DIGEST`,
|
|
120
|
+
`ASTRALE_VERSION_METADATA`, `ASTRALE_SIGNING_IDENTITY`, `ROUTER`, `ASSETS`) are never a variable
|
|
121
|
+
or a secret of the request.
|
|
122
|
+
|
|
123
|
+
- **A Domain** is deployed by `astrale-domain deploy <environment>` with `astrale()` of
|
|
124
|
+
`@astrale-os/adapter-astrale`: its project is the Domain's origin, its build manifest members are
|
|
125
|
+
the Domain's (so `ASTRALE_BUILD_DIGEST` is the sealed release's), its execution settings travel
|
|
126
|
+
as the `ASTRALE_SETTINGS` variable, it publishes its `DeploymentRecordV1` and asks for a signing
|
|
127
|
+
identity, and readiness probes the release's documents. The adapter, not Services, verifies the
|
|
128
|
+
release served and installs it deliberately.
|
|
129
|
+
- **Any other Worker** is deployed with `@astrale-os/adapter-astrale/services`
|
|
130
|
+
(`readWorkerDirectory`, `deployWorker`, `callService`), as the projects
|
|
131
|
+
[`create-astrale-service`](#generic-services) generates do.
|
|
102
132
|
|
|
103
133
|
## Routing and crons
|
|
104
134
|
|
|
105
|
-
The Services dispatcher
|
|
135
|
+
The Services dispatcher is the hosting kit's dispatcher (`@astrale-os/adapter-cloudflare/hosting`),
|
|
136
|
+
extended only with the frozen legacy tenant routing of `providers/cloudflare/legacy` (removal: D20).
|
|
137
|
+
It serves one routing domain:
|
|
106
138
|
|
|
107
139
|
- `https://<label>.<routing domain>` serves that deployment: `record:<label>` activates it, and
|
|
108
|
-
`retirement:<label>` answers `DEPLOYMENT_RETIRED` (`410`).
|
|
140
|
+
`retirement:<label>` answers `DEPLOYMENT_RETIRED` (`410`). It answers the deployment's record at
|
|
141
|
+
`/.well-known/astrale/deployment.json` itself; a deployment that publishes none answers that path.
|
|
109
142
|
- `https://<name>.<routing domain>` serves the deployment `current:<name>` names, through the same
|
|
110
143
|
path, so a retired deployment is never served through its Service either.
|
|
111
144
|
- Every minute the dispatcher reads the `cron:<name>` keys and posts each due cron to the current
|
|
112
|
-
deployment of that Service
|
|
145
|
+
deployment of that Service: `POST https://cron.internal/__scheduled` with the expression in
|
|
146
|
+
`x-astrale-cron` and the minute it fired for, in epoch milliseconds, in
|
|
147
|
+
`x-astrale-scheduled-time`, within a 10-second deadline. A non-2xx answer is logged. The public
|
|
148
|
+
never reaches `/__scheduled`: the dispatcher refuses it in every spelling (case, repeated or
|
|
149
|
+
trailing slashes, percent-encoding) before any lookup. A run is not exactly-once: a job must be
|
|
150
|
+
safe to repeat.
|
|
151
|
+
- A legacy tenant Worker with a `service:<name>` entry keeps its routing and crons ahead of all of
|
|
152
|
+
this, until D20 removes them.
|
|
113
153
|
|
|
114
154
|
## Secrets
|
|
115
155
|
|
|
116
156
|
Services never keeps a value. A deploy binds the request's secrets to the new deployment only;
|
|
117
|
-
every other deployment keeps its own. `setSecret` rewrites one name the current deployment's
|
|
157
|
+
every other deployment keeps its own. `setSecret` rewrites one name the current deployment's
|
|
118
158
|
configuration declares, in place, under the deployment's graph lease: it compares the keyed
|
|
119
159
|
digests, writes only changed values, forgets the digests of the names it writes first and records
|
|
120
160
|
the new ones once every write succeeded. The provider serves a new value a few seconds later. A
|
|
121
|
-
new name, or removing one, is a new release;
|
|
161
|
+
new name, or removing one, is a new release; a signing key never rotates.
|
|
122
162
|
|
|
123
163
|
Never pass a secret value in argv. Write the input to a file only you can read, outside the
|
|
124
164
|
repository:
|
|
@@ -139,7 +179,7 @@ carries.
|
|
|
139
179
|
|
|
140
180
|
```sh
|
|
141
181
|
astrale call /:services.astrale.ai:class.CloudflareService:list \
|
|
142
|
-
|
|
182
|
+
project=agencies.example.com environment=production --admin --json
|
|
143
183
|
astrale call "@$SERVICE::services.astrale.ai:class.CloudflareService.method.promote" \
|
|
144
184
|
label=$PREVIOUS_LABEL --admin --json
|
|
145
185
|
```
|
|
@@ -158,6 +198,62 @@ deployment. It answers `deleting` with `retryAfterMs` until the last one is reti
|
|
|
158
198
|
the Service with its deployments in one Mutation and answers `deleted`. The name is then free
|
|
159
199
|
again.
|
|
160
200
|
|
|
201
|
+
## Generic Services
|
|
202
|
+
|
|
203
|
+
The package ships an isolated scaffolding binary (the root import remains Schema-only):
|
|
204
|
+
|
|
205
|
+
```sh
|
|
206
|
+
npm exec --package @astrale-domains/services -- create-astrale-service nightly-report
|
|
207
|
+
cd nightly-report
|
|
208
|
+
npm install
|
|
209
|
+
# Edit service.json and src/job.js; put the secret values in .env.production.
|
|
210
|
+
npm run deploy -- production
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
The generator creates local source only and refuses an existing destination: a plain Worker
|
|
214
|
+
(`src/index.js` answering `GET /health` and running `src/job.js` on `POST /__scheduled`), an
|
|
215
|
+
optional ES256 account session to call an Astrale Kernel (`src/account.js`), `service.json` (the
|
|
216
|
+
project, the Workers runtime, the variables, the secret names, readiness paths and crons) and its
|
|
217
|
+
build (esbuild) and deploy scripts. `npm run deploy -- <environment>` builds it, deploys it through
|
|
218
|
+
`@astrale-os/adapter-astrale/services` as one immutable deployment promoted by default
|
|
219
|
+
(`--no-promote` deploys beside the current one), then replaces the Service's crons with
|
|
220
|
+
`service.json`'s. It installs, registers, grants and schedules nothing else. The generated README
|
|
221
|
+
explains promotion and rollback, secrets, crons, logs and deletion. The scaffold lives in
|
|
222
|
+
[`tooling/scaffold`](tooling/scaffold); `pnpm package` copies it to `dist/scaffold`.
|
|
223
|
+
|
|
224
|
+
## Migration to the agnostic model
|
|
225
|
+
|
|
226
|
+
The Kernel installs no Schema revision that existing data does not satisfy
|
|
227
|
+
(`DATA_MIGRATION_REQUIRED`), and the SDK has no install-time migration: a Service stored before
|
|
228
|
+
Services hosted any Worker names its project `origin`, and its deployments store the Domain's
|
|
229
|
+
`FrozenConfigurationV1` and `DeploymentRecordV1`. The transition is expand, migrate, contract:
|
|
230
|
+
|
|
231
|
+
1. **Expand** — this revision admits both forms. Every reader reads a legacy node as the Worker
|
|
232
|
+
deployment it is (`convertLegacyDeployment`): its build digest is its record's; its
|
|
233
|
+
configuration binds what the runtime that uploaded it bound (the compatibility date and flags,
|
|
234
|
+
the author's variables, `ASTRALE_SETTINGS` encoded from the frozen settings as that runtime
|
|
235
|
+
encoded it, the secret names, `record: true`, `signingIdentity: true`, `router` as it was); its
|
|
236
|
+
record is the RFC 8785 JSON its activation published, with the Astrale record media type. Its
|
|
237
|
+
label stays: a stored label is never derived again. Every writer writes the agnostic form only.
|
|
238
|
+
2. **Migrate** — the Kernel's Root calls `CloudflareService.migrate` once. Each legacy Service and
|
|
239
|
+
deployment is rewritten by its own fenced Mutation (`origin` becomes `project`; a deployment
|
|
240
|
+
stores its reading), nothing is written at Cloudflare, and a rerun rewrites nothing more. It
|
|
241
|
+
answers how many it rewrote and names any deployment it could not read (`unconverted`, empty
|
|
242
|
+
when everything converted).
|
|
243
|
+
3. **Contract** — the next revision drops the legacy shapes and `migrate`; the Kernel installs it
|
|
244
|
+
only once no legacy value remains.
|
|
245
|
+
|
|
246
|
+
Call it as the Admin Kernel's Root, with a short-lived root credential file (`--creds`) removed
|
|
247
|
+
once the call answers:
|
|
248
|
+
|
|
249
|
+
```sh
|
|
250
|
+
astrale call "/:services.astrale.ai:class.CloudflareService:migrate" --admin --json \
|
|
251
|
+
--creds "$ROOT_CREDENTIAL_FILE"
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
It answers `{ "services": <n>, "deployments": <n>, "unconverted": [] }`; a second call answers
|
|
255
|
+
zeros.
|
|
256
|
+
|
|
161
257
|
## Admin-hosted runtime
|
|
162
258
|
|
|
163
259
|
Services are hosted for the Admin instance's Kernel alone: its graph is the registry of Service
|
|
@@ -173,14 +269,16 @@ hosting var.
|
|
|
173
269
|
|
|
174
270
|
`pnpm run deploy admin` makes one immutable deployment of this release in the platform dispatch
|
|
175
271
|
namespace. Deploying never installs: install the deployed URL with
|
|
176
|
-
`astrale domain install <url> -i <instance>`.
|
|
177
|
-
|
|
272
|
+
`astrale domain install <url> -i <instance>`. The Cloudflare Provider and its operator tooling
|
|
273
|
+
are built on the hosting kit: provisioning both dispatchers (the Services environment's and the
|
|
274
|
+
platform namespace's), audits, platform retirement and secret rotation are described in
|
|
178
275
|
[`providers/cloudflare/operator/README.md`](providers/cloudflare/operator/README.md).
|
|
179
276
|
|
|
180
277
|
## Domain development
|
|
181
278
|
|
|
182
|
-
Services is developed against its tests: the fake Cloudflare account
|
|
183
|
-
|
|
279
|
+
Services is developed against its tests: the hosting kit's fake Cloudflare account
|
|
280
|
+
(`@astrale-os/adapter-cloudflare/hosting/testing`, running the dispatchers as uploaded, and the
|
|
281
|
+
scaffolded Worker itself) and the [real-Kernel suite](tests/kernel/README.md).
|
|
184
282
|
|
|
185
283
|
```sh
|
|
186
284
|
pnpm typecheck
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { cp, mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { basename, resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const USAGE = 'Usage: create-astrale-service <new-directory>'
|
|
6
|
+
/** A project name Services admits (`PROJECT_NAME`), kept to a directory-friendly subset. */
|
|
7
|
+
const PROJECT = /^[a-z][a-z0-9-]{0,62}$/u
|
|
8
|
+
/** The packages a generated Service depends on, at the versions this package was released with. */
|
|
9
|
+
const PINNED = ['@astrale-os/sdk', '@astrale-os/adapter-astrale', 'esbuild', 'jose']
|
|
10
|
+
|
|
11
|
+
const [directory, ...extra] = process.argv.slice(2)
|
|
12
|
+
if (directory === '--help' || directory === '-h') {
|
|
13
|
+
console.log(
|
|
14
|
+
`${USAGE}\nCreates the local source of a Cloudflare Worker that Admin's Services hosts: an HTTP` +
|
|
15
|
+
' entry, a cron job and its build and deploy scripts. It installs, registers, deploys and' +
|
|
16
|
+
' schedules nothing.',
|
|
17
|
+
)
|
|
18
|
+
} else {
|
|
19
|
+
try {
|
|
20
|
+
if (!directory || directory.startsWith('-') || extra.length > 0) throw new Error(USAGE)
|
|
21
|
+
const destination = resolve(directory)
|
|
22
|
+
const name = basename(destination)
|
|
23
|
+
if (!PROJECT.test(name)) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
'Use a lower-case project name beginning with a letter (letters, digits and dashes, at most 63 characters).',
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
const versions = await pinnedVersions()
|
|
29
|
+
// Exclusive creation keeps an existing project and refuses a symbolic link.
|
|
30
|
+
await mkdir(destination)
|
|
31
|
+
const template = new URL('./template/', import.meta.url)
|
|
32
|
+
for (const entry of await readdir(template)) {
|
|
33
|
+
await cp(new URL(entry, template), resolve(destination, entry), {
|
|
34
|
+
recursive: true,
|
|
35
|
+
force: false,
|
|
36
|
+
errorOnExist: true,
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
await rename(resolve(destination, 'gitignore'), resolve(destination, '.gitignore'))
|
|
40
|
+
await writeJson(resolve(destination, 'package.json'), {
|
|
41
|
+
name,
|
|
42
|
+
version: '0.0.0',
|
|
43
|
+
private: true,
|
|
44
|
+
type: 'module',
|
|
45
|
+
scripts: {
|
|
46
|
+
build: 'node scripts/build.mjs',
|
|
47
|
+
deploy: 'node scripts/deploy.mjs',
|
|
48
|
+
},
|
|
49
|
+
dependencies: {
|
|
50
|
+
'@astrale-os/sdk': versions['@astrale-os/sdk'],
|
|
51
|
+
jose: versions.jose,
|
|
52
|
+
},
|
|
53
|
+
devDependencies: {
|
|
54
|
+
'@astrale-os/adapter-astrale': versions['@astrale-os/adapter-astrale'],
|
|
55
|
+
esbuild: versions.esbuild,
|
|
56
|
+
},
|
|
57
|
+
engines: { node: '>=22.13' },
|
|
58
|
+
})
|
|
59
|
+
await writeJson(resolve(destination, 'service.json'), {
|
|
60
|
+
project: name,
|
|
61
|
+
compatibilityDate: '2026-09-01',
|
|
62
|
+
compatibilityFlags: ['nodejs_compat'],
|
|
63
|
+
vars: { KERNEL: '', TARGET: '' },
|
|
64
|
+
secrets: ['ACCOUNT_PRIVATE_JWK'],
|
|
65
|
+
readiness: ['/health'],
|
|
66
|
+
crons: [],
|
|
67
|
+
})
|
|
68
|
+
console.log(
|
|
69
|
+
`Created ${destination}\nInstall its dependencies, edit service.json and src/job.js, then deploy:` +
|
|
70
|
+
' npm run deploy -- <environment>. Its README explains each step.',
|
|
71
|
+
)
|
|
72
|
+
} catch (error) {
|
|
73
|
+
console.error(
|
|
74
|
+
error.code === 'EEXIST' ? 'Destination already exists; no file was written.' : error.message,
|
|
75
|
+
)
|
|
76
|
+
process.exitCode = 1
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The versions of `PINNED` this package declares, in its dependencies or devDependencies. */
|
|
81
|
+
async function pinnedVersions() {
|
|
82
|
+
const owner = JSON.parse(await readFile(new URL('../../package.json', import.meta.url), 'utf8'))
|
|
83
|
+
const declared = { ...owner.devDependencies, ...owner.dependencies }
|
|
84
|
+
return Object.fromEntries(
|
|
85
|
+
PINNED.map((name) => {
|
|
86
|
+
if (typeof declared[name] !== 'string') throw new Error(`No version of ${name} is pinned.`)
|
|
87
|
+
return [name, declared[name]]
|
|
88
|
+
}),
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function writeJson(path, value) {
|
|
93
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx' })
|
|
94
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
# The secrets service.json declares, for one environment: copy to .env.<environment> (ignored by
|
|
2
|
+
# git). `npm run deploy -- <environment>` reads them and binds them to the new deployment; they are
|
|
3
|
+
# never stored by Services. One value per line, as NAME=value.
|
|
4
|
+
ACCOUNT_PRIVATE_JWK=
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Astrale Service
|
|
2
|
+
|
|
3
|
+
A plain Cloudflare Worker that Admin's Services hosts (`services.astrale.ai`): an HTTP entry and a
|
|
4
|
+
cron job, with no Cloudflare account of your own. It is not an Astrale Domain: it publishes no
|
|
5
|
+
Schema and installs nothing. Services knows nothing of what it serves either: it runs the Worker
|
|
6
|
+
you build, at its own URL, under your Identity.
|
|
7
|
+
|
|
8
|
+
- `src/index.js` answers `GET /health` and runs `src/job.js` on `POST /__scheduled`; every other
|
|
9
|
+
path answers 404. Add your routes there.
|
|
10
|
+
- `src/job.js` calls one callable of an Astrale Kernel as this Service's own account
|
|
11
|
+
(`src/account.js`, an ES256 key registered on a dedicated Identity). Replace it with your job.
|
|
12
|
+
- `service.json` is what every deployment freezes beside its code: the project (with an environment,
|
|
13
|
+
it names the Service), the Workers compatibility date and flags, plain variables, the names of its
|
|
14
|
+
secrets, the readiness paths probed before a deployment is activated, and the Service's crons.
|
|
15
|
+
|
|
16
|
+
## Deploy
|
|
17
|
+
|
|
18
|
+
Install the dependencies (Node 22.13+) and the Astrale CLI, and sign in: `astrale auth login`.
|
|
19
|
+
Services is called on the Admin instance as the CLI's identity.
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
npm install
|
|
23
|
+
cp .env.example .env.production # then fill in the secret values; git ignores it
|
|
24
|
+
npm run deploy -- production # --admin <bookmark>, --as <identity>, --no-promote
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`deploy` builds `.dist/worker.mjs`, then calls `CloudflareService.deploy` through
|
|
28
|
+
`@astrale-os/adapter-astrale/services` (`readWorkerDirectory`, `deployWorker`). The first deploy of
|
|
29
|
+
a project and an environment creates their Service, owned by you, or by the Identity you act as with
|
|
30
|
+
`--as`. Each deploy is one immutable deployment at `https://<label>.<routing domain>`; the Service's
|
|
31
|
+
URL, `https://<service>.<routing domain>`, serves the one last promoted, by default the new one. The
|
|
32
|
+
same code and configuration reuse their deployment and rewrite its secrets in place when their
|
|
33
|
+
values changed; any other change is a new deployment. Secret values come from `.env.<environment>`
|
|
34
|
+
(or the process environment) and are never stored by Services, nor printed.
|
|
35
|
+
|
|
36
|
+
`deploy` answers the deployment's `label`, `url` and the `serviceId` every Service Method is called
|
|
37
|
+
on, then replaces the Service's crons with `service.json`'s `crons`.
|
|
38
|
+
|
|
39
|
+
## Operate
|
|
40
|
+
|
|
41
|
+
Call the Service's Methods on the Admin instance with its id, as its owner:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
SERVICE=@<serviceId>::services.astrale.ai:class.CloudflareService
|
|
45
|
+
astrale call "$SERVICE.method.promote" label=<label> --admin --json # roll back or forward
|
|
46
|
+
astrale call "$SERVICE.method.setSchedule" --admin -d '{"crons":["*/5 * * * *"]}' --json
|
|
47
|
+
astrale call "$SERVICE.method.schedules" --admin --json
|
|
48
|
+
astrale call "$SERVICE.method.logs" --admin -d '{"tail":50}' --json
|
|
49
|
+
astrale call "$SERVICE.method.setSecret" --admin -d @secret.json --json # { "name", "value" }
|
|
50
|
+
astrale call "$SERVICE.method.secrets" --admin --json # names only
|
|
51
|
+
astrale call "$SERVICE.method.delete" --admin --json
|
|
52
|
+
astrale call /:services.astrale.ai:class.CloudflareService:list \
|
|
53
|
+
project=<project> environment=production --admin --json
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
- **Promote, roll back**: `promote` points the Service's URL at one of its active deployments.
|
|
57
|
+
- **Retire**: `astrale call "@<deploymentId>::services.astrale.ai:class.CloudflareDeployment.method.retire" --admin`
|
|
58
|
+
withdraws one deployment for good, in two calls about 65 seconds apart. The current deployment is
|
|
59
|
+
never retired alone: promote another one first, or delete the Service.
|
|
60
|
+
- **Secrets**: `setSecret` rewrites one declared secret of the current deployment in place; write
|
|
61
|
+
its input to a file only you can read, never in argv. A new secret name is a new deployment: add
|
|
62
|
+
it to `service.json` and deploy. Put the new value in `.env.<environment>` too: the next deploy
|
|
63
|
+
binds the values it carries.
|
|
64
|
+
- **Crons**: five-field UTC expressions. Each due cron is posted by the Services dispatcher to the
|
|
65
|
+
deployment the Service serves, as `POST https://cron.internal/__scheduled` with `x-astrale-cron`
|
|
66
|
+
(the expression) and `x-astrale-scheduled-time` (the minute it fired for, in epoch ms), within a
|
|
67
|
+
10 s deadline. The public never reaches `/__scheduled`, in any spelling, and a run is at most once
|
|
68
|
+
per minute but not exactly once: make the job safe to repeat.
|
|
69
|
+
- **Logs**: `logs` reads the recent logs of the current deployment, or of one by `label`.
|
|
70
|
+
- **Delete**: `delete` stops routing the Service and its crons, retires every deployment, then
|
|
71
|
+
deletes the Service and frees its name; call it again after `retryAfterMs` until it answers
|
|
72
|
+
`deleted`.
|
|
73
|
+
|
|
74
|
+
`/health` proves the Worker runs, not that its account is registered or authorized: check the job's
|
|
75
|
+
real call once, then add its cron.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { build } from 'esbuild'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
4
|
+
|
|
5
|
+
/** Bundle the Worker into one ES module, `.dist/worker.mjs`, as Workers loads it. */
|
|
6
|
+
export async function buildWorker(directory = process.cwd()) {
|
|
7
|
+
await build({
|
|
8
|
+
absWorkingDir: resolve(directory),
|
|
9
|
+
entryPoints: ['src/index.js'],
|
|
10
|
+
outfile: '.dist/worker.mjs',
|
|
11
|
+
bundle: true,
|
|
12
|
+
platform: 'browser',
|
|
13
|
+
conditions: ['workerd', 'worker', 'browser'],
|
|
14
|
+
format: 'esm',
|
|
15
|
+
target: 'es2022',
|
|
16
|
+
logLevel: 'warning',
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) await buildWorker()
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {
|
|
2
|
+
callService,
|
|
3
|
+
deployWorker,
|
|
4
|
+
readWorkerDirectory,
|
|
5
|
+
} from '@astrale-os/adapter-astrale/services'
|
|
6
|
+
import { parseArgs } from 'node:util'
|
|
7
|
+
|
|
8
|
+
import { buildWorker } from './build.mjs'
|
|
9
|
+
import { readSecrets, readService, serviceDeployment } from './service.mjs'
|
|
10
|
+
|
|
11
|
+
const USAGE =
|
|
12
|
+
'Usage: npm run deploy -- <environment> [--admin <bookmark>] [--as <identity>] [--no-promote]'
|
|
13
|
+
|
|
14
|
+
const { positionals, values } = parseArgs({
|
|
15
|
+
args: process.argv.slice(2).filter((argument) => argument !== '--'),
|
|
16
|
+
allowPositionals: true,
|
|
17
|
+
options: {
|
|
18
|
+
admin: { type: 'string' },
|
|
19
|
+
as: { type: 'string' },
|
|
20
|
+
'no-promote': { type: 'boolean', default: false },
|
|
21
|
+
},
|
|
22
|
+
})
|
|
23
|
+
const [environment, ...extra] = positionals
|
|
24
|
+
if (environment === undefined || extra.length > 0) throw new Error(USAGE)
|
|
25
|
+
|
|
26
|
+
const directory = process.cwd()
|
|
27
|
+
const service = await readService(directory)
|
|
28
|
+
const secrets = await readSecrets(directory, environment, service.secrets ?? [])
|
|
29
|
+
await buildWorker(directory)
|
|
30
|
+
const worker = await readWorkerDirectory('.dist', { mainModule: 'worker.mjs' })
|
|
31
|
+
const options = {
|
|
32
|
+
...(values.admin === undefined ? {} : { admin: values.admin }),
|
|
33
|
+
...(values.as === undefined ? {} : { identity: values.as }),
|
|
34
|
+
}
|
|
35
|
+
const promote = !values['no-promote']
|
|
36
|
+
// One immutable deployment: the same code and configuration reuse their deployment, and the
|
|
37
|
+
// Service's URL serves it once it is active unless --no-promote.
|
|
38
|
+
const {
|
|
39
|
+
state,
|
|
40
|
+
secrets: bound,
|
|
41
|
+
deployment,
|
|
42
|
+
} = await deployWorker(
|
|
43
|
+
serviceDeployment({ service, environment, worker, secrets, promote }),
|
|
44
|
+
options,
|
|
45
|
+
)
|
|
46
|
+
// The Service's crons are service.json's, fired on the deployment its URL serves.
|
|
47
|
+
const crons =
|
|
48
|
+
promote && service.crons !== undefined
|
|
49
|
+
? await callService(deployment.serviceId, 'setSchedule', { crons: service.crons }, options)
|
|
50
|
+
: undefined
|
|
51
|
+
if (crons?.kind === 'rejected') process.exitCode = 1
|
|
52
|
+
console.log(
|
|
53
|
+
JSON.stringify(
|
|
54
|
+
{
|
|
55
|
+
state,
|
|
56
|
+
secrets: bound.state,
|
|
57
|
+
serviceId: deployment.serviceId,
|
|
58
|
+
deployment: { id: deployment.id, label: deployment.label, url: deployment.url },
|
|
59
|
+
current: deployment.current,
|
|
60
|
+
...(crons === undefined ? {} : { schedules: crons }),
|
|
61
|
+
},
|
|
62
|
+
null,
|
|
63
|
+
2,
|
|
64
|
+
),
|
|
65
|
+
)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { workerConfiguration } from '@astrale-os/sdk/deployment/worker'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `service.json`: what every deployment of this Service freezes beside its code. Its project and
|
|
7
|
+
* an environment name the Service; a changed variable, flag or secret name is a new deployment.
|
|
8
|
+
*/
|
|
9
|
+
export async function readService(directory) {
|
|
10
|
+
const service = JSON.parse(await readFile(join(directory, 'service.json'), 'utf8'))
|
|
11
|
+
for (const member of ['project', 'compatibilityDate']) {
|
|
12
|
+
if (typeof service[member] !== 'string') throw new TypeError(`service.json needs ${member}.`)
|
|
13
|
+
}
|
|
14
|
+
return service
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The value of each secret `service.json` declares, for one environment: from
|
|
19
|
+
* `.env.<environment>` when it exists, else from the process environment. A missing one refuses
|
|
20
|
+
* the deploy before anything is called; values are never printed.
|
|
21
|
+
*/
|
|
22
|
+
export async function readSecrets(directory, environment, declared, variables = process.env) {
|
|
23
|
+
const file = await readFile(join(directory, `.env.${environment}`), 'utf8').catch((error) => {
|
|
24
|
+
if (error.code === 'ENOENT') return undefined
|
|
25
|
+
throw error
|
|
26
|
+
})
|
|
27
|
+
const values = file === undefined ? variables : parseEnv(file)
|
|
28
|
+
const missing = declared.filter((name) => typeof values[name] !== 'string' || values[name] === '')
|
|
29
|
+
if (missing.length > 0) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`Set ${missing.join(', ')} in .env.${environment} or in the environment before deploying.`,
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
return Object.fromEntries(declared.map((name) => [name, values[name]]))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The deployment of one built Worker in one environment, as `deployWorker`
|
|
39
|
+
* (`@astrale-os/adapter-astrale/services`) sends it: no record, no signing identity, no router,
|
|
40
|
+
* the readiness paths `service.json` names.
|
|
41
|
+
*/
|
|
42
|
+
export function serviceDeployment({ service, environment, worker, secrets, promote = true }) {
|
|
43
|
+
return {
|
|
44
|
+
project: service.project,
|
|
45
|
+
environment,
|
|
46
|
+
worker,
|
|
47
|
+
configuration: workerConfiguration({
|
|
48
|
+
compatibilityDate: service.compatibilityDate,
|
|
49
|
+
compatibilityFlags: service.compatibilityFlags ?? [],
|
|
50
|
+
vars: service.vars ?? {},
|
|
51
|
+
secrets: Object.keys(secrets),
|
|
52
|
+
}),
|
|
53
|
+
secrets,
|
|
54
|
+
...(service.readiness === undefined ? {} : { readiness: service.readiness }),
|
|
55
|
+
promote,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `NAME=value` lines; blank lines and `#` comments are skipped, a value is taken as written. */
|
|
60
|
+
function parseEnv(text) {
|
|
61
|
+
const values = {}
|
|
62
|
+
for (const line of text.split(/\r?\n/u)) {
|
|
63
|
+
if (line.trim() === '' || line.trimStart().startsWith('#')) continue
|
|
64
|
+
const separator = line.indexOf('=')
|
|
65
|
+
if (separator <= 0) throw new Error('A .env line is not NAME=value.')
|
|
66
|
+
values[line.slice(0, separator).trim()] = line.slice(separator + 1)
|
|
67
|
+
}
|
|
68
|
+
return values
|
|
69
|
+
}
|