diff --git a/sdk/openapi/cloud.openapi.yaml b/sdk/openapi/cloud.openapi.yaml index 690b0cc..cec4c0a 100644 --- a/sdk/openapi/cloud.openapi.yaml +++ b/sdk/openapi/cloud.openapi.yaml @@ -194,19 +194,7 @@ components: region: { type: string } environmentId: { type: string } serviceId: { type: [string, "null"] } - NewUploadService: - type: object - required: [name, appName, source] - properties: - name: { type: string } - appName: { type: string } - source: - type: object - required: [type] - properties: - type: { type: string, const: upload } - port: { type: [integer, "null"] } - isPublic: { type: boolean } + NewService: { $ref: 'services.yaml#/schemas/NewService' } CreatedService: type: object required: [serviceId, name, appName] @@ -239,6 +227,7 @@ components: required: [serviceId, scaling, volumeLimits] properties: serviceId: { type: string } + source: { $ref: 'services.yaml#/schemas/ServiceSettingsSource' } scaling: type: object required: [bounds, replicaLimit, pricing] @@ -353,7 +342,7 @@ paths: required: true content: application/json: - schema: { $ref: '#/components/schemas/NewUploadService' } + schema: { $ref: '#/components/schemas/NewService' } responses: '200': content: @@ -387,6 +376,7 @@ paths: 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}/source-dockerfile: { $ref: 'services.yaml#/paths/sourceDockerfile' } /api/cloud/services/{serviceId}/variables/references: { $ref: 'services.yaml#/paths/variableReferences' } /api/cloud/services/{serviceId}/source-deployments: post: diff --git a/sdk/openapi/services.yaml b/sdk/openapi/services.yaml index 9aca423..c984e67 100644 --- a/sdk/openapi/services.yaml +++ b/sdk/openapi/services.yaml @@ -1,3 +1,55 @@ +schemas: + NewService: + description: | + A service to create in an environment. Its source says where its deployments come + from: an upload each `ajs cloud deploy` carries, or a repository AntelopeJS Cloud + builds at each push. + type: object + required: [name, appName, source] + properties: + name: { type: string } + appName: { type: string } + source: + oneOf: + - $ref: '#/schemas/UploadSource' + - $ref: '#/schemas/RepositorySource' + port: { type: [integer, "null"] } + isPublic: { type: boolean } + UploadSource: + type: object + required: [type] + properties: + type: { type: string, const: upload } + RepositorySource: + description: | + A GitHub repository the workspace's GitHub installation grants. Two services of one + repository differ by their Dockerfile: an API built by the AntelopeJS recipe next to + a dashboard built by `frontend.Dockerfile`. + type: object + required: [type, repository] + properties: + type: { type: string, const: repository } + repository: { type: string, description: "The repository, as `owner/name`." } + branch: + description: The branch this service builds; null or absent follows its environment's branch. + type: [string, "null"] + dockerfilePath: { $ref: '#/schemas/DockerfilePath' } + DockerfilePath: + description: | + Path, inside the repository, of the Dockerfile that builds the service, such as + `frontend.Dockerfile` or `apps/web/Dockerfile`: plain segments of letters, digits, + `.`, `_` and `-`, separated by `/`. Null or absent: the root `Dockerfile` when the + repository has one, else a recipe. + type: [string, "null"] + maxLength: 255 + ServiceSettingsSource: + description: Where the service's deployments come from. + type: object + required: [type] + properties: + type: { type: string, enum: [repository, image, upload, database] } + repository: { type: [string, "null"], description: "`owner/name`, for a repository source." } + dockerfilePath: { $ref: '#/schemas/DockerfilePath' } paths: port: put: @@ -124,3 +176,40 @@ paths: application/json: schema: { $ref: './cloud.openapi.yaml#/components/schemas/Error' } default: { $ref: './cloud.openapi.yaml#/components/responses/Error' } + sourceDockerfile: + put: + operationId: setServiceDockerfile + description: | + Names the Dockerfile, inside its repository, that builds the service, or goes back + to the root `Dockerfile` or a recipe with null. The next build reads it; the + revisions already built keep their image. A build of a commit that holds no file at + that path fails with `cloud.errors.builds.dockerfile_not_found`. + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: serviceId + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [path] + properties: + path: { $ref: '#/schemas/DockerfilePath' } + responses: + '200': + description: Done + '400': + description: '`cloud.errors.settings.invalid_dockerfile_path`: not a plain relative path inside the repository.' + content: + application/json: + schema: { $ref: './cloud.openapi.yaml#/components/schemas/Error' } + '409': + description: '`cloud.errors.settings.not_a_repository_source`: the service does not build a repository. `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 3e034d8..ec0c336 100644 --- a/sdk/src/api.ts +++ b/sdk/src/api.ts @@ -21,7 +21,14 @@ export type EnvironmentVariable = components["schemas"]["EnvironmentVariable"]; export type EnvironmentVariableList = components["schemas"]["EnvironmentVariableList"]; export type NewProject = components["schemas"]["NewProject"]; -export type NewUploadService = components["schemas"]["NewUploadService"]; +/** A service to create: one each `ajs cloud deploy` uploads, or one built from a repository. */ +export type NewService = components["schemas"]["NewService"]; +/** A service whose deployments each carry their own uploaded source. */ +export type NewUploadService = NewService & { source: { type: "upload" } }; +/** A service AntelopeJS Cloud builds from a repository at each push. */ +export type NewRepositoryService = NewService & { + source: { type: "repository" }; +}; export type VariableOrigin = components["schemas"]["VariableOrigin"]; export type VariableWrite = components["schemas"]["VariableWrite"]; export type WorkspaceProvisioning = @@ -151,10 +158,17 @@ export class CloudApi { return dataOf(result); } + /** + * Creates a service in an environment. With a `repository` source it builds + * that repository at each push to the environment's branch, with the + * Dockerfile its `dockerfilePath` names when it has one: that is how a + * second service, such as a dashboard built by `frontend.Dockerfile`, is + * built from the repository of the first. + */ async createService( projectId: string, environmentId: string, - body: NewUploadService, + body: NewService, ) { const result = await this.client.POST( "/api/cloud/projects/{projectId}/environments/{environmentId}/services", @@ -296,6 +310,24 @@ export class CloudApi { acceptedOf(result); } + /** + * Names the Dockerfile, inside its repository, that builds the service, or + * goes back to the root `Dockerfile` or a recipe with `null`. The next + * build reads it. A path that is not a plain relative path is refused with + * `cloud.errors.settings.invalid_dockerfile_path`, a service that builds no + * repository with `cloud.errors.settings.not_a_repository_source`. + */ + async setServiceDockerfile( + serviceId: string, + path: string | null, + ): Promise { + const result = await this.client.PUT( + "/api/cloud/services/{serviceId}/source-dockerfile", + { params: { path: { serviceId } }, body: { path }, 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. diff --git a/sdk/src/generated/cloud-api.ts b/sdk/src/generated/cloud-api.ts index faaf0bb..9746a6d 100644 --- a/sdk/src/generated/cloud-api.ts +++ b/sdk/src/generated/cloud-api.ts @@ -400,6 +400,28 @@ export interface paths { patch?: never; trace?: never; }; + "/api/cloud/services/{serviceId}/source-dockerfile": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * @description Names the Dockerfile, inside its repository, that builds the service, or goes back + * to the root `Dockerfile` or a recipe with null. The next build reads it; the + * revisions already built keep their image. A build of a commit that holds no file at + * that path fails with `cloud.errors.builds.dockerfile_not_found`. + */ + put: operations["setServiceDockerfile"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/cloud/services/{serviceId}/variables/references": { parameters: { query?: never; @@ -715,13 +737,15 @@ export interface components { environmentId: string; serviceId?: string | null; }; - NewUploadService: { + /** + * @description A service to create in an environment. Its source says where its deployments come + * from: an upload each `ajs cloud deploy` carries, or a repository AntelopeJS Cloud + * builds at each push. + */ + NewService: { name: string; appName: string; - source: { - /** @constant */ - type: "upload"; - }; + source: components["schemas"]["UploadSource"] | components["schemas"]["RepositorySource"]; port?: number | null; isPublic?: boolean; }; @@ -746,6 +770,7 @@ export interface components { }; ServiceSettings: { serviceId: string; + source?: components["schemas"]["ServiceSettingsSource"]; scaling: { bounds: components["schemas"]["ServiceBounds"]; replicaLimit: number; @@ -921,6 +946,31 @@ export interface components { environmentId: string; services: components["schemas"]["ServiceHealth"][]; }; + UploadSource: { + /** @constant */ + type: "upload"; + }; + /** + * @description Path, inside the repository, of the Dockerfile that builds the service, such as + * `frontend.Dockerfile` or `apps/web/Dockerfile`: plain segments of letters, digits, + * `.`, `_` and `-`, separated by `/`. Null or absent: the root `Dockerfile` when the + * repository has one, else a recipe. + */ + DockerfilePath: string | null; + /** + * @description A GitHub repository the workspace's GitHub installation grants. Two services of one + * repository differ by their Dockerfile: an API built by the AntelopeJS recipe next to + * a dashboard built by `frontend.Dockerfile`. + */ + RepositorySource: { + /** @constant */ + type: "repository"; + /** @description The repository, as `owner/name`. */ + repository: string; + /** @description The branch this service builds; null or absent follows its environment's branch. */ + branch?: string | null; + dockerfilePath?: components["schemas"]["DockerfilePath"]; + }; /** @description A monthly amount in the plan's currency. A plan that rates nothing a database holds includes it, with no amount to show. */ MonthlyCost: { /** @constant */ @@ -1050,6 +1100,14 @@ export interface components { /** @description The plan that remains once the databases are created. */ plan: components["schemas"]["ResourcePlan"]; }; + /** @description Where the service's deployments come from. */ + ServiceSettingsSource: { + /** @enum {string} */ + type: "repository" | "image" | "upload" | "database"; + /** @description `owner/name`, for a repository source. */ + repository?: string | null; + dockerfilePath?: components["schemas"]["DockerfilePath"]; + }; /** @description What a built revision was built from. */ RevisionBuild: { /** @description The branch built; null for a source the CLI uploaded. */ @@ -1523,7 +1581,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["NewUploadService"]; + "application/json": components["schemas"]["NewService"]; }; }; responses: { @@ -1796,6 +1854,51 @@ export interface operations { default: components["responses"]["Error"]; }; }; + setServiceDockerfile: { + parameters: { + query?: never; + header?: never; + path: { + serviceId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + path: components["schemas"]["DockerfilePath"]; + }; + }; + }; + responses: { + /** @description Done */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `cloud.errors.settings.invalid_dockerfile_path`: not a plain relative path inside the repository. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description `cloud.errors.settings.not_a_repository_source`: the service does not build a repository. `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; diff --git a/sdk/test/services.test.ts b/sdk/test/services.test.ts index 387b302..d81928c 100644 --- a/sdk/test/services.test.ts +++ b/sdk/test/services.test.ts @@ -93,6 +93,129 @@ describe("service network settings", () => { }); }); +describe("a service built from a repository", () => { + const SERVICES = + "https://api.antelopejs.cloud/api/cloud/projects/prj%2F1/environments/env%2F2/services"; + + test("is created with its repository and the Dockerfile that builds it, and answers its id", async () => { + const created = { + serviceId: "svc-9", + name: "dashboard", + appName: "shop-dashboard", + }; + const { sent, send } = recorder( + () => + new Response(JSON.stringify(created), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + const api = new CloudApi({ token: "t-1", fetch: send }); + + const answer = await api.createService("prj/1", "env/2", { + name: "dashboard", + appName: "shop-dashboard", + source: { + type: "repository", + repository: "acme/shop", + dockerfilePath: "frontend.Dockerfile", + }, + port: 3000, + isPublic: true, + }); + + assert.deepEqual(answer, created); + assert.equal(sent[0]?.method, "POST"); + assert.equal(sent[0]?.url, SERVICES); + assert.equal(sent[0]?.authorization, "Bearer t-1"); + assert.deepEqual(JSON.parse(sent[0]?.body ?? ""), { + name: "dashboard", + appName: "shop-dashboard", + source: { + type: "repository", + repository: "acme/shop", + dockerfilePath: "frontend.Dockerfile", + }, + port: 3000, + isPublic: true, + }); + }); + + test("an upload service is still created as before", async () => { + const { sent, send } = recorder( + () => + new Response( + JSON.stringify({ serviceId: "svc-1", name: "web", appName: "web" }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + const api = new CloudApi({ token: "t", fetch: send }); + + await api.createService("prj/1", "env/2", { + name: "web", + appName: "web", + source: { type: "upload" }, + }); + + assert.deepEqual(JSON.parse(sent[0]?.body ?? ""), { + name: "web", + appName: "web", + source: { type: "upload" }, + }); + }); + + test("its Dockerfile is put at the encoded service path, null goes back to the default, 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", fetch: send }); + + assert.equal( + await api.setServiceDockerfile("svc/3", "apps/web/Dockerfile"), + undefined, + ); + await api.setServiceDockerfile("svc/3", null); + + assert.deepEqual( + sent.map( + (request) => `${request.method} ${request.url} ${request.body}`, + ), + [ + `PUT ${SERVICE}/source-dockerfile {"path":"apps/web/Dockerfile"}`, + `PUT ${SERVICE}/source-dockerfile {"path":null}`, + ], + ); + } + }); + + test("a refused Dockerfile is a CloudApiError carrying the code", async () => { + const refusals: Array<[number, string]> = [ + [400, "cloud.errors.settings.invalid_dockerfile_path"], + [409, "cloud.errors.settings.not_a_repository_source"], + ]; + + for (const [status, code] of refusals) { + const { send } = recorder( + () => + new Response(JSON.stringify(code), { + status, + headers: { "content-type": "application/json" }, + }), + ); + const api = new CloudApi({ token: "t", fetch: send }); + + await assert.rejects( + api.setServiceDockerfile("svc/3", "../Dockerfile"), + (error) => { + assert.ok(error instanceof CloudApiError); + assert.equal(error.status, status); + assert.equal(error.code, code); + return true; + }, + ); + } + }); +}); + describe("a variable that follows a published value", () => { const database = { serviceId: "svc-db", publishedKey: "MONGO_URL" };