diff --git a/.yarn/cache/@mittwald-container-deploy-npm-1.6.2-d05358a47e-4ccb91401e.zip b/.yarn/cache/@mittwald-container-deploy-npm-1.6.2-d05358a47e-4ccb91401e.zip deleted file mode 100644 index 9f69f97f9..000000000 Binary files a/.yarn/cache/@mittwald-container-deploy-npm-1.6.2-d05358a47e-4ccb91401e.zip and /dev/null differ diff --git a/.yarn/cache/@mittwald-container-deploy-npm-2.0.1-20fb47c9df-a1851b4267.zip b/.yarn/cache/@mittwald-container-deploy-npm-2.0.1-20fb47c9df-a1851b4267.zip new file mode 100644 index 000000000..5dc6939ec Binary files /dev/null and b/.yarn/cache/@mittwald-container-deploy-npm-2.0.1-20fb47c9df-a1851b4267.zip differ diff --git a/docs/experimental.md b/docs/experimental.md index 9220f8467..1c12d159f 100644 --- a/docs/experimental.md +++ b/docs/experimental.md @@ -7,7 +7,7 @@ Experimental features, which are not considered stable yet and might change with ## `mw experimental deploy` -Deploys a new container. +Build the application in the current directory and deploy it as a container. ``` USAGE @@ -26,7 +26,7 @@ FLAGS --image-name= name of the Docker image to build and push --image-tag= tag of the Docker image to build and push --service-name= name of the container service to deploy - --uri-prefix= [default: webapp] prefix for the generated default domain + --uri-prefix= prefix for the generated default domain --wait-timeout= [default: 600s] the duration to wait for the resource to be ready (common units like 'ms', 's', 'm' are accepted). @@ -34,6 +34,32 @@ AUTHENTICATION FLAGS --token= API token to use for authentication (overrides environment and config file). NOTE: watch out that tokens passed via this flag might be logged in your shell history. +DESCRIPTION + Build the application in the current directory and deploy it as a container. + + The command runs the following steps: + + 1. Checks that Docker and Railpack are installed locally. + 2. Makes sure that the project has a working private container registry. The registry runs as the "project-registry" + container in a dedicated stack, is reachable at the "registry" subdomain of the project's default domain and is + configured as a registry of the project. Missing or misconfigured parts are created or repaired; existing parts + (including the stored images and the credentials) are reused. There is one such registry per project, shared by all + deployments. + 3. Builds a container image from the current directory, using the Dockerfile in it if there is one, and Railpack + otherwise. The container ports are taken from the EXPOSE instructions of the Dockerfile; a PORT variable passed with + --env takes precedence. Without either, port 80 is used. + 4. Pushes the image to the project registry. + 5. Deploys the image as a container service. Each service runs in a stack of its own, named after the service; a + service that already exists is updated in the stack it is in. + 6. Makes the service available at ..project.space. + + With --build-only, the command stops after pushing the image. + + The service name defaults to the name of the current directory (lowercased, with every run of characters other than + letters and digits replaced by a dash), and the image name and domain prefix default to the service name. Deploying + from different directories therefore creates independent deployments, while deploying again from the same directory + updates the existing one. + EXAMPLES Deploy from current directory (auto-detects or creates Dockerfile) @@ -47,9 +73,9 @@ EXAMPLES $ mw experimental deploy --uri-prefix myapp - Deploy a second, parallel service with a custom image and service name + Deploy the current directory as a second, independent service, e.g. for a feature branch - $ mw experimental deploy --service-name my-feature --image-name my-app --image-tag feature + $ mw experimental deploy --service-name my-app-feature --image-tag feature Build and push image only, without deploying a service @@ -82,7 +108,7 @@ FLAG DESCRIPTIONS --image-name= name of the Docker image to build and push - Defaults to 'app-image'. + Defaults to the service name. --image-tag= tag of the Docker image to build and push @@ -90,10 +116,10 @@ FLAG DESCRIPTIONS --service-name= name of the container service to deploy - Set this to run multiple parallel deployments in the same project. Defaults to 'app-'. + Also names the stack the service is deployed into. Defaults to the name of the current directory. --uri-prefix= prefix for the generated default domain - Defaults to 'webapp'. + Defaults to the service name. ``` diff --git a/package.json b/package.json index 9550b14ed..acfffc64e 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ ], "dependencies": { "@mittwald/api-client": "^4.400.0", - "@mittwald/container-deploy": "^1.3.0", + "@mittwald/container-deploy": "^2.0.1", "@mittwald/react-use-promise": "^2.6.0", "@oclif/core": "^4.0.18", "@oclif/plugin-autocomplete": "^3.0.3", diff --git a/src/commands/experimental/deploy.tsx b/src/commands/experimental/deploy.tsx index 611b415a4..19f0406f4 100644 --- a/src/commands/experimental/deploy.tsx +++ b/src/commands/experimental/deploy.tsx @@ -16,16 +16,18 @@ import { Success } from "../../rendering/react/components/Success.js"; import { Value } from "../../rendering/react/components/Value.js"; import { waitFlags } from "../../lib/wait.js"; import { getProjectShortIdFromUuid } from "../../lib/resources/project/shortId.js"; +import { ensureStackForService } from "../../lib/resources/stack/ensure.js"; +import { serviceNameFromDirectory } from "../../lib/resources/container/service-name.js"; import { checkRequiredTools, setupProjectRegistry, + MW_REGISTRY_SERVICE_NAME as registryServiceName, checkRepository, buildDockerImage, localDockerPush, deployService, createAndWaitForDomain, - DEFAULT_IMAGE_NAME, DEFAULT_IMAGE_TAG, } from "@mittwald/container-deploy"; @@ -35,8 +37,24 @@ type Result = { buildOnly: boolean; }; +const registryStackDescription = "project registry"; + export class Deploy extends ExecRenderBaseCommand { - static summary = "Deploys a new container."; + static summary = + "Build the application in the current directory and deploy it as a container."; + static description = `\ +The command runs the following steps: + +1. Checks that Docker and Railpack are installed locally. +2. Makes sure that the project has a working private container registry. The registry runs as the "${registryServiceName}" container in a dedicated stack, is reachable at the "registry" subdomain of the project's default domain and is configured as a registry of the project. Missing or misconfigured parts are created or repaired; existing parts (including the stored images and the credentials) are reused. There is one such registry per project, shared by all deployments. +3. Builds a container image from the current directory, using the Dockerfile in it if there is one, and Railpack otherwise. The container ports are taken from the EXPOSE instructions of the Dockerfile; a PORT variable passed with --env takes precedence. Without either, port 80 is used. +4. Pushes the image to the project registry. +5. Deploys the image as a container service. Each service runs in a stack of its own, named after the service; a service that already exists is updated in the stack it is in. +6. Makes the service available at ..project.space. + +With --build-only, the command stops after pushing the image. + +The service name defaults to the name of the current directory (lowercased, with every run of characters other than letters and digits replaced by a dash), and the image name and domain prefix default to the service name. Deploying from different directories therefore creates independent deployments, while deploying again from the same directory updates the existing one.`; static flags = { ...processFlags, ...projectFlags, @@ -45,19 +63,18 @@ export class Deploy extends ExecRenderBaseCommand { "env-file": containerEnvFileFlag(), "uri-prefix": Flags.string({ summary: "prefix for the generated default domain", - description: "Defaults to 'webapp'.", + description: "Defaults to the service name.", required: false, - default: "webapp", }), "service-name": Flags.string({ summary: "name of the container service to deploy", description: - "Set this to run multiple parallel deployments in the same project. Defaults to 'app-'.", + "Also names the stack the service is deployed into. Defaults to the name of the current directory.", required: false, }), "image-name": Flags.string({ summary: "name of the Docker image to build and push", - description: `Defaults to '${DEFAULT_IMAGE_NAME}'.`, + description: "Defaults to the service name.", required: false, }), "image-tag": Flags.string({ @@ -90,9 +107,9 @@ export class Deploy extends ExecRenderBaseCommand { }, { description: - "Deploy a second, parallel service with a custom image and service name", + "Deploy the current directory as a second, independent service, e.g. for a feature branch", command: - "<%= config.bin %> <%= command.id %> --service-name my-feature --image-name my-app --image-tag feature", + "<%= config.bin %> <%= command.id %> --service-name my-app-feature --image-tag feature", }, { description: "Build and push image only, without deploying a service", @@ -110,6 +127,7 @@ export class Deploy extends ExecRenderBaseCommand { this.apiClient, projectId, ); + const serviceName = this.getServiceName(); await p.runStep("Checking dev tools ...", async () => { checkRequiredTools(); @@ -123,9 +141,21 @@ export class Deploy extends ExecRenderBaseCommand { const registryData = await p.runStep( "Setting up registry ...", async () => { + const { stackId: registryStackId, created } = + await ensureStackForService( + this.apiClient, + projectId, + registryServiceName, + registryStackDescription, + ); + if (created) { + p.addInfo(`Created stack "${registryStackDescription}"`); + } + const registry = await setupProjectRegistry( this.apiClient, projectId, + registryStackId, projectShortId, this.flags["wait-timeout"], ); @@ -151,7 +181,7 @@ export class Deploy extends ExecRenderBaseCommand { "Building Docker image ...", async () => { const result = await buildDockerImage(registryData, repositoryData, { - name: this.flags["image-name"], + name: this.flags["image-name"] ?? serviceName, tag: this.flags["image-tag"], }); p.addInfo(`Built image ${result.imageName}`); @@ -180,13 +210,25 @@ export class Deploy extends ExecRenderBaseCommand { } const deployResult = await p.runStep("Deploying ...", async () => { + const { stackId: applicationStackId, created } = + await ensureStackForService( + this.apiClient, + projectId, + serviceName, + serviceName, + ); + if (created) { + p.addInfo(`Created stack "${serviceName}"`); + } + const result = await deployService( this.apiClient, projectId, + applicationStackId, repositoryData, this.flags["wait-timeout"], environment, - this.flags["service-name"], + serviceName, ); deployedServiceId = result.deployedServiceId; p.addInfo(`Service ${result.serviceName} is now running`); @@ -194,15 +236,15 @@ export class Deploy extends ExecRenderBaseCommand { }); await p.runStep("Setting up domain ...", async () => { - const uriPrefix = this.flags["uri-prefix"]; + const uriPrefix = this.flags["uri-prefix"] ?? serviceName; const uri = `${uriPrefix}.${projectShortId}.project.space`; - const registryServiceId = deployResult.deployedServiceId; + const appServiceId = deployResult.deployedServiceId; const timeout = this.flags["wait-timeout"]; await createAndWaitForDomain( this.apiClient, projectId, uri, - registryServiceId, + appServiceId, repositoryData.ports[0], timeout, ); @@ -221,6 +263,25 @@ export class Deploy extends ExecRenderBaseCommand { }; } + private getServiceName(): string { + const serviceName = + this.flags["service-name"] ?? serviceNameFromDirectory(process.cwd()); + + if (!serviceName) { + throw new Error( + "Could not derive a service name from the current directory name; please set one with --service-name.", + ); + } + + if (serviceName === registryServiceName) { + throw new Error( + `The service name "${registryServiceName}" is reserved for the project registry; please choose a different one with --service-name.`, + ); + } + + return serviceName; + } + protected render({ deployedServiceId, builtImageName, diff --git a/src/lib/resources/container/service-name.test.ts b/src/lib/resources/container/service-name.test.ts new file mode 100644 index 000000000..bfe94c983 --- /dev/null +++ b/src/lib/resources/container/service-name.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "@jest/globals"; +import { serviceNameFromDirectory } from "./service-name.js"; + +describe("serviceNameFromDirectory", () => { + it("uses the base name of the directory", () => { + expect(serviceNameFromDirectory("/home/user/projects/shop")).toBe("shop"); + }); + + it("lowercases and replaces unsupported characters with dashes", () => { + expect(serviceNameFromDirectory("/src/My Shop_v2.0")).toBe("my-shop-v2-0"); + }); + + it("trims leading and trailing dashes", () => { + expect(serviceNameFromDirectory("/src/__shop__")).toBe("shop"); + }); + + it("limits the name to 63 characters", () => { + const name = serviceNameFromDirectory(`/src/${"a".repeat(62)}_b`); + expect(name).toBe("a".repeat(62)); + }); + + it("returns undefined if no usable characters remain", () => { + expect(serviceNameFromDirectory("/src/___")).toBeUndefined(); + }); +}); diff --git a/src/lib/resources/container/service-name.ts b/src/lib/resources/container/service-name.ts new file mode 100644 index 000000000..345451bcd --- /dev/null +++ b/src/lib/resources/container/service-name.ts @@ -0,0 +1,27 @@ +import path from "path"; + +// Service names are also used as image names and domain labels, so they are +// limited to the characters (and length) that are valid in all of these. +const maxServiceNameLength = 63; + +/** + * Derives a service name from a directory name, similar to how docker compose + * derives a project name: the name is lowercased, every run of characters other + * than [a-z0-9] is replaced by a single dash, and leading and trailing dashes + * are removed. + * + * @returns The service name, or undefined if the directory name contains no + * usable characters. + */ +export function serviceNameFromDirectory( + directory: string, +): string | undefined { + const name = path + .basename(path.resolve(directory)) + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .slice(0, maxServiceNameLength) + .replace(/^-+|-+$/g, ""); + + return name || undefined; +} diff --git a/src/lib/resources/stack/ensure.test.ts b/src/lib/resources/stack/ensure.test.ts new file mode 100644 index 000000000..05b3fd28d --- /dev/null +++ b/src/lib/resources/stack/ensure.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "@jest/globals"; +import { type MittwaldAPIV2 } from "@mittwald/api-client"; +import { selectStackForService } from "./ensure.js"; + +type Stack = MittwaldAPIV2.Components.Schemas.ContainerStackResponse; +type Service = MittwaldAPIV2.Components.Schemas.ContainerServiceResponse; + +function stack(id: string, description: string, serviceNames: string[]) { + return { + id, + description, + services: serviceNames.map((serviceName) => ({ serviceName }) as Service), + } as Stack; +} + +describe("selectStackForService", () => { + it("prefers the stack that already contains the service over a description match", () => { + const stacks = [ + stack("registry", "project registry", []), + stack("default", "default", ["project-registry", "app"]), + ]; + + expect( + selectStackForService(stacks, "project-registry", "project registry")?.id, + ).toBe("default"); + }); + + it("falls back to a stack with a matching description", () => { + const stacks = [ + stack("default", "default", ["other"]), + stack("registry", "project registry", []), + ]; + + expect( + selectStackForService(stacks, "project-registry", "project registry")?.id, + ).toBe("registry"); + }); + + it("returns undefined if no stack matches", () => { + const stacks = [stack("default", "default", ["other"])]; + + expect( + selectStackForService(stacks, "project-registry", "project registry"), + ).toBeUndefined(); + }); +}); diff --git a/src/lib/resources/stack/ensure.ts b/src/lib/resources/stack/ensure.ts new file mode 100644 index 000000000..06ac3a1f3 --- /dev/null +++ b/src/lib/resources/stack/ensure.ts @@ -0,0 +1,52 @@ +import { + assertStatus, + type MittwaldAPIV2, + type MittwaldAPIV2Client, +} from "@mittwald/api-client"; + +type Stack = MittwaldAPIV2.Components.Schemas.ContainerStackResponse; + +/** + * Selects the stack that should host a given service: the stack that already + * contains a service with that name, or else a stack with the given + * description. + */ +export function selectStackForService( + stacks: Stack[], + serviceName: string, + description: string, +): Stack | undefined { + return ( + stacks.find((s) => + s.services?.some((svc) => svc.serviceName === serviceName), + ) ?? stacks.find((s) => s.description === description) + ); +} + +/** + * Returns the ID of the stack that should host a given service (see + * selectStackForService), creating a new stack with the given description if + * there is none. + */ +export async function ensureStackForService( + apiClient: MittwaldAPIV2Client, + projectId: string, + serviceName: string, + description: string, +): Promise<{ stackId: string; created: boolean }> { + const stacks = await apiClient.container.listStacks({ projectId }); + assertStatus(stacks, 200); + + const existing = selectStackForService(stacks.data, serviceName, description); + if (existing) { + return { stackId: existing.id, created: false }; + } + + const created = await apiClient.container.createStack({ + projectId, + data: { description }, + }); + assertStatus(created, 201); + + return { stackId: created.data.id, created: true }; +} diff --git a/yarn.lock b/yarn.lock index 36e037151..d573c1448 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1978,7 +1978,7 @@ __metadata: "@eslint/js": ^10.0.1 "@jest/globals": ^30.5.1 "@mittwald/api-client": ^4.400.0 - "@mittwald/container-deploy": ^1.3.0 + "@mittwald/container-deploy": ^2.0.1 "@mittwald/react-use-promise": ^2.6.0 "@oclif/core": ^4.0.18 "@oclif/plugin-autocomplete": ^3.0.3 @@ -2039,15 +2039,15 @@ __metadata: languageName: unknown linkType: soft -"@mittwald/container-deploy@npm:^1.3.0": - version: 1.6.2 - resolution: "@mittwald/container-deploy@npm:1.6.2" +"@mittwald/container-deploy@npm:^2.0.1": + version: 2.0.1 + resolution: "@mittwald/container-deploy@npm:2.0.1" peerDependencies: "@mittwald/api-client": ^4.331.0 peerDependenciesMeta: "@mittwald/api-client": optional: false - checksum: 4ccb91401edb193d1a33c185e0ef7898dc52c6a7b8d39b28eae41203015e90dc30f25b8e5c50636d0e76cef887d3cccd769f047fb199d8d94051ca6720b03cc6 + checksum: a1851b4267b59a6f22fb3cc84d40320f740a29ce896815f5c8c6ff799f177429d9fd413adb03d32daab3cec8c908bc43ba2a8ca44ca52081d2f183f4422b0166 languageName: node linkType: hard