diff --git a/sdk/openapi/cloud.openapi.yaml b/sdk/openapi/cloud.openapi.yaml index c9f068b..997c159 100644 --- a/sdk/openapi/cloud.openapi.yaml +++ b/sdk/openapi/cloud.openapi.yaml @@ -385,6 +385,9 @@ paths: application/json: schema: { $ref: '#/components/schemas/ServiceSettings' } default: { $ref: '#/components/responses/Error' } + /api/cloud/services/{serviceId}/port: { $ref: 'services.yaml#/paths/port' } + /api/cloud/services/{serviceId}/network: { $ref: 'services.yaml#/paths/network' } + /api/cloud/services/{serviceId}/variables/references: { $ref: 'services.yaml#/paths/variableReferences' } /api/cloud/services/{serviceId}/source-deployments: post: operationId: deploySource diff --git a/sdk/openapi/services.yaml b/sdk/openapi/services.yaml new file mode 100644 index 0000000..9aca423 --- /dev/null +++ b/sdk/openapi/services.yaml @@ -0,0 +1,126 @@ +paths: + port: + put: + operationId: setServicePort + description: | + Sets the port the service's container listens on, or clears it with null. A service a + project creation builds from a repository starts without one, and cannot be deployed + until it has one: a deployment asked before fails with `cloud.errors.builds.missing_port`. + + The running revision keeps the port it was deployed with; the next deployment takes the + new one. + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: serviceId + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [port] + properties: + port: + description: The port the container listens on; null for a service that listens on none. + type: [integer, "null"] + minimum: 1024 + maximum: 65535 + responses: + '200': + description: Done + '400': + description: '`cloud.errors.settings.invalid_port`: the port is not an integer from 1024 to 65535.' + content: + application/json: + schema: { $ref: './cloud.openapi.yaml#/components/schemas/Error' } + '409': + description: '`cloud.errors.settings.public_needs_port`: a public service cannot lose its port. `cloud.errors.settings.preview_read_only`: a preview mirrors its environment and is not edited.' + content: + application/json: + schema: { $ref: './cloud.openapi.yaml#/components/schemas/Error' } + default: { $ref: './cloud.openapi.yaml#/components/responses/Error' } + variableReferences: + post: + operationId: followPublishedValue + description: | + Adds a variable to the service that follows a value another service of the same + environment publishes, such as the `MONGO_URL` of a MongoDB resource: the variable takes + the published value, and takes the new one when it changes. Listed afterwards with a + `resource` origin. The value itself is never sent to the client. + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: serviceId + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [key, serviceId, publishedKey] + properties: + key: + description: The name of the variable on the service. + type: string + serviceId: + description: The service that publishes the value. + type: string + publishedKey: + description: The key the publisher publishes the value under. + type: string + responses: + '200': + description: Done + '400': + description: '`cloud.errors.settings.invalid_key`: the name is not a valid variable name. `cloud.errors.settings.self_reference`: a service cannot follow its own value.' + content: + application/json: + schema: { $ref: './cloud.openapi.yaml#/components/schemas/Error' } + '404': + description: '`cloud.errors.settings.unknown_publisher`: the environment has no such service. `cloud.errors.settings.key_not_published`: the publisher publishes no such key.' + content: + application/json: + schema: { $ref: './cloud.openapi.yaml#/components/schemas/Error' } + '409': + description: '`cloud.errors.settings.duplicate_key`: the service already holds a variable of that name. `cloud.errors.settings.preview_read_only`: a preview mirrors its environment and is not edited.' + content: + application/json: + schema: { $ref: './cloud.openapi.yaml#/components/schemas/Error' } + default: { $ref: './cloud.openapi.yaml#/components/responses/Error' } + network: + put: + operationId: setServiceExposure + description: | + Opens the service to the internet on its own address, or closes it. A service is created + closed. Sending the exposure it already has changes nothing. + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: serviceId + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [isPublic] + properties: + isPublic: + description: Whether the internet reaches the service on its own address. + type: boolean + responses: + '200': + description: Done + '409': + description: '`cloud.errors.settings.public_needs_port`: a service without a port cannot be opened. `cloud.errors.settings.subdomain_taken`: another service holds its address. `cloud.errors.settings.preview_read_only`: a preview mirrors its environment and is not edited.' + content: + application/json: + schema: { $ref: './cloud.openapi.yaml#/components/schemas/Error' } + default: { $ref: './cloud.openapi.yaml#/components/responses/Error' } diff --git a/sdk/src/api.ts b/sdk/src/api.ts index e5dd73d..7ffc789 100644 --- a/sdk/src/api.ts +++ b/sdk/src/api.ts @@ -52,6 +52,14 @@ export interface CreateProjectOptions { idempotencyKey?: string; } +/** A value a service publishes to the other services of its environment. */ +export interface PublishedValue { + /** The service that publishes the value. */ + serviceId: string; + /** The key it publishes the value under. */ + publishedKey: string; +} + /** How a log read is shaped: one bounded page, or a tail from a cursor. */ export interface LogRequest { follow: boolean; @@ -265,6 +273,62 @@ export class CloudApi { return dataOf(result); } + /** + * Sets the port the service's container listens on, from 1024 to 65535, or + * clears it with null. A service created from a repository has none, and is + * refused its deployments until it has one. The running revision keeps its + * port; the next deployment takes the new one. + */ + async setServicePort(serviceId: string, port: number | null): Promise { + const result = await this.client.PUT( + "/api/cloud/services/{serviceId}/port", + { params: { path: { serviceId } }, body: { port }, parseAs: "text" }, + ); + acceptedOf(result); + } + + /** + * Opens the service to the internet on its own address, or closes it. A + * service without a port is refused with + * `cloud.errors.settings.public_needs_port`. + */ + async setServiceExposure( + serviceId: string, + isPublic: boolean, + ): Promise { + const result = await this.client.PUT( + "/api/cloud/services/{serviceId}/network", + { params: { path: { serviceId } }, body: { isPublic }, parseAs: "text" }, + ); + acceptedOf(result); + } + + /** + * Adds a variable to the service that follows a value another service of + * its environment publishes, such as the `MONGO_URL` of a MongoDB resource. + * The value never reaches the client; the variable is then listed with a + * `resource` origin. + * + * @param serviceId The service that gets the variable + * @param key The variable's name on that service + * @param published The publishing service and the key it publishes + */ + async followPublishedValue( + serviceId: string, + key: string, + published: PublishedValue, + ): Promise { + const result = await this.client.POST( + "/api/cloud/services/{serviceId}/variables/references", + { + params: { path: { serviceId } }, + body: { key, ...published }, + parseAs: "text", + }, + ); + acceptedOf(result); + } + async deploymentHistory(serviceId: string) { const result = await this.client.GET( "/api/cloud/services/{serviceId}/deployments", diff --git a/sdk/src/generated/cloud-api.ts b/sdk/src/generated/cloud-api.ts index 344dcb2..31eb1ed 100644 --- a/sdk/src/generated/cloud-api.ts +++ b/sdk/src/generated/cloud-api.ts @@ -356,6 +356,72 @@ export interface paths { patch?: never; trace?: never; }; + "/api/cloud/services/{serviceId}/port": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * @description Sets the port the service's container listens on, or clears it with null. A service a + * project creation builds from a repository starts without one, and cannot be deployed + * until it has one: a deployment asked before fails with `cloud.errors.builds.missing_port`. + * + * The running revision keeps the port it was deployed with; the next deployment takes the + * new one. + */ + put: operations["setServicePort"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/cloud/services/{serviceId}/network": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * @description Opens the service to the internet on its own address, or closes it. A service is created + * closed. Sending the exposure it already has changes nothing. + */ + put: operations["setServiceExposure"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/cloud/services/{serviceId}/variables/references": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * @description Adds a variable to the service that follows a value another service of the same + * environment publishes, such as the `MONGO_URL` of a MongoDB resource: the variable takes + * the published value, and takes the new one when it changes. Listed afterwards with a + * `resource` origin. The value itself is never sent to the client. + */ + post: operations["followPublishedValue"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/cloud/services/{serviceId}/source-deployments": { parameters: { query?: never; @@ -1620,6 +1686,148 @@ export interface operations { default: components["responses"]["Error"]; }; }; + setServicePort: { + parameters: { + query?: never; + header?: never; + path: { + serviceId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description The port the container listens on; null for a service that listens on none. */ + port: number | null; + }; + }; + }; + responses: { + /** @description Done */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `cloud.errors.settings.invalid_port`: the port is not an integer from 1024 to 65535. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description `cloud.errors.settings.public_needs_port`: a public service cannot lose its port. `cloud.errors.settings.preview_read_only`: a preview mirrors its environment and is not edited. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + default: components["responses"]["Error"]; + }; + }; + setServiceExposure: { + parameters: { + query?: never; + header?: never; + path: { + serviceId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Whether the internet reaches the service on its own address. */ + isPublic: boolean; + }; + }; + }; + responses: { + /** @description Done */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `cloud.errors.settings.public_needs_port`: a service without a port cannot be opened. `cloud.errors.settings.subdomain_taken`: another service holds its address. `cloud.errors.settings.preview_read_only`: a preview mirrors its environment and is not edited. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + default: components["responses"]["Error"]; + }; + }; + followPublishedValue: { + parameters: { + query?: never; + header?: never; + path: { + serviceId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description The name of the variable on the service. */ + key: string; + /** @description The service that publishes the value. */ + serviceId: string; + /** @description The key the publisher publishes the value under. */ + publishedKey: string; + }; + }; + }; + responses: { + /** @description Done */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `cloud.errors.settings.invalid_key`: the name is not a valid variable name. `cloud.errors.settings.self_reference`: a service cannot follow its own value. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description `cloud.errors.settings.unknown_publisher`: the environment has no such service. `cloud.errors.settings.key_not_published`: the publisher publishes no such key. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description `cloud.errors.settings.duplicate_key`: the service already holds a variable of that name. `cloud.errors.settings.preview_read_only`: a preview mirrors its environment and is not edited. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + default: components["responses"]["Error"]; + }; + }; deploySource: { parameters: { query?: never; diff --git a/sdk/test/services.test.ts b/sdk/test/services.test.ts new file mode 100644 index 0000000..387b302 --- /dev/null +++ b/sdk/test/services.test.ts @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { CloudApi, CloudApiError } from "../src/index.js"; + +interface Sent { + url: string; + method: string; + authorization: string | null; + body: string; +} + +/** A `fetch` that records each request and answers every one with `answer`. */ +function recorder(answer: () => Response) { + const sent: Sent[] = []; + const send: typeof fetch = async (input, init) => { + const request = new Request(input, init); + sent.push({ + url: request.url, + method: request.method, + authorization: request.headers.get("authorization"), + body: await request.text(), + }); + return answer(); + }; + return { sent, send }; +} + +const SERVICE = "https://api.antelopejs.cloud/api/cloud/services/svc%2F3"; + +describe("service network settings", () => { + test("a port is put at the encoded service path, null clears it, and an answer with no body is accepted", async () => { + for (const status of [200, 204]) { + const { sent, send } = recorder(() => new Response(null, { status })); + const api = new CloudApi({ token: "t-1", fetch: send }); + + assert.equal(await api.setServicePort("svc/3", 5010), undefined); + await api.setServicePort("svc/3", null); + + assert.deepEqual( + sent.map( + (request) => `${request.method} ${request.url} ${request.body}`, + ), + [ + `PUT ${SERVICE}/port {"port":5010}`, + `PUT ${SERVICE}/port {"port":null}`, + ], + ); + assert.equal(sent[0]?.authorization, "Bearer t-1"); + } + }); + + test("an exposure is put at the service's network path, open or closed", async () => { + const { sent, send } = recorder(() => new Response(null, { status: 200 })); + const api = new CloudApi({ token: "t", fetch: send }); + + assert.equal(await api.setServiceExposure("svc/3", true), undefined); + await api.setServiceExposure("svc/3", false); + + assert.deepEqual( + sent.map((request) => `${request.method} ${request.url} ${request.body}`), + [ + `PUT ${SERVICE}/network {"isPublic":true}`, + `PUT ${SERVICE}/network {"isPublic":false}`, + ], + ); + }); + + test("a refused port or exposure is a CloudApiError carrying the code", async () => { + const { send } = recorder( + () => + new Response( + JSON.stringify("cloud.errors.settings.public_needs_port"), + { + status: 409, + headers: { "content-type": "application/json" }, + }, + ), + ); + const api = new CloudApi({ token: "t", fetch: send }); + const calls = [ + () => api.setServicePort("svc/3", null), + () => api.setServiceExposure("svc/3", true), + ]; + + for (const call of calls) { + await assert.rejects(call(), (error) => { + assert.ok(error instanceof CloudApiError); + assert.equal(error.status, 409); + assert.equal(error.code, "cloud.errors.settings.public_needs_port"); + return true; + }); + } + }); +}); + +describe("a variable that follows a published value", () => { + const database = { serviceId: "svc-db", publishedKey: "MONGO_URL" }; + + test("names the variable, the publisher and its key in one POST, and accepts an answer with no body", async () => { + const { sent, send } = recorder(() => new Response(null, { status: 200 })); + const api = new CloudApi({ token: "t", fetch: send }); + + assert.equal( + await api.followPublishedValue("svc/3", "MONGODB_URL", database), + undefined, + ); + + assert.equal(sent[0]?.method, "POST"); + assert.equal(sent[0]?.url, `${SERVICE}/variables/references`); + assert.deepEqual(JSON.parse(sent[0]?.body ?? ""), { + key: "MONGODB_URL", + serviceId: "svc-db", + publishedKey: "MONGO_URL", + }); + }); + + test("a name the service already holds is a CloudApiError carrying the code", async () => { + const { send } = recorder( + () => + new Response(JSON.stringify("cloud.errors.settings.duplicate_key"), { + status: 409, + headers: { "content-type": "application/json" }, + }), + ); + const api = new CloudApi({ token: "t", fetch: send }); + + await assert.rejects( + api.followPublishedValue("svc/3", "MONGODB_URL", database), + (error) => { + assert.ok(error instanceof CloudApiError); + assert.equal(error.code, "cloud.errors.settings.duplicate_key"); + return true; + }, + ); + }); +});