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
25 changes: 4 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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};
}
}
```
9 changes: 5 additions & 4 deletions examples/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
4 changes: 3 additions & 1 deletion examples/diff.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 1 addition & 1 deletion examples/swarm-app.diff.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
services:
service_specs:

nginx:
image: nginx:alpine
12 changes: 7 additions & 5 deletions examples/swarm-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ networks:
name: external
external: true

services:
service_specs:

# {% if echo_servers is defined %}
# {% for entry in echo_servers %}
Expand All @@ -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 {
Expand All @@ -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
Expand Down
14 changes: 10 additions & 4 deletions schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -43,7 +43,7 @@
"configs": {
"values": {
"optionalProperties": {
"sourceFile": {
"source_file": {
"type": "string"
},
"content": {
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 0 additions & 4 deletions src/asserts.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
import assert from "assert";

export function assertNotNullOrUndefined<T> (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);
}
Expand Down
2 changes: 1 addition & 1 deletion src/commands/diff-cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
61 changes: 32 additions & 29 deletions src/commands/wait-cmd.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<string, string>();
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");
}

Expand Down
6 changes: 3 additions & 3 deletions src/docker-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
Expand All @@ -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) {
Expand Down
11 changes: 5 additions & 6 deletions src/hashed-config.ts
Original file line number Diff line number Diff line change
@@ -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 {

Expand Down Expand Up @@ -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;
}

Expand All @@ -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`});
}
Expand Down
4 changes: 2 additions & 2 deletions src/service-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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};
}),
},
};
Expand Down
Loading