Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file not shown.
Binary file not shown.
40 changes: 33 additions & 7 deletions docs/experimental.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,14 +26,40 @@ FLAGS
--image-name=<value> name of the Docker image to build and push
--image-tag=<value> tag of the Docker image to build and push
--service-name=<value> name of the container service to deploy
--uri-prefix=<value> [default: webapp] prefix for the generated default domain
--uri-prefix=<value> prefix for the generated default domain
--wait-timeout=<value> [default: 600s] the duration to wait for the resource to be ready (common units like 'ms',
's', 'm' are accepted).

AUTHENTICATION FLAGS
--token=<value> 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 <uri-prefix>.<project-short-id>.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)

Expand All @@ -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

Expand Down Expand Up @@ -82,18 +108,18 @@ FLAG DESCRIPTIONS

--image-name=<value> name of the Docker image to build and push

Defaults to 'app-image'.
Defaults to the service name.

--image-tag=<value> tag of the Docker image to build and push

Defaults to 'latest'.

--service-name=<value> name of the container service to deploy

Set this to run multiple parallel deployments in the same project. Defaults to 'app-<project-id>'.
Also names the stack the service is deployed into. Defaults to the name of the current directory.

--uri-prefix=<value> prefix for the generated default domain

Defaults to 'webapp'.
Defaults to the service name.
```

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
87 changes: 74 additions & 13 deletions src/commands/experimental/deploy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -35,8 +37,24 @@ type Result = {
buildOnly: boolean;
};

const registryStackDescription = "project registry";

export class Deploy extends ExecRenderBaseCommand<typeof Deploy, Result> {
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 <uri-prefix>.<project-short-id>.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,
Expand All @@ -45,19 +63,18 @@ export class Deploy extends ExecRenderBaseCommand<typeof Deploy, Result> {
"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-<project-id>'.",
"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({
Expand Down Expand Up @@ -90,9 +107,9 @@ export class Deploy extends ExecRenderBaseCommand<typeof Deploy, Result> {
},
{
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",
Expand All @@ -110,6 +127,7 @@ export class Deploy extends ExecRenderBaseCommand<typeof Deploy, Result> {
this.apiClient,
projectId,
);
const serviceName = this.getServiceName();

await p.runStep("Checking dev tools ...", async () => {
checkRequiredTools();
Expand All @@ -123,9 +141,21 @@ export class Deploy extends ExecRenderBaseCommand<typeof Deploy, Result> {
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"],
);
Expand All @@ -151,7 +181,7 @@ export class Deploy extends ExecRenderBaseCommand<typeof Deploy, Result> {
"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}`);
Expand Down Expand Up @@ -180,29 +210,41 @@ export class Deploy extends ExecRenderBaseCommand<typeof Deploy, Result> {
}

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`);
return result;
});

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,
);
Expand All @@ -221,6 +263,25 @@ export class Deploy extends ExecRenderBaseCommand<typeof Deploy, Result> {
};
}

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,
Expand Down
25 changes: 25 additions & 0 deletions src/lib/resources/container/service-name.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
27 changes: 27 additions & 0 deletions src/lib/resources/container/service-name.ts
Original file line number Diff line number Diff line change
@@ -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;
}
46 changes: 46 additions & 0 deletions src/lib/resources/stack/ensure.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading