@wpelevator/tug 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/compose.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import yaml from 'js-yaml';
3
3
  import crypto from 'node:crypto';
4
+ import { isDuration } from "./util.js";
4
5
  export async function readCompose(file = 'docker-compose.yml') {
5
6
  return yaml.load(await fs.readFile(file, 'utf8'));
6
7
  }
@@ -56,6 +57,51 @@ function validateIngress(name, svc, seen, errors) {
56
57
  if (tls === 'custom' && (!ingress.cert || !ingress.key))
57
58
  errors.push(`${name}: ingress.tls custom requires cert and key (names of project secrets holding the PEM certificate and private key)`);
58
59
  }
60
+ /** True when the service opted in to `x-tug.rollout.strategy: rolling`. */
61
+ export function isRolling(svc) { return svc?.['x-tug']?.rollout?.strategy === 'rolling'; }
62
+ function validateRollout(name, svc, errors) {
63
+ const rollout = svc['x-tug']?.rollout;
64
+ if (rollout === undefined)
65
+ return;
66
+ if (!rollout || typeof rollout !== 'object' || Array.isArray(rollout)) {
67
+ errors.push(`${name}: x-tug.rollout must be an object`);
68
+ return;
69
+ }
70
+ for (const key of Object.keys(rollout))
71
+ if (key !== 'strategy' && key !== 'drain')
72
+ errors.push(`${name}: x-tug.rollout.${key} is not supported (supported: strategy, drain)`);
73
+ const strategy = rollout.strategy ?? 'recreate';
74
+ if (!['recreate', 'rolling'].includes(strategy)) {
75
+ errors.push(`${name}: rollout.strategy must be recreate or rolling`);
76
+ return;
77
+ }
78
+ if (rollout.drain !== undefined && !isDuration(rollout.drain))
79
+ errors.push(`${name}: rollout.drain must be a duration such as 30s, 500ms or 1m`);
80
+ if (strategy !== 'rolling')
81
+ return;
82
+ const lp = svc['x-tug'];
83
+ if (!lp.ingress)
84
+ errors.push(`${name}: rollout.strategy rolling requires x-tug.ingress (the edge is what switches traffic between revisions)`);
85
+ if (!lp.health)
86
+ errors.push(`${name}: rollout.strategy rolling requires x-tug.health (a new revision only takes over once it is healthy)`);
87
+ const ports = svc.ports || [];
88
+ if (ports.length !== 1)
89
+ errors.push(`${name}: rollout.strategy rolling requires exactly one entry in ports (the ingress port); each revision gets its own host port`);
90
+ }
91
+ function validateProjectExtension(compose, errors) {
92
+ const ext = compose['x-tug'];
93
+ if (ext === undefined)
94
+ return;
95
+ if (!ext || typeof ext !== 'object' || Array.isArray(ext)) {
96
+ errors.push('x-tug must be an object');
97
+ return;
98
+ }
99
+ for (const key of Object.keys(ext))
100
+ if (key !== 'rollback')
101
+ errors.push(`x-tug.${key} is not supported (supported: rollback)`);
102
+ if (ext.rollback !== undefined && !['auto', 'manual'].includes(ext.rollback))
103
+ errors.push('x-tug.rollback must be auto or manual');
104
+ }
59
105
  export function validateCompose(compose, nodes = []) {
60
106
  if (!compose?.services || typeof compose.services !== 'object')
61
107
  throw new Error('compose file must define services');
@@ -73,19 +119,21 @@ export function validateCompose(compose, nodes = []) {
73
119
  if (lp.replicas && selected.length && lp.replicas !== selected.length)
74
120
  errors.push(`${name}: replicas must match placement node count in v1`);
75
121
  validateIngress(name, svc, ingressHosts, errors);
122
+ validateRollout(name, svc, errors);
76
123
  }
124
+ validateProjectExtension(compose, errors);
77
125
  if (errors.length)
78
126
  throw new Error(errors.join('; '));
79
127
  }
