@di-framework/tenant-cli 0.0.0-stage → 6.0.10
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 +143 -2
- package/api/v1/openapi.yaml +1 -0
- package/dist/cli.d.ts +22 -0
- package/dist/cli.js +419 -0
- package/dist/client/index.d.ts +135 -0
- package/dist/client/index.js +131 -0
- package/dist/client/schema.d.ts +1213 -0
- package/dist/client/sse.d.ts +12 -0
- package/dist/client/sse.js +61 -0
- package/dist/credentials.d.ts +42 -0
- package/dist/credentials.js +65 -0
- package/dist/init.d.ts +17 -0
- package/dist/init.js +41 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +25 -0
- package/dist/oidc.d.ts +54 -0
- package/dist/oidc.js +110 -0
- package/package.json +46 -4
package/README.md
CHANGED
|
@@ -1,3 +1,144 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @di-framework/tenant-cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Pilot of the tenant CLI that never speaks Kubernetes. It holds one credential per account, an
|
|
4
|
+
identity-server token or a tenant API key, and every action is a call to the tenant controller's
|
|
5
|
+
OpenAPI contract. Kubeconfigs exist only inside the controller. The cut-over criterion and the
|
|
6
|
+
command set are platform issue #40; the prototype it starts from is `../tenant-auth`.
|
|
7
|
+
|
|
8
|
+
## The contract is the shared artifact
|
|
9
|
+
|
|
10
|
+
`api/v1/openapi.yaml` is committed and generated, never edited:
|
|
11
|
+
|
|
12
|
+
```text
|
|
13
|
+
src/api/contracts/*-v1.codegen.ts manifests (operations, paths, schemas)
|
|
14
|
+
src/api/schemas.ts JSON schemas published under components.schemas
|
|
15
|
+
src/api/handlers.ts the server side; every operation answers 501 in the pilot
|
|
16
|
+
src/api/generated/** @di-framework/codegen output: typed routes and validators
|
|
17
|
+
api/v1/openapi.yaml emitted from the generated @Endpoint metadata
|
|
18
|
+
src/client/schema.d.ts openapi-typescript output
|
|
19
|
+
src/client/index.ts the typed client; its paths are checked against `paths`
|
|
20
|
+
src/client/sse.ts the one hand-written transport: text/event-stream framing
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
bun run generate # di-framework generate, then the document, then the client types
|
|
25
|
+
bun test # includes a drift check: the committed document must match the manifests
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
An operation added to a manifest shows up in the document and in `paths`; a path the client
|
|
29
|
+
references that is no longer in the contract fails `tsc`. That is the whole drift story.
|
|
30
|
+
|
|
31
|
+
Streaming operations (`logs`) are SSE: each `log` event's `data` is one `LogEvent` as JSON, and
|
|
32
|
+
`end` closes the stream. The spec names this in the operation description; the framing itself is
|
|
33
|
+
`src/client/sse.ts`.
|
|
34
|
+
|
|
35
|
+
## Using the client
|
|
36
|
+
|
|
37
|
+
`@di-framework/tenant-cli/client` is the published entry point: the typed `/v1` client
|
|
38
|
+
(`createClient`, `ControllerError`, `events`, and the request/response types). It ships compiled
|
|
39
|
+
(`dist`, with `.d.ts`), so consumers need no `.ts` import support. The package also installs the
|
|
40
|
+
`di-tenant` command, which requires Bun (its shebang is `bun`).
|
|
41
|
+
|
|
42
|
+
The `./src/api/*` export is workspace-internal: `../tenant-auth` uses it inside this repository, and it
|
|
43
|
+
is not usable from the npm package (`src` is not published).
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { createClient } from '@di-framework/tenant-cli/client';
|
|
47
|
+
|
|
48
|
+
const info = await createClient({ baseUrl: 'https://controller.example' }).authInfo();
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`api/v1/openapi.yaml` ships alongside as the contract. `bun run build` produces `dist`.
|
|
52
|
+
|
|
53
|
+
## Registry
|
|
54
|
+
|
|
55
|
+
Each tenant gets its own OCI registry component in its namespace (platform#83, decision in
|
|
56
|
+
platform#52). Nothing is minted: you log in to the registry with your own identity. Use your
|
|
57
|
+
identity-server access token or a `dik_` API key as the Basic password (the username is ignored).
|
|
58
|
+
The registry asks the tenant controller's `GET /v1/auth/whoami` who the caller is: a developer may
|
|
59
|
+
push and pull, a viewer may only pull.
|
|
60
|
+
|
|
61
|
+
`GET /v1/deploy/registry` (viewer or developer) returns `RegistryInfo`: the registry `url` (an
|
|
62
|
+
origin: scheme + host[:port], no path), an optional `repositoryPrefix`, `auth` (`basic-identity`)
|
|
63
|
+
and a fixed `username` hint (`token`). The tenant controller reads the origin from
|
|
64
|
+
`TENANT_CONTROLLER_REGISTRY_URL` (`{tenant}` is replaced by the tenant), which the platform sets
|
|
65
|
+
when its `tenantAuth.registry` config is present; otherwise the endpoint answers 503
|
|
66
|
+
`no registry is configured for this tenant`. While a registry is configured but not served (a
|
|
67
|
+
host conflict or a suspended tenant) it answers 503 `the tenant registry is not available; see
|
|
68
|
+
the Tenant's TenantAuthReady condition`. The origin must be `https://`; plain `http://` is
|
|
69
|
+
accepted only for loopback (`localhost`, `127.0.0.0/8`, `[::1]`) and in-cluster hosts (`*.svc`,
|
|
70
|
+
`*.svc.cluster.local`).
|
|
71
|
+
|
|
72
|
+
Log in against the host of `url`, not the full URL, then push to
|
|
73
|
+
`<host>/<repositoryPrefix>/<name>:<tag>`:
|
|
74
|
+
|
|
75
|
+
```text
|
|
76
|
+
oras login <host>
|
|
77
|
+
docker login <host>
|
|
78
|
+
oras push <host>/<repositoryPrefix>/<name>:<tag> ...
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
An `http:` origin means plain HTTP (loopback or in-cluster only): pass `oras --plain-http` or
|
|
82
|
+
`wash --insecure`. The platform serves the registry over HTTPS through the gateway, since the Basic
|
|
83
|
+
password is an identity credential. Its certificate is the tenant controller's, so trust the
|
|
84
|
+
tenant's CA (ConfigMap `tenant-controller-ca`) as you do for the controller, for example
|
|
85
|
+
`oras login --ca-file` or `/etc/docker/certs.d/<host>/ca.crt`.
|
|
86
|
+
|
|
87
|
+
Identity access tokens expire, so a stored `docker login` stops working; a `dik_` API key suits CI.
|
|
88
|
+
|
|
89
|
+
### Which reference a workload uses
|
|
90
|
+
|
|
91
|
+
Push to the registry's public origin (`url` above), but reference the same repository on the
|
|
92
|
+
in-cluster pull host in the workload you deploy: tenant hosts pull from
|
|
93
|
+
`tenant-registry.di-runtime-<tenant>.svc` (no port), with a platform-managed pull credential, not
|
|
94
|
+
from the public origin, which is a gateway or port-forward address the hosts cannot use. Both
|
|
95
|
+
names serve the same repositories:
|
|
96
|
+
|
|
97
|
+
```text
|
|
98
|
+
oras push registry.acme.localhost:28180/web:1.0.0 ... # push (your credential)
|
|
99
|
+
# workload component image:
|
|
100
|
+
tenant-registry.di-runtime-acme.svc/web@sha256:<digest> # pull (the tenant's hosts)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`/v1/deploy` and `/v1/deploy/preview` refuse (`422`) a bundle whose component or service image
|
|
104
|
+
names the public origin, and the problem `detail` gives the in-cluster reference to use instead.
|
|
105
|
+
|
|
106
|
+
## Commands
|
|
107
|
+
|
|
108
|
+
```text
|
|
109
|
+
login --controller <url> [--account <tenant>] [--api-key <key>]
|
|
110
|
+
logout | whoami
|
|
111
|
+
deploy preview|apply --env <prod|staging> --bundle <file>
|
|
112
|
+
logs --service <name> --env <env> [--deployment <id>] [--follow] [--since <dur>] [--tail <n>]
|
|
113
|
+
services create <keyvalue|messaging|blobstore|postgres|egress> --name <name> --env <env> [--class] [--storage] [--memory] [--cpu] [--deletion-policy] [--destination]
|
|
114
|
+
deployments list|stats --env <env> [--service <name>]
|
|
115
|
+
deployments rollback --service <name> --env <env> [--to <id>]
|
|
116
|
+
secrets|vars list|set|update|unset <name> --env <env> [--from-file <path|->]
|
|
117
|
+
proxy --service <name> --env <env> [--port <n>]
|
|
118
|
+
init [name] [--dir <path>] [--name <name>] [--force]
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Shared flags: `--env`, `--json`, `--account` (or `DI_TENANT_ACCOUNT`, or the only login on disk).
|
|
122
|
+
There is no token argument. Secret and var values come from `--from-file` or stdin (`-`), so they
|
|
123
|
+
stay out of shell history. `init` writes a minimal di-framework seed and creates no platform
|
|
124
|
+
resources.
|
|
125
|
+
|
|
126
|
+
## Login
|
|
127
|
+
|
|
128
|
+
`login --controller <url>` asks the controller's one public operation (`GET /v1/auth/info`) for
|
|
129
|
+
the account, the identity issuer, and the public client id, then runs the RFC 8252 flow: a
|
|
130
|
+
loopback listener on a free port is the redirect URI, the browser signs in at the issuer, and the
|
|
131
|
+
code comes back with the PKCE verifier and `client_id` only, no secret. The identity server must
|
|
132
|
+
register that client (`AUTH_CLI_CLIENT_ID`; loopback redirects match on any port). The tokens are
|
|
133
|
+
stored under `$DI_FRAMEWORK_HOME/credentials.json` with the issuer and client id; a token within a
|
|
134
|
+
minute of expiry is refreshed before the next command, and `logout` revokes the refresh token at
|
|
135
|
+
the issuer and tells the controller. `--api-key` skips the browser; `--no-browser` prints the URL
|
|
136
|
+
instead of opening it.
|
|
137
|
+
|
|
138
|
+
## What the pilot does not do yet
|
|
139
|
+
|
|
140
|
+
- Building the component and rendering the bundle: `deploy` sends a bundle file. The CLI-side
|
|
141
|
+
build that produces it is shared with `cli-plugin-platform` and is the next step.
|
|
142
|
+
- The controller: `../tenant-auth` serves `services create` and `services proxy`; operations it
|
|
143
|
+
does not implement yet answer 501. The controller grows the endpoints behind this contract, importing the generated routes from `src/api/generated`.
|
|
144
|
+
- The `proxy` session URL is an HTTP passthrough the controller serves outside the document (`/v1/services/<service>/proxy/<session>/...`); WebSocket is not supported yet. The session URL takes its origin from the Host the caller reached (the controller has no configured public URL); the passthrough uses a 60 s idle timeout, keeps relative `Location` redirects under the session URL, and marks every request as external with `X-Forwarded-*` so workload control paths stay closed. It sends `Host: <service>-<env>` (the session's env), the host the deploy lane sets as `config.host` on the bundle's `wasi:http` host interface; a bundle must leave `config.host` out or set it to that value.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{openapi: 3.1.0,info: {title: Tenant Controller API,version: v1,description: The only surface a tenant CLI talks to. Every call carries an identity-server credential; the controller holds the kubeconfig and never exposes Kubernetes.},paths: {/v1/auth/info: {get: {summary: Describe how to log in,description: The account this controller serves and the identity server to obtain a credential from. The only unauthenticated operation.,operationId: authInfo,responses: {"200": {description: Describe how to log in,content: {application/json: {schema: {type: object,description: "What a CLI needs before it holds a credential: the account this controller serves and the identity server to log in with.",properties: {account: {type: string},issuer: {type: string,format: uri},clientId: {type: string}},required: [account,issuer,clientId]}}}}}}},/v1/auth/logout: {post: {summary: Revoke the credential,description: Revokes the bearer token at the identity server (or the API key) so it stops working everywhere.,operationId: logout,requestBody: {content: {application/json: {schema: {type: object}}},required: false},responses: {"204": {description: Revoke the credential}},security: [{identity: []}]}},/v1/auth/whoami: {get: {summary: Resolve the caller,description: "The user, role, and credential the bearer token stands for in this account.",operationId: whoami,responses: {"200": {description: Resolve the caller,content: {application/json: {schema: {type: object,properties: {user: {type: string},account: {type: string},role: {type: string,enum: [developer,viewer]},via: {type: string,enum: [identity,api-key]},credentialId: {type: string}},required: [user,account,role,via]}}}}},security: [{identity: []}]}},/v1/deploy: {post: {summary: Apply a deployment,description: Validates the bundle and starts the rollout. Progress is streamed by the logs operation of the service.,operationId: deploy,requestBody: {content: {application/json: {schema: {type: object,description: Everything the CLI renders client-side. The controller validates it at the boundary and never receives a kubeconfig or a namespace.,properties: {env: {type: string,enum: [prod,staging]},service: {type: string,description: Service the deployment belongs to.},component: {$ref: "#/components/schemas/ComponentReference"},workload: {type: object,description: "The rendered WorkloadDeployment template, without namespace or host selector.",additionalProperties: true},bindings: {type: array,items: {$ref: "#/components/schemas/Binding"}},secrets: {type: array,description: Names of tenant secrets the workload references; values never travel.,items: {type: string}}},required: [env,service,component,workload,bindings,secrets]}}},required: true},responses: {"202": {description: Apply a deployment,content: {application/json: {schema: {type: object,properties: {id: {type: string},service: {type: string},env: {type: string,enum: [prod,staging]},status: {type: string,enum: [pending,rolling,ready,failed,rolled-back]},component: {$ref: "#/components/schemas/ComponentReference"},createdAt: {type: string,format: date-time},readyAt: {type: string,format: date-time}},required: [id,service,env,status,component,createdAt]}}}}},security: [{identity: []}]}},/v1/deploy/preview: {post: {summary: Preview a deployment,description: "Validates the bundle and reports what applying it would change in the given environment, without changing anything.",operationId: previewDeploy,requestBody: {content: {application/json: {schema: {type: object,description: Everything the CLI renders client-side. The controller validates it at the boundary and never receives a kubeconfig or a namespace.,properties: {env: {type: string,enum: [prod,staging]},service: {type: string,description: Service the deployment belongs to.},component: {$ref: "#/components/schemas/ComponentReference"},workload: {type: object,description: "The rendered WorkloadDeployment template, without namespace or host selector.",additionalProperties: true},bindings: {type: array,items: {$ref: "#/components/schemas/Binding"}},secrets: {type: array,description: Names of tenant secrets the workload references; values never travel.,items: {type: string}}},required: [env,service,component,workload,bindings,secrets]}}},required: true},responses: {"200": {description: Preview a deployment,content: {application/json: {schema: {type: object,description: What applying the bundle would change.,properties: {env: {type: string,enum: [prod,staging]},service: {type: string},changes: {type: array,items: {$ref: "#/components/schemas/DeployChange"}}},required: [env,service,changes]}}}}},security: [{identity: []}]}},/v1/deploy/registry: {get: {summary: Get the tenant registry,description: "The tenant's own OCI registry: its URL and how to log in. Pull is open to viewers and developers; push needs developer. No credential is minted.",operationId: registry,responses: {"200": {description: Get the tenant registry,content: {application/json: {schema: {type: object,description: "Where a tenant pushes and pulls images: the tenant's own OCI registry. Log in with an identity-server access token or API key as the Basic password.",properties: {url: {type: string,format: uri,description: "Origin of the tenant's OCI registry: scheme + host[:port], no path. Clients use its host[:port] for login and in image references; an http: origin means plain HTTP."},repositoryPrefix: {type: string,description: "Namespace inside the tenant registry, when there is one."},auth: {type: string,enum: [basic-identity],description: Basic auth whose password is an identity-server access token or dik_ API key.},username: {type: string,description: A fixed hint for the Basic username; the registry ignores it.}},required: [url,auth,username]}}}}},security: [{identity: []}]}},/v1/deployments: {get: {summary: List deployments,description: "Deployments of the environment, newest first, optionally for one service.",operationId: deployments,responses: {"200": {description: List deployments,content: {application/json: {schema: {type: object,properties: {items: {type: array,items: {$ref: "#/components/schemas/Deployment"}}},required: [items]}}}}},parameters: [{name: env,in: query,required: true,schema: {type: string,enum: [prod,staging]}},{name: service,in: query,required: false,schema: {type: string}}],security: [{identity: []}]}},/v1/deployments/stats: {get: {summary: Summarize deployments,operationId: deploymentStats,responses: {"200": {description: Summarize deployments,content: {application/json: {schema: {type: object,properties: {env: {type: string,enum: [prod,staging]},services: {type: integer,format: int32},deployments: {type: integer,format: int32},ready: {type: integer,format: int32},failed: {type: integer,format: int32},lastDeployedAt: {type: string,format: date-time}},required: [env,services,deployments,ready,failed]}}}}},parameters: [{name: env,in: query,required: true,schema: {type: string,enum: [prod,staging]}}],security: [{identity: []}]}},/v1/deployments/rollback: {post: {summary: Roll a service back,description: "Starts a rollout of an earlier deployment, the previous one unless `to` names it.",operationId: rollback,requestBody: {content: {application/json: {schema: {type: object,properties: {env: {type: string,enum: [prod,staging]},service: {type: string},to: {type: string,description: Deployment id to roll back to; the previous one when absent.}},required: [env,service]}}},required: true},responses: {"202": {description: Roll a service back,content: {application/json: {schema: {type: object,properties: {id: {type: string},service: {type: string},env: {type: string,enum: [prod,staging]},status: {type: string,enum: [pending,rolling,ready,failed,rolled-back]},component: {$ref: "#/components/schemas/ComponentReference"},createdAt: {type: string,format: date-time},readyAt: {type: string,format: date-time}},required: [id,service,env,status,component,createdAt]}}}}},security: [{identity: []}]}},/v1/secrets: {get: {summary: List secrets,description: Names and update times only; secret values are never returned.,operationId: secrets,responses: {"200": {description: List secrets,content: {application/json: {schema: {type: object,properties: {env: {type: string,enum: [prod,staging]},items: {type: array,items: {$ref: "#/components/schemas/SecretEntry"}}},required: [env,items]}}}}},parameters: [{name: env,in: query,required: true,schema: {type: string,enum: [prod,staging]}}],security: [{identity: []}]}},"/v1/secrets/{name}": {put: {summary: Set a secret,description: Creates or replaces the secret. The CLI reads the value from a file or stdin so it stays out of shell history.,operationId: setSecret,requestBody: {content: {application/json: {schema: {type: object,properties: {value: {type: string}},required: [value]}}},required: true},responses: {"204": {description: Set a secret}},parameters: [{name: name,in: path,required: true,schema: {type: string}},{name: env,in: query,required: true,schema: {type: string,enum: [prod,staging]}}],security: [{identity: []}]},delete: {summary: Unset a secret,operationId: unsetSecret,responses: {"204": {description: Unset a secret}},parameters: [{name: name,in: path,required: true,schema: {type: string}},{name: env,in: query,required: true,schema: {type: string,enum: [prod,staging]}}],security: [{identity: []}]},patch: {summary: Update a secret,description: Replaces the value of an existing secret; 404 when it does not exist.,operationId: updateSecret,requestBody: {content: {application/json: {schema: {type: object,properties: {value: {type: string}},required: [value]}}},required: true},responses: {"204": {description: Update a secret}},parameters: [{name: name,in: path,required: true,schema: {type: string}},{name: env,in: query,required: true,schema: {type: string,enum: [prod,staging]}}],security: [{identity: []}]}},/v1/services: {post: {summary: Create a backing service,description: "Creates a keyvalue, messaging, blobstore, postgres or egress backing service in the environment as the caller. http, cron and worker services come from the deploy bundle.",operationId: createService,requestBody: {content: {application/json: {schema: {type: object,description: "A backing service (BackingService) in the environment, shaped like `di-framework platform service create`. `destinations` is required for egress and only allowed there. http, cron and worker services come from the deploy bundle, not from this operation.",properties: {env: {type: string,enum: [prod,staging]},type: {type: string,enum: [keyvalue,messaging,blobstore,postgres,egress]},name: {type: string,description: "DNS label, at most 40 characters: `^[a-z][a-z0-9]*(-[a-z0-9]+)*$`."},className: {type: string,description: "The platform default class for the type when absent (keyvalue-redis, messaging-nats, blobstore-nats, postgres-dedicated, egress-public); any other class is refused."},parameters: {type: object,description: "Sizing quantities such as `1Gi` or `500m`.",properties: {storage: {type: string},memory: {type: string},cpu: {type: string}},additionalProperties: false},deletionPolicy: {type: string,enum: [Retain,Delete]},destinations: {type: array,items: {type: string},description: "Egress only: hosts the workload may reach (`host`, `*.suffix`, optionally `:port`)."}},required: [env,type,name]}}},required: true},responses: {"201": {description: Create a backing service,content: {application/json: {schema: {type: object,properties: {name: {type: string},env: {type: string,enum: [prod,staging]},type: {type: string,enum: [keyvalue,messaging,blobstore,postgres,egress]},className: {type: string},parameters: {type: object,properties: {storage: {type: string},memory: {type: string},cpu: {type: string}}},deletionPolicy: {type: string,enum: [Retain,Delete]},destinations: {type: array,items: {type: string}},createdAt: {type: string,format: date-time}},required: [name,env,type,className,createdAt]}}}}},security: [{identity: []}]}},"/v1/services/{service}/logs": {get: {summary: Read or follow service logs,description: "Server-sent events (`text/event-stream`). Each `log` event carries one LogEvent as JSON; an `end` event closes the stream. With `follow` the stream stays open until the client disconnects.",operationId: logs,responses: {"200": {description: Read or follow service logs,content: {application/json: {schema: {type: object,description: "One line of a service log. Streamed as `text/event-stream`: each SSE `data:` field is one LogEvent as JSON; the `event:` field is `log`, or `end` when the stream closes.",properties: {timestamp: {type: string,format: date-time},deployment: {type: string},instance: {type: string},level: {type: string,enum: [debug,info,warn,error]},message: {type: string}},required: [timestamp,deployment,message]}}}}},parameters: [{name: service,in: path,required: true,schema: {type: string}},{name: env,in: query,required: true,schema: {type: string,enum: [prod,staging]}},{name: deployment,in: query,required: false,schema: {type: string}},{name: follow,in: query,required: false,schema: {type: boolean,default: false}},{name: since,in: query,required: false,schema: {type: string,description: "Duration such as 10m or 1h, or an RFC 3339 timestamp."}},{name: tail,in: query,required: false,schema: {type: integer,format: int32,default: 100}}],security: [{identity: []}]}},"/v1/services/{service}/proxy": {post: {summary: Open an HTTP session to a service,description: "Issues a short-lived HTTP session to the service, bound to the caller. The session URL takes the same bearer.",operationId: proxy,requestBody: {content: {application/json: {schema: {type: object,properties: {env: {type: string,enum: [prod,staging]},port: {type: integer,format: int32,description: "Service port; only 80, the tenant HTTP upstream, is supported (the default)."}},required: [env]}}},required: true},responses: {"201": {description: Open an HTTP session to a service,content: {application/json: {schema: {type: object,description: "A short-lived HTTP session to one service, bound to the caller. Requests to `url` and paths below it carry the same bearer and are forwarded to the service until `expiresAt`. The controller serves the session URL outside this document; WebSocket is not supported yet.",properties: {url: {type: string,format: uri},port: {type: integer,format: int32},expiresAt: {type: string,format: date-time}},required: [url,port,expiresAt]}}}}},parameters: [{name: service,in: path,required: true,schema: {type: string}}],security: [{identity: []}]}},"/v1/vars/{name}": {put: {summary: Set a var,description: Creates or replaces the var. The CLI reads the value from a file or stdin so it stays out of shell history.,operationId: setVar,requestBody: {content: {application/json: {schema: {type: object,properties: {value: {type: string}},required: [value]}}},required: true},responses: {"204": {description: Set a var}},parameters: [{name: name,in: path,required: true,schema: {type: string}},{name: env,in: query,required: true,schema: {type: string,enum: [prod,staging]}}],security: [{identity: []}]},delete: {summary: Unset a var,operationId: unsetVar,responses: {"204": {description: Unset a var}},parameters: [{name: name,in: path,required: true,schema: {type: string}},{name: env,in: query,required: true,schema: {type: string,enum: [prod,staging]}}],security: [{identity: []}]},patch: {summary: Update a var,description: Replaces the value of an existing var; 404 when it does not exist.,operationId: updateVar,requestBody: {content: {application/json: {schema: {type: object,properties: {value: {type: string}},required: [value]}}},required: true},responses: {"204": {description: Update a var}},parameters: [{name: name,in: path,required: true,schema: {type: string}},{name: env,in: query,required: true,schema: {type: string,enum: [prod,staging]}}],security: [{identity: []}]}},/v1/vars: {get: {summary: List vars,description: "Names, values, and update times of the environment variables.",operationId: vars,responses: {"200": {description: List vars,content: {application/json: {schema: {type: object,properties: {env: {type: string,enum: [prod,staging]},items: {type: array,items: {$ref: "#/components/schemas/ConfigEntry"}}},required: [env,items]}}}}},parameters: [{name: env,in: query,required: true,schema: {type: string,enum: [prod,staging]}}],security: [{identity: []}]}}},components: {schemas: {Empty: {type: object},Problem: {type: object,description: RFC 9457 problem details; every error response uses it.,properties: {type: {type: string},title: {type: string},status: {type: integer,format: int32},detail: {type: string}},required: [title,status]},AuthInfo: {type: object,description: "What a CLI needs before it holds a credential: the account this controller serves and the identity server to log in with.",properties: {account: {type: string},issuer: {type: string,format: uri},clientId: {type: string}},required: [account,issuer,clientId]},Principal: {type: object,properties: {user: {type: string},account: {type: string},role: {type: string,enum: [developer,viewer]},via: {type: string,enum: [identity,api-key]},credentialId: {type: string}},required: [user,account,role,via]},ComponentReference: {type: object,description: The built component the controller pulls from the platform registry.,properties: {reference: {type: string,description: OCI reference the component was pushed as.},digest: {type: string,description: sha256 digest of the pushed artifact.}},required: [reference,digest]},Binding: {type: object,description: "One `@WasmCloudBinding` of the component, rendered by the CLI.",properties: {name: {type: string},capability: {type: string},serviceName: {type: string,description: Backing service the binding attaches to.},config: {type: object,additionalProperties: {type: string}}},required: [name,capability]},DeployBundle: {type: object,description: Everything the CLI renders client-side. The controller validates it at the boundary and never receives a kubeconfig or a namespace.,properties: {env: {type: string,enum: [prod,staging]},service: {type: string,description: Service the deployment belongs to.},component: {$ref: "#/components/schemas/ComponentReference"},workload: {type: object,description: "The rendered WorkloadDeployment template, without namespace or host selector.",additionalProperties: true},bindings: {type: array,items: {$ref: "#/components/schemas/Binding"}},secrets: {type: array,description: Names of tenant secrets the workload references; values never travel.,items: {type: string}}},required: [env,service,component,workload,bindings,secrets]},RegistryInfo: {type: object,description: "Where a tenant pushes and pulls images: the tenant's own OCI registry. Log in with an identity-server access token or API key as the Basic password.",properties: {url: {type: string,format: uri,description: "Origin of the tenant's OCI registry: scheme + host[:port], no path. Clients use its host[:port] for login and in image references; an http: origin means plain HTTP."},repositoryPrefix: {type: string,description: "Namespace inside the tenant registry, when there is one."},auth: {type: string,enum: [basic-identity],description: Basic auth whose password is an identity-server access token or dik_ API key.},username: {type: string,description: A fixed hint for the Basic username; the registry ignores it.}},required: [url,auth,username]},DeployChange: {type: object,properties: {kind: {type: string,enum: [create,update,unchanged,delete]},resource: {type: string},name: {type: string},detail: {type: string}},required: [kind,resource,name]},DeployPlan: {type: object,description: What applying the bundle would change.,properties: {env: {type: string,enum: [prod,staging]},service: {type: string},changes: {type: array,items: {$ref: "#/components/schemas/DeployChange"}}},required: [env,service,changes]},Deployment: {type: object,properties: {id: {type: string},service: {type: string},env: {type: string,enum: [prod,staging]},status: {type: string,enum: [pending,rolling,ready,failed,rolled-back]},component: {$ref: "#/components/schemas/ComponentReference"},createdAt: {type: string,format: date-time},readyAt: {type: string,format: date-time}},required: [id,service,env,status,component,createdAt]},DeploymentList: {type: object,properties: {items: {type: array,items: {$ref: "#/components/schemas/Deployment"}}},required: [items]},DeploymentStats: {type: object,properties: {env: {type: string,enum: [prod,staging]},services: {type: integer,format: int32},deployments: {type: integer,format: int32},ready: {type: integer,format: int32},failed: {type: integer,format: int32},lastDeployedAt: {type: string,format: date-time}},required: [env,services,deployments,ready,failed]},RollbackRequest: {type: object,properties: {env: {type: string,enum: [prod,staging]},service: {type: string},to: {type: string,description: Deployment id to roll back to; the previous one when absent.}},required: [env,service]},CreateServiceRequest: {type: object,description: "A backing service (BackingService) in the environment, shaped like `di-framework platform service create`. `destinations` is required for egress and only allowed there. http, cron and worker services come from the deploy bundle, not from this operation.",properties: {env: {type: string,enum: [prod,staging]},type: {type: string,enum: [keyvalue,messaging,blobstore,postgres,egress]},name: {type: string,description: "DNS label, at most 40 characters: `^[a-z][a-z0-9]*(-[a-z0-9]+)*$`."},className: {type: string,description: "The platform default class for the type when absent (keyvalue-redis, messaging-nats, blobstore-nats, postgres-dedicated, egress-public); any other class is refused."},parameters: {type: object,description: "Sizing quantities such as `1Gi` or `500m`.",properties: {storage: {type: string},memory: {type: string},cpu: {type: string}},additionalProperties: false},deletionPolicy: {type: string,enum: [Retain,Delete]},destinations: {type: array,items: {type: string},description: "Egress only: hosts the workload may reach (`host`, `*.suffix`, optionally `:port`)."}},required: [env,type,name]},Service: {type: object,properties: {name: {type: string},env: {type: string,enum: [prod,staging]},type: {type: string,enum: [keyvalue,messaging,blobstore,postgres,egress]},className: {type: string},parameters: {type: object,properties: {storage: {type: string},memory: {type: string},cpu: {type: string}}},deletionPolicy: {type: string,enum: [Retain,Delete]},destinations: {type: array,items: {type: string}},createdAt: {type: string,format: date-time}},required: [name,env,type,className,createdAt]},LogEvent: {type: object,description: "One line of a service log. Streamed as `text/event-stream`: each SSE `data:` field is one LogEvent as JSON; the `event:` field is `log`, or `end` when the stream closes.",properties: {timestamp: {type: string,format: date-time},deployment: {type: string},instance: {type: string},level: {type: string,enum: [debug,info,warn,error]},message: {type: string}},required: [timestamp,deployment,message]},ConfigEntry: {type: object,description: A secret lists its name only; a var also carries its value.,properties: {name: {type: string},value: {type: string},updatedAt: {type: string,format: date-time}},required: [name,updatedAt]},ConfigList: {type: object,properties: {env: {type: string,enum: [prod,staging]},items: {type: array,items: {$ref: "#/components/schemas/ConfigEntry"}}},required: [env,items]},SecretEntry: {type: object,description: A secret lists its name and update time only; its value never travels on read.,properties: {name: {type: string},updatedAt: {type: string,format: date-time}},required: [name,updatedAt],additionalProperties: false},SecretList: {type: object,properties: {env: {type: string,enum: [prod,staging]},items: {type: array,items: {$ref: "#/components/schemas/SecretEntry"}}},required: [env,items]},ConfigValue: {type: object,properties: {value: {type: string}},required: [value]},ProxyRequest: {type: object,properties: {env: {type: string,enum: [prod,staging]},port: {type: integer,format: int32,description: "Service port; only 80, the tenant HTTP upstream, is supported (the default)."}},required: [env]},ProxySession: {type: object,description: "A short-lived HTTP session to one service, bound to the caller. Requests to `url` and paths below it carry the same bearer and are forwarded to the service until `expiresAt`. The controller serves the session URL outside this document; WebSocket is not supported yet.",properties: {url: {type: string,format: uri},port: {type: integer,format: int32},expiresAt: {type: string,format: date-time}},required: [url,port,expiresAt]}},securitySchemes: {identity: {type: http,scheme: bearer,description: "An identity-server access token for a member of this account, or a tenant API key (`dik_…`). Obtained by the CLI login; never passed as an argument."}}}}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface Io {
|
|
2
|
+
stdout(line: string): void;
|
|
3
|
+
stderr(line: string): void;
|
|
4
|
+
/** Standard input, read whole; used by `--from-file -`. */
|
|
5
|
+
stdin(): string;
|
|
6
|
+
/** Opens a URL in the user's browser; `main.ts` spawns the platform opener. */
|
|
7
|
+
open(url: string): void;
|
|
8
|
+
env: NodeJS.ProcessEnv;
|
|
9
|
+
cwd: string;
|
|
10
|
+
fetch?: typeof fetch;
|
|
11
|
+
/** How long `login` waits for the browser to come back; five minutes by default. */
|
|
12
|
+
loginTimeoutMs?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface Parsed {
|
|
15
|
+
command: string;
|
|
16
|
+
flags: Record<string, string>;
|
|
17
|
+
positionals: string[];
|
|
18
|
+
}
|
|
19
|
+
/** `--flag value`, `--flag=value`, boolean flags, and positionals; `--` ends flag parsing. */
|
|
20
|
+
export declare function parseArgs(argv: string[]): Parsed;
|
|
21
|
+
export declare const USAGE = "di-tenant: the tenant CLI pilot. It talks to the tenant controller only.\n\n login --controller <url> [--account <tenant>] [--api-key <key>] [--no-browser]\n logout [--account <tenant>]\n whoami [--account <tenant>]\n deploy preview|apply --env <prod|staging> --bundle <file>\n logs --service <name> --env <env> [--deployment <id>] [--follow] [--since <dur>] [--tail <n>]\n services create <keyvalue|messaging|blobstore|postgres|egress> --name <name> --env <env> [--class <class>] [--storage <q>] [--memory <q>] [--cpu <q>] [--deletion-policy <Retain|Delete>] [--destination <host[:port],...>]\n deployments list|stats --env <env> [--service <name>]\n deployments rollback --service <name> --env <env> [--to <id>]\n secrets|vars list --env <env>\n secrets|vars set|update <name> --env <env> --from-file <path|->\n secrets|vars unset <name> --env <env>\n proxy --service <name> --env <env> [--port <n>]\n init [name] [--dir <path>] [--name <name>] [--force]\n\nShared flags: --env, --json, --account. The credential comes from the login on disk; there is no token argument.";
|
|
22
|
+
export declare function run(argv: string[], io: Io): Promise<number>;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { createClient, } from "./client/index.js";
|
|
3
|
+
import { bearer, CliError, load, needsRefresh, readStore, resolveAccount, writeStore, } from "./credentials.js";
|
|
4
|
+
import { init } from "./init.js";
|
|
5
|
+
import { authorizationUrl, discover, exchangeCode, OidcError, pkce, randomToken, refreshTokens, revokeToken, } from "./oidc.js";
|
|
6
|
+
const SCOPE = 'openid profile email offline_access';
|
|
7
|
+
const BOOLEAN_FLAGS = new Set(['json', 'follow', 'force', 'no-browser']);
|
|
8
|
+
/** `--flag value`, `--flag=value`, boolean flags, and positionals; `--` ends flag parsing. */
|
|
9
|
+
export function parseArgs(argv) {
|
|
10
|
+
const flags = {};
|
|
11
|
+
const positionals = [];
|
|
12
|
+
for (let i = 0; i < argv.length; i++) {
|
|
13
|
+
const arg = argv[i];
|
|
14
|
+
if (arg === '--') {
|
|
15
|
+
positionals.push(...argv.slice(i + 1));
|
|
16
|
+
break;
|
|
17
|
+
}
|
|
18
|
+
if (!arg.startsWith('--')) {
|
|
19
|
+
positionals.push(arg);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const equals = arg.indexOf('=');
|
|
23
|
+
const name = equals === -1 ? arg.slice(2) : arg.slice(2, equals);
|
|
24
|
+
if (equals !== -1)
|
|
25
|
+
flags[name] = arg.slice(equals + 1);
|
|
26
|
+
else if (BOOLEAN_FLAGS.has(name))
|
|
27
|
+
flags[name] = 'true';
|
|
28
|
+
else {
|
|
29
|
+
const value = argv[i + 1];
|
|
30
|
+
if (value === undefined || value.startsWith('--'))
|
|
31
|
+
throw new CliError(`--${name} needs a value`);
|
|
32
|
+
flags[name] = value;
|
|
33
|
+
i++;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const [command = 'help', ...rest] = positionals;
|
|
37
|
+
return { command, flags, positionals: rest };
|
|
38
|
+
}
|
|
39
|
+
export const USAGE = `di-tenant: the tenant CLI pilot. It talks to the tenant controller only.
|
|
40
|
+
|
|
41
|
+
login --controller <url> [--account <tenant>] [--api-key <key>] [--no-browser]
|
|
42
|
+
logout [--account <tenant>]
|
|
43
|
+
whoami [--account <tenant>]
|
|
44
|
+
deploy preview|apply --env <prod|staging> --bundle <file>
|
|
45
|
+
logs --service <name> --env <env> [--deployment <id>] [--follow] [--since <dur>] [--tail <n>]
|
|
46
|
+
services create <keyvalue|messaging|blobstore|postgres|egress> --name <name> --env <env> [--class <class>] [--storage <q>] [--memory <q>] [--cpu <q>] [--deletion-policy <Retain|Delete>] [--destination <host[:port],...>]
|
|
47
|
+
deployments list|stats --env <env> [--service <name>]
|
|
48
|
+
deployments rollback --service <name> --env <env> [--to <id>]
|
|
49
|
+
secrets|vars list --env <env>
|
|
50
|
+
secrets|vars set|update <name> --env <env> --from-file <path|->
|
|
51
|
+
secrets|vars unset <name> --env <env>
|
|
52
|
+
proxy --service <name> --env <env> [--port <n>]
|
|
53
|
+
init [name] [--dir <path>] [--name <name>] [--force]
|
|
54
|
+
|
|
55
|
+
Shared flags: --env, --json, --account. The credential comes from the login on disk; there is no token argument.`;
|
|
56
|
+
function requireFlag(flags, name) {
|
|
57
|
+
const value = flags[name];
|
|
58
|
+
if (!value)
|
|
59
|
+
throw new CliError(`--${name} is required`);
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
function requireEnv(flags) {
|
|
63
|
+
const value = requireFlag(flags, 'env');
|
|
64
|
+
if (value !== 'prod' && value !== 'staging')
|
|
65
|
+
throw new CliError('--env must be prod or staging');
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
const BACKING_TYPES = [
|
|
69
|
+
'keyvalue',
|
|
70
|
+
'messaging',
|
|
71
|
+
'blobstore',
|
|
72
|
+
'postgres',
|
|
73
|
+
'egress',
|
|
74
|
+
];
|
|
75
|
+
function integer(flags, name) {
|
|
76
|
+
const value = flags[name];
|
|
77
|
+
if (value === undefined)
|
|
78
|
+
return undefined;
|
|
79
|
+
const parsed = Number(value);
|
|
80
|
+
if (!Number.isInteger(parsed) || parsed < 0)
|
|
81
|
+
throw new CliError(`--${name} must be a whole number`);
|
|
82
|
+
return parsed;
|
|
83
|
+
}
|
|
84
|
+
/** `--from-file <path>` or `-` for stdin: values never appear on the command line. */
|
|
85
|
+
function valueFrom(flags, io) {
|
|
86
|
+
const source = requireFlag(flags, 'from-file');
|
|
87
|
+
const raw = source === '-' ? io.stdin() : readFileSync(source, 'utf8');
|
|
88
|
+
return raw.replace(/\r?\n$/, '');
|
|
89
|
+
}
|
|
90
|
+
function print(io, flags, value, line) {
|
|
91
|
+
io.stdout(flags.json === 'true' ? JSON.stringify(value, null, 2) : line(value));
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Resolves the credential on disk for the account, refreshing an identity token that is about to
|
|
95
|
+
* expire, and builds a client with its bearer.
|
|
96
|
+
*/
|
|
97
|
+
async function session(flags, io) {
|
|
98
|
+
const store = readStore(io.env);
|
|
99
|
+
const account = resolveAccount(flags.account, io.env, store);
|
|
100
|
+
let credential = load(account, io.env);
|
|
101
|
+
if (needsRefresh(credential)) {
|
|
102
|
+
credential = await refresh(credential, io);
|
|
103
|
+
store[account] = credential;
|
|
104
|
+
writeStore(store, io.env);
|
|
105
|
+
}
|
|
106
|
+
const client = createClient({
|
|
107
|
+
baseUrl: credential.controller.url,
|
|
108
|
+
token: bearer(credential),
|
|
109
|
+
fetch: io.fetch,
|
|
110
|
+
});
|
|
111
|
+
return { credential, client };
|
|
112
|
+
}
|
|
113
|
+
/** The refresh grant at the issuer, with the public client id; identity rotates the token. */
|
|
114
|
+
async function refresh(credential, io) {
|
|
115
|
+
if (!credential.issuer || !credential.clientId || !credential.refreshToken) {
|
|
116
|
+
throw new CliError('the login cannot be refreshed; run login again');
|
|
117
|
+
}
|
|
118
|
+
const metadata = await discover(credential.issuer, io.fetch);
|
|
119
|
+
let tokens;
|
|
120
|
+
try {
|
|
121
|
+
tokens = await refreshTokens(metadata, { clientId: credential.clientId, refreshToken: credential.refreshToken }, io.fetch);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
if (error instanceof OidcError) {
|
|
125
|
+
throw new CliError(`the login has expired (${error.message}); run login again`);
|
|
126
|
+
}
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
return { ...credential, ...issued(tokens, credential.refreshToken) };
|
|
130
|
+
}
|
|
131
|
+
function issued(tokens, previousRefresh) {
|
|
132
|
+
return {
|
|
133
|
+
accessToken: tokens.access_token,
|
|
134
|
+
refreshToken: tokens.refresh_token ?? previousRefresh,
|
|
135
|
+
expiresAt: new Date(Date.now() + tokens.expires_in * 1000).toISOString(),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const page = (text, status = 200) => new Response(`<!doctype html><title>di-tenant</title><p style="font:16px system-ui;padding:24px">${text}</p>`, { status, headers: { 'content-type': 'text/html' } });
|
|
139
|
+
/**
|
|
140
|
+
* RFC 8252 browser login: a loopback listener on a free port is the redirect URI, the browser
|
|
141
|
+
* signs in at the issuer, and the code comes back with the PKCE verifier, no client secret.
|
|
142
|
+
*/
|
|
143
|
+
async function browserLogin(info, flags, io) {
|
|
144
|
+
const metadata = await discover(info.issuer, io.fetch);
|
|
145
|
+
const { verifier, challenge } = await pkce();
|
|
146
|
+
const state = randomToken(16);
|
|
147
|
+
const { promise, resolve, reject } = Promise.withResolvers();
|
|
148
|
+
const server = Bun.serve({
|
|
149
|
+
hostname: '127.0.0.1',
|
|
150
|
+
port: 0,
|
|
151
|
+
fetch(request) {
|
|
152
|
+
const url = new URL(request.url);
|
|
153
|
+
if (url.pathname !== '/callback')
|
|
154
|
+
return page('Not found.', 404);
|
|
155
|
+
const refused = url.searchParams.get('error');
|
|
156
|
+
if (refused) {
|
|
157
|
+
reject(new CliError(`the identity server refused the login: ${refused}`));
|
|
158
|
+
return page('Login failed. Return to the terminal.', 400);
|
|
159
|
+
}
|
|
160
|
+
const code = url.searchParams.get('code');
|
|
161
|
+
if (!code || url.searchParams.get('state') !== state) {
|
|
162
|
+
reject(new CliError('the browser returned an unexpected login response'));
|
|
163
|
+
return page('Login failed. Return to the terminal.', 400);
|
|
164
|
+
}
|
|
165
|
+
resolve(code);
|
|
166
|
+
return page('Signed in. You can close this window and return to the terminal.');
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
const redirectUri = `http://127.0.0.1:${server.port}/callback`;
|
|
170
|
+
try {
|
|
171
|
+
const url = authorizationUrl(metadata, {
|
|
172
|
+
clientId: info.clientId,
|
|
173
|
+
redirectUri,
|
|
174
|
+
scope: SCOPE,
|
|
175
|
+
state,
|
|
176
|
+
nonce: randomToken(16),
|
|
177
|
+
codeChallenge: challenge,
|
|
178
|
+
});
|
|
179
|
+
io.stderr(`Opening your browser to sign in to ${info.account} at ${info.issuer}.\nIf it does not open, visit:\n ${url}`);
|
|
180
|
+
if (flags['no-browser'] !== 'true')
|
|
181
|
+
io.open(url);
|
|
182
|
+
const timer = setTimeout(() => reject(new CliError('timed out waiting for the browser login')), io.loginTimeoutMs ?? 300_000);
|
|
183
|
+
const code = await promise.finally(() => clearTimeout(timer));
|
|
184
|
+
return await exchangeCode(metadata, { clientId: info.clientId, redirectUri, code, codeVerifier: verifier }, io.fetch);
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
await server.stop();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
async function login(flags, io) {
|
|
191
|
+
const controller = requireFlag(flags, 'controller').replace(/\/$/, '');
|
|
192
|
+
const info = await createClient({ baseUrl: controller, fetch: io.fetch }).authInfo();
|
|
193
|
+
if (flags.account && info.account !== flags.account) {
|
|
194
|
+
throw new CliError(`${controller} serves account ${info.account}, not ${flags.account}`);
|
|
195
|
+
}
|
|
196
|
+
const apiKey = flags['api-key'];
|
|
197
|
+
const tokens = apiKey ? undefined : await browserLogin(info, flags, io);
|
|
198
|
+
const token = apiKey ?? tokens.access_token;
|
|
199
|
+
const who = await createClient({ baseUrl: controller, token, fetch: io.fetch }).whoami();
|
|
200
|
+
const base = {
|
|
201
|
+
account: info.account,
|
|
202
|
+
user: who.user,
|
|
203
|
+
role: who.role,
|
|
204
|
+
controller: { url: controller },
|
|
205
|
+
};
|
|
206
|
+
const credential = apiKey
|
|
207
|
+
? { ...base, via: 'api-key', apiKey }
|
|
208
|
+
: {
|
|
209
|
+
...base,
|
|
210
|
+
via: 'identity',
|
|
211
|
+
issuer: info.issuer,
|
|
212
|
+
clientId: info.clientId,
|
|
213
|
+
...issued(tokens),
|
|
214
|
+
};
|
|
215
|
+
const store = readStore(io.env);
|
|
216
|
+
store[info.account] = credential;
|
|
217
|
+
writeStore(store, io.env);
|
|
218
|
+
io.stderr(`Logged in to ${info.account} as ${who.user} (${who.role}) via ${credential.via}.`);
|
|
219
|
+
return 0;
|
|
220
|
+
}
|
|
221
|
+
export async function run(argv, io) {
|
|
222
|
+
try {
|
|
223
|
+
return await dispatch(parseArgs(argv), io);
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
if (error instanceof CliError) {
|
|
227
|
+
io.stderr(error.message);
|
|
228
|
+
return error.exitCode;
|
|
229
|
+
}
|
|
230
|
+
if (error instanceof OidcError) {
|
|
231
|
+
io.stderr(`identity: ${error.message}`);
|
|
232
|
+
return 1;
|
|
233
|
+
}
|
|
234
|
+
if (error.name === 'ControllerError') {
|
|
235
|
+
const failure = error;
|
|
236
|
+
io.stderr(`controller: ${failure.status} ${failure.message}`);
|
|
237
|
+
return 1;
|
|
238
|
+
}
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
async function dispatch(parsed, io) {
|
|
243
|
+
const { command, flags, positionals } = parsed;
|
|
244
|
+
const [sub, ...rest] = positionals;
|
|
245
|
+
switch (command) {
|
|
246
|
+
case 'help':
|
|
247
|
+
io.stdout(USAGE);
|
|
248
|
+
return 0;
|
|
249
|
+
case 'login':
|
|
250
|
+
return login(flags, io);
|
|
251
|
+
case 'logout': {
|
|
252
|
+
// Tell the controller first, while the token is still valid, then revoke the refresh token
|
|
253
|
+
// at the issuer, then forget the credential. A failure anywhere still forgets it.
|
|
254
|
+
const store = readStore(io.env);
|
|
255
|
+
const account = resolveAccount(flags.account, io.env, store);
|
|
256
|
+
const credential = load(account, io.env);
|
|
257
|
+
try {
|
|
258
|
+
await createClient({
|
|
259
|
+
baseUrl: credential.controller.url,
|
|
260
|
+
token: bearer(credential),
|
|
261
|
+
fetch: io.fetch,
|
|
262
|
+
}).logout();
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
// An expired or already revoked token; nothing to tell the controller.
|
|
266
|
+
}
|
|
267
|
+
if (credential.via === 'identity' && credential.refreshToken && credential.issuer) {
|
|
268
|
+
await revokeToken(await discover(credential.issuer, io.fetch), credential.clientId ?? '', credential.refreshToken, io.fetch);
|
|
269
|
+
}
|
|
270
|
+
delete store[account];
|
|
271
|
+
writeStore(store, io.env);
|
|
272
|
+
io.stderr(`Logged out of ${account}.`);
|
|
273
|
+
return 0;
|
|
274
|
+
}
|
|
275
|
+
case 'whoami': {
|
|
276
|
+
const { client } = await session(flags, io);
|
|
277
|
+
const who = await client.whoami();
|
|
278
|
+
print(io, flags, who, () => `${who.user} (${who.role}) in ${who.account} via ${who.via}`);
|
|
279
|
+
return 0;
|
|
280
|
+
}
|
|
281
|
+
case 'deploy': {
|
|
282
|
+
if (sub !== 'preview' && sub !== 'apply')
|
|
283
|
+
throw new CliError('usage: deploy preview|apply');
|
|
284
|
+
const env = requireEnv(flags);
|
|
285
|
+
const bundle = JSON.parse(readFileSync(requireFlag(flags, 'bundle'), 'utf8'));
|
|
286
|
+
bundle.env = env;
|
|
287
|
+
const { client } = await session(flags, io);
|
|
288
|
+
if (sub === 'preview') {
|
|
289
|
+
const plan = await client.previewDeploy(bundle);
|
|
290
|
+
print(io, flags, plan, () => plan.changes.length === 0
|
|
291
|
+
? `${plan.service} in ${plan.env}: no changes`
|
|
292
|
+
: plan.changes.map((c) => `${c.kind} ${c.resource}/${c.name}`).join('\n'));
|
|
293
|
+
return 0;
|
|
294
|
+
}
|
|
295
|
+
const deployment = await client.deploy(bundle);
|
|
296
|
+
print(io, flags, deployment, () => `${deployment.service} in ${deployment.env}: ${deployment.id} ${deployment.status}`);
|
|
297
|
+
return 0;
|
|
298
|
+
}
|
|
299
|
+
case 'logs': {
|
|
300
|
+
const { client } = await session(flags, io);
|
|
301
|
+
const stream = client.logs(requireFlag(flags, 'service'), {
|
|
302
|
+
env: requireEnv(flags),
|
|
303
|
+
deployment: flags.deployment,
|
|
304
|
+
follow: flags.follow === 'true',
|
|
305
|
+
since: flags.since,
|
|
306
|
+
tail: integer(flags, 'tail'),
|
|
307
|
+
});
|
|
308
|
+
for await (const event of stream) {
|
|
309
|
+
print(io, flags, event, () => `${event.timestamp} ${event.deployment} ${event.level ?? 'info'} ${event.message}`);
|
|
310
|
+
}
|
|
311
|
+
return 0;
|
|
312
|
+
}
|
|
313
|
+
case 'services': {
|
|
314
|
+
if (sub !== 'create')
|
|
315
|
+
throw new CliError('usage: services create <keyvalue|messaging|blobstore|postgres|egress> --name <name>');
|
|
316
|
+
const type = rest[0];
|
|
317
|
+
if (!BACKING_TYPES.includes(type)) {
|
|
318
|
+
throw new CliError('service type must be keyvalue, messaging, blobstore, postgres, or egress (http, cron and worker services come from deploy)');
|
|
319
|
+
}
|
|
320
|
+
const policy = flags['deletion-policy'];
|
|
321
|
+
if (policy !== undefined && policy !== 'Retain' && policy !== 'Delete')
|
|
322
|
+
throw new CliError('--deletion-policy must be Retain or Delete');
|
|
323
|
+
const sizing = { storage: flags.storage, memory: flags.memory, cpu: flags.cpu };
|
|
324
|
+
const request = {
|
|
325
|
+
env: requireEnv(flags),
|
|
326
|
+
type,
|
|
327
|
+
name: requireFlag(flags, 'name'),
|
|
328
|
+
className: flags.class,
|
|
329
|
+
parameters: Object.values(sizing).some((v) => v !== undefined) ? sizing : undefined,
|
|
330
|
+
deletionPolicy: policy,
|
|
331
|
+
destinations: flags.destination?.split(',').filter((d) => d !== ''),
|
|
332
|
+
};
|
|
333
|
+
const { client } = await session(flags, io);
|
|
334
|
+
const service = await client.createService(request);
|
|
335
|
+
print(io, flags, service, () => `created ${service.type} service ${service.name} (${service.className}) in ${service.env}`);
|
|
336
|
+
return 0;
|
|
337
|
+
}
|
|
338
|
+
case 'deployments': {
|
|
339
|
+
const env = requireEnv(flags);
|
|
340
|
+
const { client } = await session(flags, io);
|
|
341
|
+
if (sub === 'list') {
|
|
342
|
+
const list = await client.deployments({ env, service: flags.service });
|
|
343
|
+
print(io, flags, list, () => list.items.length === 0
|
|
344
|
+
? `no deployments in ${env}`
|
|
345
|
+
: list.items.map((d) => `${d.id} ${d.service} ${d.status} ${d.createdAt}`).join('\n'));
|
|
346
|
+
return 0;
|
|
347
|
+
}
|
|
348
|
+
if (sub === 'stats') {
|
|
349
|
+
const stats = await client.deploymentStats(env);
|
|
350
|
+
print(io, flags, stats, () => `${env}: ${stats.services} services, ${stats.deployments} deployments, ${stats.ready} ready, ${stats.failed} failed`);
|
|
351
|
+
return 0;
|
|
352
|
+
}
|
|
353
|
+
if (sub === 'rollback') {
|
|
354
|
+
const deployment = await client.rollback({
|
|
355
|
+
env,
|
|
356
|
+
service: requireFlag(flags, 'service'),
|
|
357
|
+
to: flags.to,
|
|
358
|
+
});
|
|
359
|
+
print(io, flags, deployment, () => `${deployment.service} in ${env}: rolling back to ${deployment.id}`);
|
|
360
|
+
return 0;
|
|
361
|
+
}
|
|
362
|
+
throw new CliError('usage: deployments list|stats|rollback');
|
|
363
|
+
}
|
|
364
|
+
case 'secrets':
|
|
365
|
+
case 'vars': {
|
|
366
|
+
const env = requireEnv(flags);
|
|
367
|
+
const { client } = await session(flags, io);
|
|
368
|
+
const secret = command === 'secrets';
|
|
369
|
+
if (sub === 'list') {
|
|
370
|
+
const list = secret
|
|
371
|
+
? await client.secrets(env)
|
|
372
|
+
: await client.vars(env);
|
|
373
|
+
print(io, flags, list, () => list.items.length === 0
|
|
374
|
+
? `no ${command} in ${env}`
|
|
375
|
+
: list.items.map((e) => (secret ? e.name : `${e.name}=${e.value ?? ''}`)).join('\n'));
|
|
376
|
+
return 0;
|
|
377
|
+
}
|
|
378
|
+
const name = rest[0];
|
|
379
|
+
if (!name || (sub !== 'set' && sub !== 'update' && sub !== 'unset')) {
|
|
380
|
+
throw new CliError(`usage: ${command} list|set|update|unset <name>`);
|
|
381
|
+
}
|
|
382
|
+
if (sub === 'unset')
|
|
383
|
+
await (secret ? client.unsetSecret(env, name) : client.unsetVar(env, name));
|
|
384
|
+
else {
|
|
385
|
+
const value = valueFrom(flags, io);
|
|
386
|
+
if (sub === 'set')
|
|
387
|
+
await (secret ? client.setSecret(env, name, value) : client.setVar(env, name, value));
|
|
388
|
+
else
|
|
389
|
+
await (secret
|
|
390
|
+
? client.updateSecret(env, name, value)
|
|
391
|
+
: client.updateVar(env, name, value));
|
|
392
|
+
}
|
|
393
|
+
io.stderr(`${sub} ${command.slice(0, -1)} ${name} in ${env}`);
|
|
394
|
+
return 0;
|
|
395
|
+
}
|
|
396
|
+
case 'proxy': {
|
|
397
|
+
const { client } = await session(flags, io);
|
|
398
|
+
const sessionInfo = await client.proxy(requireFlag(flags, 'service'), {
|
|
399
|
+
env: requireEnv(flags),
|
|
400
|
+
port: integer(flags, 'port'),
|
|
401
|
+
});
|
|
402
|
+
print(io, flags, sessionInfo, () => `proxy session until ${sessionInfo.expiresAt}: send HTTP requests to ${sessionInfo.url}/<path> with your bearer, e.g. curl -H "Authorization: Bearer $TOKEN" ${sessionInfo.url}/`);
|
|
403
|
+
return 0;
|
|
404
|
+
}
|
|
405
|
+
case 'init': {
|
|
406
|
+
const seeded = init({
|
|
407
|
+
name: sub,
|
|
408
|
+
dir: flags.dir,
|
|
409
|
+
projectName: flags.name,
|
|
410
|
+
force: flags.force === 'true',
|
|
411
|
+
cwd: io.cwd,
|
|
412
|
+
});
|
|
413
|
+
print(io, flags, seeded, () => `seeded ${seeded.directory}: ${seeded.files.join(', ')}`);
|
|
414
|
+
return 0;
|
|
415
|
+
}
|
|
416
|
+
default:
|
|
417
|
+
throw new CliError(`unknown command ${command}\n\n${USAGE}`, 2);
|
|
418
|
+
}
|
|
419
|
+
}
|