diff --git a/README.md b/README.md index 4d6c36b..b16b2b7 100644 --- a/README.md +++ b/README.md @@ -3,30 +3,13 @@ Deploy application to docker swarm in a controlled manner. # Why? -- Rolling update of swarm config, thanks to checksum naming. -- Explicit syntax, no more optionals, no more short syntax. -- Built-in jinja2 style templating via nunjucks +- Rolling update of swarm config, thanks to checksum naming +- Explicit syntax, no more optionals, no more short syntax +- [Built-in jinja2 style templating via nunjucks](./examples/swarm-app.yml?plain=1L13) +- [Inline swarm configs with envsubst](./examples/swarm-app.yml?plain=1L34) # Usage - `swarm-app validate` will exit on basic configuration file mistakes. - `swarm-app diff` gives a proper diff overview of what you are about to deploy. - `swarm-app deploy` deploys the application. - `swarm-app wait` waits for deployment to reconcile, and outputs status. - -## Inline swarm configs with envsubst -```sh -export NGINX_FOLDER=html -``` - -```yml -services: - nginx: - configs: - /etc/nginx/conf.d/default.conf: - content: | - server { - location / { - root ${NGINX_FOLDER}; - } - } -``` diff --git a/examples/deploy.sh b/examples/deploy.sh index 8a50fa1..c19aa43 100755 --- a/examples/deploy.sh +++ b/examples/deploy.sh @@ -2,11 +2,12 @@ set -e -export STACK_NAME="test"; - docker network inspect external &>/dev/null || docker network create external --driver=overlay -NGINX_FOLDER="/usr/share/nginx/html" \ - node ../src/index.js deploy "$STACK_NAME" -f swarm-app.yml -i swarm-app.input.yml +export STACK_NAME="test" +export NGINX_FOLDER="/usr/share/nginx/html" +export NGINX_LOCATION="/public" + +node ../src/index.js deploy "$STACK_NAME" -f swarm-app.yml -i swarm-app.input.yml node ../src/index.js wait "$STACK_NAME" diff --git a/examples/diff.sh b/examples/diff.sh index 1159eec..e767e3d 100755 --- a/examples/diff.sh +++ b/examples/diff.sh @@ -2,6 +2,8 @@ set -e -export STACK_NAME="test"; +export STACK_NAME="test" +export NGINX_FOLDER="/usr/share/nginx/html" +export NGINX_LOCATION="/public" node ../src/index.js diff --write-lhs-rhs -f swarm-app.yml -f swarm-app.diff.yml -i swarm-app.input.yml "$STACK_NAME" diff --git a/examples/swarm-app.diff.yml b/examples/swarm-app.diff.yml index f811877..6823ba1 100644 --- a/examples/swarm-app.diff.yml +++ b/examples/swarm-app.diff.yml @@ -1,5 +1,5 @@ --- -services: +service_specs: nginx: image: nginx:alpine diff --git a/examples/swarm-app.yml b/examples/swarm-app.yml index 231c31a..0e27a6f 100644 --- a/examples/swarm-app.yml +++ b/examples/swarm-app.yml @@ -8,7 +8,7 @@ networks: name: external external: true -services: +service_specs: # {% if echo_servers is defined %} # {% for entry in echo_servers %} @@ -27,7 +27,7 @@ services: org.company.country: england configs: /etc/nginx/nginx.conf: - sourceFile: 'nginx.conf' + source_file: 'nginx.conf' /etc/nginx/conf.d/default.conf: content: | server { @@ -49,15 +49,17 @@ services: stop_signal: SIGQUIT stop_grace_period: 10 placement: - preferences: [{ spread: node.hostname }] + preferences: + - { spread: node.hostname } max_replicas_per_node: 2 constraints: - node.labels.purpose == generic endpoint_spec: ports: - protocol: tcp - published: 8080 - target: 80 + published_port: 8080 + target_port: 80 + publish_mode: host health_check: test: ["CMD", "true"] interval: 5000000 # 5s diff --git a/schema.json b/schema.json index 314d528..2f51db3 100644 --- a/schema.json +++ b/schema.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "title": "Generated schema for swarm-app", "properties": { - "services": { + "service_specs": { "values": { "optionalProperties": { "extends": { @@ -43,7 +43,7 @@ "configs": { "values": { "optionalProperties": { - "sourceFile": { + "source_file": { "type": "string" }, "content": { @@ -103,14 +103,20 @@ "ports": { "elements": { "properties": { - "published": { + "published_port": { "type": "int16" }, - "target": { + "target_port": { "type": "int16" } }, "optionalProperties": { + "publish_mode": { + "enum": [ + "ingress", + "host" + ] + }, "protocol": { "enum": [ "tcp", diff --git a/src/asserts.ts b/src/asserts.ts index 8c73efb..767559b 100644 --- a/src/asserts.ts +++ b/src/asserts.ts @@ -1,9 +1,5 @@ import assert from "assert"; -export function assertNotNullOrUndefined (value: T | null | undefined, msg: string): asserts value is T { - assert(value == null, msg); -} - export function assertString (value: unknown, msg: string): asserts value is string { assert(typeof value === "string", msg); } diff --git a/src/commands/diff-cmd.ts b/src/commands/diff-cmd.ts index e9a6253..0343657 100644 --- a/src/commands/diff-cmd.ts +++ b/src/commands/diff-cmd.ts @@ -50,7 +50,7 @@ interface InitServiceResourcesOpt { } function initServiceResources ({appName, config, hashedConfigs, current}: InitServiceResourcesOpt): ServiceSpec[] { const serviceSpecs: ServiceSpec[] = []; - for (const serviceName of Object.keys(config.services)) { + for (const serviceName of Object.keys(config.service_specs)) { const serviceSpec = initServiceSpec({appName, serviceName, config, hashedConfigs, current}); delete serviceSpec.version; serviceSpecs.push(serviceSpec); diff --git a/src/commands/wait-cmd.ts b/src/commands/wait-cmd.ts index 3bb34f6..2192d47 100644 --- a/src/commands/wait-cmd.ts +++ b/src/commands/wait-cmd.ts @@ -1,10 +1,14 @@ import {ArgumentsCamelCase, Argv} from "yargs"; -import Docker from "dockerode"; +import Docker, {Service} from "dockerode"; import timers from "timers/promises"; import {assertNumber, assertString} from "../asserts.js"; import {yargsAppNameFileOption} from "./deploy-cmd"; +import assert from "assert"; interface Task { + ID: string; + ServiceID: string; + Slot: number; DesiredState: string; Status: { State: string; @@ -23,50 +27,49 @@ export async function handler (args: ArgumentsCamelCase) { const dockerode = new Docker(); - console.log(`Awaiting task reconciliation for a max of ${timeout}ms`); + console.log(`Awaiting task reconciliation for ${timeout}ms`); - let services; - let timedout = false; - let reconciled; - let latestTaskError = ""; + let services: Service[], tasks: Task[], timedout, bail, serviceStateMap; const start = Date.now(); do { - latestTaskError = ""; - reconciled = true; + serviceStateMap = new Map(); services = await dockerode.listServices({filters: {label: [`com.docker.stack.namespace=${appName}`]}}); + tasks = await dockerode.listTasks({filters: {"label": [`com.docker.stack.namespace=${appName}`], "desired-state": ["running"]}}) as Task[]; - // Check the tasks for failures. for (const s of services) { - const tasks = await dockerode.listTasks({ - Filter: `service=${s.Spec?.Name}`, - }) as Task[]; - for (const t of tasks) { - if (t.DesiredState === "ready" && t.Status.State != "running") { - reconciled = false; - } - if (t.Status.State === "rejected" && latestTaskError == "") { - latestTaskError = t.Status.Err; + if (s.UpdateStatus?.State) { + serviceStateMap.set(s.ID, s.UpdateStatus.State); + } else { + const runningTasks = tasks.filter(t => t.Status.State === "running" && t.ServiceID === s.ID); + const totalTasks = tasks.filter(t => t.ServiceID === s.ID); + if (totalTasks.length > runningTasks.length) { + serviceStateMap.set(s.ID ?? "unspecified", "replicating"); } } } + const servicesUpdating = [...serviceStateMap].filter(([v]) => !["completed", "rollback_completed"].includes(v)); + bail = servicesUpdating.length === 0; + if (!bail) { + servicesUpdating.forEach(([serviceId, state]) => { + const serviceName = services.find(s => s.ID === serviceId)?.Spec?.Name; + assert(serviceName != null, "serviceName must be a string"); + const errMsg = tasks.find(t => t.ServiceID === serviceId && t.Status.Err)?.Status.Err; + console.log(`${serviceName} is in ${state}${errMsg ? ", error: '" + errMsg + "'" : ""}`); + }); + } + // To prevent high cpu usage await timers.setTimeout(5000); + // Calculate timedout timedout = Date.now() - timeout > start; - if (!reconciled && latestTaskError != "") { - console.error(latestTaskError); - } - } while (!timedout && !reconciled); - - if (!reconciled || timedout) { - if (timedout) { - console.error("Reconciliation timed out"); - } else { - console.error("Reconciliation failed"); - } + } while (!timedout && !bail); + if (timedout) { + console.error("Reconciliation timed out"); process.exit(1); } + console.log("Reconciliation succeeded"); } diff --git a/src/docker-api.ts b/src/docker-api.ts index 3d3b09b..602a1f4 100644 --- a/src/docker-api.ts +++ b/src/docker-api.ts @@ -70,7 +70,7 @@ export async function createMissingNetworks ({dockerode, current, config, appNam const listNetworks = await dockerode.listNetworks({filters: {label: [`com.docker.stack.namespace=${appName}`]}}); foundNetwork = listNetworks.find((ln) => ln.Name === n.name) as NetworkInspectInfoPlus | undefined; - assert(foundNetwork != null, `Network ${n.name} could not be found, it has just have been created!`); + assert(foundNetwork != null, `Network ${n.name} could not be found, it should have just have been created!`); newNetworks.push(foundNetwork); } return newNetworks; @@ -101,7 +101,7 @@ export async function removeUnusedServices ({dockerode, current, config, appName for (const s of current.services) { if (!s.Spec?.Name) continue; const serviceShortName = s.Spec.Name.replace(new RegExp(`^${appName}_`), ""); - if (config.services[serviceShortName]) continue; + if (config.service_specs[serviceShortName]) continue; console.log(`Removing service ${s.Spec.Name}`); await dockerode.getService(s.ID).remove(); } @@ -115,7 +115,7 @@ interface UpsertServicesOpts { hashedConfigs: HashedConfigs; } export async function upsertServices ({dockerode, config, current, appName, hashedConfigs}: UpsertServicesOpts) { - for (const serviceName of Object.keys(config.services)) { + for (const serviceName of Object.keys(config.service_specs)) { const serviceSpec = initServiceSpec({appName, serviceName, config, hashedConfigs, current}); const foundService = current.services.find((s) => s.Spec?.Name === `${appName}_${serviceName}`); if (!foundService) { diff --git a/src/hashed-config.ts b/src/hashed-config.ts index c899157..3960577 100644 --- a/src/hashed-config.ts +++ b/src/hashed-config.ts @@ -1,8 +1,7 @@ import crypto from "crypto"; import {SwarmAppConfig} from "./swarm-app-config.js"; import fs from "fs"; -import {AssertionError} from "assert"; -import {assertNotNullOrUndefined} from "./asserts"; +import assert, {AssertionError} from "assert"; export class HashedConfig { @@ -35,7 +34,7 @@ export class HashedConfigs { public find (serviceName: string, targetPath: string): HashedConfig { const found = this.list.find(l => l.serviceName === serviceName && l.targetPath === targetPath); - assertNotNullOrUndefined(found, `Could not find hashed config ${serviceName} ${targetPath}`); + assert(found != null, `Could not find hashed config ${serviceName} ${targetPath}`); return found; } @@ -54,14 +53,14 @@ export class HashedConfigs { export async function initHashedConfigs (config: SwarmAppConfig) { const hashedConfigs = new HashedConfigs(); - for (const [serviceName, s] of Object.entries(config.services)) { + for (const [serviceName, s] of Object.entries(config.service_specs)) { if (!s.configs) continue; for (const [targetPath, c] of Object.entries(s.configs)) { let content; if (c.content) { content = c.content; - } else if (c.sourceFile) { - content = await fs.promises.readFile(c.sourceFile, "utf-8"); + } else if (c.source_file) { + content = await fs.promises.readFile(c.source_file, "utf-8"); } else { throw new AssertionError({message: `config ${targetPath} missing content or file field`}); } diff --git a/src/service-spec.ts b/src/service-spec.ts index 21fba10..5c2990a 100644 --- a/src/service-spec.ts +++ b/src/service-spec.ts @@ -37,7 +37,7 @@ interface InitServiceSpecOpts { } export function initServiceSpec ({appName, serviceName, config, hashedConfigs, current}: InitServiceSpecOpts): ServiceSpec & {version?: number} { - const serviceConfig = config.services[serviceName]; + const serviceConfig = config.service_specs[serviceName]; let env; if (serviceConfig.environment) { @@ -111,7 +111,7 @@ export function initServiceSpec ({appName, serviceName, config, hashedConfigs, c EndpointSpec: { Mode: "vip", Ports: serviceConfig.endpoint_spec?.ports.map(p => { - return {Protocol: p.protocol, TargetPort: p.target, PublishedPort: p.published, PublishMode: "ingress"}; + return {Protocol: p.protocol, TargetPort: p.target_port, PublishedPort: p.published_port, PublishMode: p.publish_mode}; }), }, }; diff --git a/src/swarm-app-config.ts b/src/swarm-app-config.ts index c292185..155aee7 100644 --- a/src/swarm-app-config.ts +++ b/src/swarm-app-config.ts @@ -17,8 +17,9 @@ export interface SwarmAppNetworkConfig { export interface SwarmAppEndpointSpecPort { protocol?: "tcp" | "udp" | "sctp"; - published: number; - target: number; + publish_mode?: "ingress" | "host"; + published_port: number; + target_port: number; } export interface SwarmAppServiceConfig { @@ -29,7 +30,7 @@ export interface SwarmAppServiceConfig { entrypoint?: string[]; container_labels?: Record; configs?: Record; environment?: Record; @@ -67,13 +68,13 @@ export interface SwarmAppServiceConfig { export interface SwarmAppConfig { networks?: Record; - services: Record; + service_specs: Record; } export const swarmAppConfigSchema: JTDSchemaType = { properties: { - services: { + service_specs: { values: { optionalProperties: { extends: { @@ -92,7 +93,7 @@ export const swarmAppConfigSchema: JTDSchemaType = { configs: { values: { optionalProperties: { - sourceFile: {type: "string"}, + source_file: {type: "string"}, content: {type: "string"}, }, }, @@ -123,10 +124,11 @@ export const swarmAppConfigSchema: JTDSchemaType = { ports: { elements: { properties: { - published: {type: "int16"}, - target: {type: "int16"}, + published_port: {type: "int16"}, + target_port: {type: "int16"}, }, optionalProperties: { + publish_mode: {enum: ["ingress", "host"]}, protocol: {enum: ["tcp", "udp", "sctp"]}, }, }, @@ -209,7 +211,7 @@ export async function expandSwarmAppConfig (swarmAppConfig: SwarmAppConfig, appN } // Expand envFile to environment - for (const s of Object.values(swarmAppConfig.services)) { + for (const s of Object.values(swarmAppConfig.service_specs)) { if (!s.env_file) continue; const envFileCnt = await fs.promises.readFile(s.env_file, "utf8"); s.environment = {...s.environment, ...parseEnvFile(envFileCnt)}; @@ -217,7 +219,7 @@ export async function expandSwarmAppConfig (swarmAppConfig: SwarmAppConfig, appN } // Envsubst all string values - const services = swarmAppConfig.services; + const services = swarmAppConfig.service_specs; traverse(swarmAppConfig).forEach(function (v) { if (typeof v !== "string") return; @@ -230,7 +232,7 @@ export async function expandSwarmAppConfig (swarmAppConfig: SwarmAppConfig, appN }); // Ensure com.docker.stack.namespace labels - for (const service of Object.values(swarmAppConfig.services)) { + for (const service of Object.values(swarmAppConfig.service_specs)) { service.service_labels = service.service_labels ?? {}; service.service_labels["com.docker.stack.namespace"] = appName; service.container_labels = service.container_labels ?? {};