From f6b628a3f92eceea40c306b2cbd4086a8ffdc723 Mon Sep 17 00:00:00 2001 From: dada-sudo Date: Thu, 12 Nov 2026 12:50:48 +0100 Subject: [PATCH] feat(sdk): create a service built from a repository, with the Dockerfile that builds it createService took an upload source only, so a second service of a project (a dashboard next to its API) could not be created from the repository the project builds. It now takes a repository source too, with an optional dockerfilePath, and setServiceDockerfile names or clears that Dockerfile on an existing service. The service settings read returns the source with its dockerfilePath. NewUploadService stays exported; NewService is the body createService takes. Co-Authored-By: Claude --- sdk/openapi/cloud.openapi.yaml | 18 ++--- sdk/openapi/services.yaml | 89 ++++++++++++++++++++++++ sdk/src/api.ts | 36 +++++++++- sdk/src/generated/cloud-api.ts | 115 ++++++++++++++++++++++++++++-- sdk/test/services.test.ts | 123 +++++++++++++++++++++++++++++++++ 5 files changed, 359 insertions(+), 22 deletions(-) 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" };