80
- export function buildDesiredState({ project, version, compose, registry, defaultNode = null }) {
128
+ export function buildDesiredState({ project, version, compose, registry, revision, defaultNode = null }) {
81
129
  const services = {};
82
130
  for (const [name, src] of Object.entries(compose.services || {})) {
83
131
  const lp = src['x-tug'] || {};
84
132
  const placement = lp.placement || (defaultNode ? { nodes: [defaultNode] } : {});
85
- const image = src.build ? `${registry}/${project}/${name}:${version}` : src.image;
133
+ const image = src.build ? `${registry ? `${registry}/` : "tug-build/"}${project}/${name}:${revision || version}` : src.image;
86
134
  services[name] = { image, build: src.build || null, placement, replicas: lp.replicas || placement.nodes?.length || 1, health: lp.health || null };
87
135
  }
88
- return { project, version, services, compose };
136
+ return { project, version, ...(revision ? { revision } : {}), services, compose };
89
137
  }
90
138
  /** `extra_hosts` entries (`name:ip`) for services that run on other nodes, from health-aware endpoints. */
91
139
  export function crossNodeHosts(project, endpoints, local) {
@@ -98,25 +146,53 @@ export function crossNodeHosts(project, endpoints, local) {
98
146
  }
99
147
  return hosts;
100
148
  }
149
+ /** Removes `depends_on` references to `names` (services that live in another Compose project). */
150
+ export function dropDependsOn(svc, names) {
151
+ const deps = svc.depends_on;
152
+ if (Array.isArray(deps)) {
153
+ const kept = deps.filter(d => !names.has(String(d)));
154
+ if (kept.length)
155
+ svc.depends_on = kept;
156
+ else
157
+ delete svc.depends_on;
158
+ }
159
+ else if (deps && typeof deps === 'object') {
160
+ const kept = Object.fromEntries(Object.entries(deps).filter(([dep]) => !names.has(dep)));
161
+ if (Object.keys(kept).length)
162
+ svc.depends_on = kept;
163
+ else
164
+ delete svc.depends_on;
165
+ }
166
+ }
101
167
  function extraHostsList(value) {
102
168
  if (!value)
103
169
  return [];
104
170
  return Array.isArray(value) ? value.map(String) : Object.entries(value).map(([k, v]) => `${k}:${v}`);
105
171
  }
106
- export function composeForNode(desiredState, nodeName, secrets = {}, endpoints = {}) {
172
+ /**
173
+ * The Compose file for one node. `exclude` names services placed here that are managed outside the
174
+ * node's main project (rolling services run in their own revision projects); they still count as
175
+ * local, so they get no cross-node `extra_hosts` entry, and `depends_on` references to them are dropped.
176
+ */
177
+ export function composeForNode(desiredState, nodeName, secrets = {}, endpoints = {}, exclude = new Set()) {
107
178
  const out = { services: {}, volumes: {} };
108
179
  const outVolumes = out.volumes;
109
180
  const usedVolumes = new Set();
181
+ const placed = new Set();
110
182
  for (const [name, src] of Object.entries(desiredState.compose.services || {})) {
111
- const lp = src['x-tug'] || {};
112
- const nodes = lp.placement?.nodes || [];
183
+ const nodes = desiredState.services[name].placement?.nodes || [];
113
184
  if (nodes.length && !nodes.includes(nodeName))
114
185
  continue;
186
+ placed.add(name);
187
+ if (exclude.has(name))
188
+ continue;
115
189
  const svc = { ...src, image: desiredState.services[name].image };
116
190
  delete svc.build;
117
191
  delete svc['x-tug'];
118
192
  delete svc.deploy;
119
193
  delete svc.container_name;
194
+ if (desiredState.services[name].artifact)
195
+ svc.pull_policy = 'never';
120
196
  if (Object.keys(secrets).length)
121
197
  svc.env_file = Array.from(new Set([...(Array.isArray(svc.env_file) ? svc.env_file : svc.env_file ? [svc.env_file] : []), '.env']));
122
198
  for (const vol of svc.volumes || []) {
@@ -124,9 +200,11 @@ export function composeForNode(desiredState, nodeName, secrets = {}, endpoints =
124
200
  if (source && desiredState.compose?.volumes?.[source])
125
201
  usedVolumes.add(source);
126
202
  }
203
+ if (exclude.size)
204
+ dropDependsOn(svc, exclude);
127
205
  out.services[name] = svc;
128
206
  }
129
- const hosts = crossNodeHosts(desiredState.project, endpoints, new Set(Object.keys(out.services)));
207
+ const hosts = crossNodeHosts(desiredState.project, endpoints, placed);
130
208
  if (hosts.length) {
131
209
  for (const svc of Object.values(out.services)) {
132
210
  const existing = extraHostsList(svc.extra_hosts);
package/dist/control.d.ts CHANGED
@@ -1,11 +1,24 @@
1
1
  #!/usr/bin/env node
2
2
  import express from 'express';
3
+ import { saveImageArchive, inspectImage, validateImageArchive } from './artifacts.ts';
3
4
  import https from 'node:https';
5
+ import { openDb } from './db.ts';
4
6
  import type { NodeRow } from './db.ts';
7
+ import { buildAndPush, run as runCommand } from './build.ts';
5
8
  declare module 'express-serve-static-core' {
6
9
  interface Request {
7
10
  node?: NodeRow;
8
11
  }
9
12
  }
10
- export declare function createApp(): Promise<import("express-serve-static-core").Express>;
13
+ export interface ControlImageOperations {
14
+ build: typeof buildAndPush;
15
+ save: typeof saveImageArchive;
16
+ inspect: typeof inspectImage;
17
+ validate: typeof validateImageArchive;
18
+ run: typeof runCommand;
19
+ }
20
+ export declare function createApp(options?: {
21
+ images?: Partial<ControlImageOperations>;
22
+ openDatabase?: typeof openDb;
23
+ }): Promise<import("express-serve-static-core").Express>;
11
24
  export declare function listen(app: express.Express, port?: number): Promise<import('node:http').Server | https.Server>;