@wpelevator/tug 0.3.0 → 0.5.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.
@@ -0,0 +1,20 @@
1
+ /** The portable subset shared by synchronous SQLite runtimes. Only anonymous ? bindings are supported. */
2
+ export type SqlValue = string | number | bigint | null | Uint8Array;
3
+ export type SqlRow = Record<string, SqlValue>;
4
+ export interface Db {
5
+ get<T = SqlRow>(sql: string, ...values: SqlValue[]): T | undefined;
6
+ all<T = SqlRow>(sql: string, ...values: SqlValue[]): T[];
7
+ run(sql: string, ...values: SqlValue[]): {
8
+ changes: number;
9
+ };
10
+ exec(sql: string): void;
11
+ close(): void;
12
+ }
13
+ export type ConnectDb = (filename: string, options: {
14
+ readOnly: boolean;
15
+ }) => Db;
16
+ export declare class DbError extends Error {
17
+ readonly kind: 'unique' | 'busy';
18
+ constructor(kind: DbError['kind'], cause: Error);
19
+ }
20
+ export declare function isUniqueViolation(error: unknown): boolean;
@@ -0,0 +1,11 @@
1
+ export class DbError extends Error {
2
+ kind;
3
+ constructor(kind, cause) {
4
+ super(cause.message, { cause });
5
+ this.name = 'DbError';
6
+ this.kind = kind;
7
+ }
8
+ }
9
+ export function isUniqueViolation(error) {
10
+ return error instanceof DbError && error.kind === 'unique';
11
+ }
@@ -0,0 +1,3 @@
1
+ import type { ConnectDb } from './db-driver.ts';
2
+ /** This module is the only runtime-specific adapter; replace it at build time for a future Bun bundle. */
3
+ export declare const connectNodeDb: ConnectDb;
@@ -0,0 +1,60 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { DbError } from "./db-driver.js";
3
+ function sqliteCall(operation) {
4
+ try {
5
+ return operation();
6
+ }
7
+ catch (error) {
8
+ if (error instanceof Error && 'errcode' in error) {
9
+ const code = Number(error.errcode);
10
+ if (code === 1555 || code === 2067)
11
+ throw new DbError('unique', error);
12
+ if ((code & 255) === 5 || (code & 255) === 6)
13
+ throw new DbError('busy', error);
14
+ }
15
+ throw error;
16
+ }
17
+ }
18
+ /** Count anonymous placeholders outside SQL literals, quoted identifiers and comments. */
19
+ function checkBindings(sql, values) {
20
+ const tokens = sql.match(/'(?:''|[^'])*'|"(?:""|[^"])*"|`(?:``|[^`])*`|\[[^\]]*\]|--[^\r\n]*|\/\*[\s\S]*?\*\/|\?\d*|[:@$][\w]+/g) ?? [];
21
+ const parameters = tokens.filter((token) => /^[?:@$]/.test(token));
22
+ if (parameters.some((token) => token !== '?'))
23
+ throw new TypeError('Only anonymous ? SQL parameters are supported');
24
+ if (parameters.length !== values.length)
25
+ throw new TypeError(`Expected ${parameters.length} SQL bindings, received ${values.length}`);
26
+ for (const value of values) {
27
+ if (value !== null &&
28
+ typeof value !== 'string' &&
29
+ typeof value !== 'bigint' &&
30
+ !(typeof value === 'number' && Number.isFinite(value)) &&
31
+ !(value instanceof Uint8Array))
32
+ throw new TypeError('SQL bindings must be strings, finite numbers, bigints, null or Uint8Array');
33
+ }
34
+ }
35
+ /** This module is the only runtime-specific adapter; replace it at build time for a future Bun bundle. */
36
+ export const connectNodeDb = (filename, { readOnly }) => {
37
+ const database = sqliteCall(() => new DatabaseSync(filename, { readOnly }));
38
+ const prepare = (sql, values) => {
39
+ checkBindings(sql, values);
40
+ return database.prepare(sql);
41
+ };
42
+ return {
43
+ get: (sql, ...values) => sqliteCall(() => {
44
+ const row = prepare(sql, values).get(...values);
45
+ return row === undefined ? undefined : { ...row };
46
+ }),
47
+ all: (sql, ...values) => sqliteCall(() => prepare(sql, values)
48
+ .all(...values)
49
+ .map((row) => ({ ...row }))),
50
+ run: (sql, ...values) => sqliteCall(() => {
51
+ const { changes } = prepare(sql, values).run(...values);
52
+ const count = Number(changes);
53
+ if (!Number.isSafeInteger(count))
54
+ throw new RangeError('SQL change count exceeds the safe integer range');
55
+ return { changes: count };
56
+ }),
57
+ exec: (sql) => sqliteCall(() => database.exec(sql)),
58
+ close: () => sqliteCall(() => database.close()),
59
+ };
60
+ };
package/dist/db.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import sqlite3 from 'sqlite3';
2
- import type { Database } from 'sqlite';
3
- export type Db = Database<sqlite3.Database, sqlite3.Statement>;
1
+ import type { ConnectDb, Db } from './db-driver.ts';
2
+ export type { Db } from './db-driver.ts';
3
+ export declare const openDb: (filename: string) => Promise<Db>, openAgentDb: (filename: string) => Promise<Db>, openSnapshotDb: (filename: string) => Db;
4
4
  export interface ProjectRow {
5
5
  id: string;
6
6
  name: string;
@@ -19,7 +19,6 @@ export interface NodeRow {
19
19
  status: string;
20
20
  labels_json: string | null;
21
21
  agent_token_hash: string | null;
22
- registry_token_hash: string | null;
23
22
  last_seen_at: string | null;
24
23
  created_at: string;
25
24
  updated_at: string;
@@ -120,5 +119,9 @@ export interface AppliedStateRow {
120
119
  service_states_json: string;
121
120
  secret_versions_json: string;
122
121
  }
123
- export declare function openDb(filename: string): Promise<Db>;
124
- export declare function openAgentDb(filename: string): Promise<Db>;
122
+ /** Shared policy for every driver, including read-only restore validation. */
123
+ export declare function databaseAccess(connect: ConnectDb): {
124
+ openDb: (filename: string) => Promise<Db>;
125
+ openAgentDb: (filename: string) => Promise<Db>;
126
+ openSnapshotDb: (filename: string) => Db;
127
+ };
package/dist/db.js CHANGED
@@ -1,19 +1,38 @@
1
- import sqlite3 from 'sqlite3';
2
- import { open } from 'sqlite';
1
+ import { connectNodeDb } from "./db-node.js";
3
2
  import path from 'node:path';
4
3
  import { ensureDir, now } from "./util.js";
5
- export async function openDb(filename) {
6
- await ensureDir(path.dirname(filename));
7
- const db = await open({ filename, driver: sqlite3.Database });
8
- await db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;');
9
- await migrate(db);
10
- return db;
4
+ // One composition point for all writable and snapshot connections.
5
+ export const { openDb, openAgentDb, openSnapshotDb } = databaseAccess(connectNodeDb);
6
+ function configure(db, writable, initialize) {
7
+ try {
8
+ db.exec('PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 1000;');
9
+ if (writable)
10
+ db.exec('PRAGMA journal_mode = WAL;');
11
+ initialize?.(db);
12
+ return db;
13
+ }
14
+ catch (error) {
15
+ try {
16
+ db.close();
17
+ }
18
+ catch {
19
+ /* Preserve the initialization failure. */
20
+ }
21
+ throw error;
22
+ }
11
23
  }
12
- export async function openAgentDb(filename) {
13
- await ensureDir(path.dirname(filename));
14
- const db = await open({ filename, driver: sqlite3.Database });
15
- await db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;');
16
- await db.exec(`
24
+ /** Shared policy for every driver, including read-only restore validation. */
25
+ export function databaseAccess(connect) {
26
+ function openSnapshotDb(filename) {
27
+ return configure(connect(filename, { readOnly: true }), false);
28
+ }
29
+ async function openDb(filename) {
30
+ await ensureDir(path.dirname(filename));
31
+ return configure(connect(filename, { readOnly: false }), true, initializeControl);
32
+ }
33
+ async function openAgentDb(filename) {
34
+ await ensureDir(path.dirname(filename));
35
+ return configure(connect(filename, { readOnly: false }), true, (db) => db.exec(`
17
36
  CREATE TABLE IF NOT EXISTS applied_state(
18
37
  project TEXT PRIMARY KEY,
19
38
  version TEXT,
@@ -27,14 +46,15 @@ CREATE TABLE IF NOT EXISTS event_state(
27
46
  fingerprint TEXT NOT NULL,
28
47
  phase TEXT NOT NULL
29
48
  );
30
- `);
31
- return db;
49
+ `));
50
+ }
51
+ return { openDb, openAgentDb, openSnapshotDb };
32
52
  }
33
- async function migrate(db) {
34
- await db.exec(`
53
+ function initializeControl(db) {
54
+ db.exec(`
35
55
  CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
36
56
  CREATE TABLE IF NOT EXISTS projects(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, current_deployment_id TEXT, created_at TEXT, updated_at TEXT);
37
- CREATE TABLE IF NOT EXISTS nodes(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, address TEXT, public_address TEXT, overlay_ip TEXT UNIQUE, ingress INTEGER NOT NULL DEFAULT 0, ingress_status_json TEXT, status TEXT, labels_json TEXT, agent_token_hash TEXT, registry_token_hash TEXT, last_seen_at TEXT, created_at TEXT, updated_at TEXT);
57
+ CREATE TABLE IF NOT EXISTS nodes(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, address TEXT, public_address TEXT, overlay_ip TEXT UNIQUE, ingress INTEGER NOT NULL DEFAULT 0, ingress_status_json TEXT, status TEXT, labels_json TEXT, agent_token_hash TEXT, last_seen_at TEXT, created_at TEXT, updated_at TEXT);
38
58
  CREATE TABLE IF NOT EXISTS wg_peers(node_id TEXT PRIMARY KEY, public_key TEXT NOT NULL UNIQUE, overlay_ip TEXT NOT NULL, endpoint TEXT, allowed_ips TEXT NOT NULL, updated_at TEXT);
39
59
  CREATE TABLE IF NOT EXISTS enroll_tokens(token_hash TEXT PRIMARY KEY, name TEXT, uses_remaining INTEGER, expires_at TEXT, created_at TEXT);
40
60
  CREATE TABLE IF NOT EXISTS deployments(id TEXT PRIMARY KEY, project_id TEXT, version TEXT, state TEXT, created_at TEXT, updated_at TEXT, error_message TEXT, actor TEXT);
@@ -51,13 +71,7 @@ CREATE TABLE IF NOT EXISTS api_tokens(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT
51
71
  CREATE INDEX IF NOT EXISTS events_by_time ON events(created_at);
52
72
  CREATE TABLE IF NOT EXISTS locks(name TEXT PRIMARY KEY, owner TEXT, expires_at TEXT, created_at TEXT);
53
73
  `);
54
- try {
55
- await db.exec('ALTER TABLE nodes ADD COLUMN labels_json TEXT');
56
- }
57
- catch {
58
- /* column already exists */
59
- }
60
- const row = await db.get('SELECT value FROM meta WHERE key = ?', 'schema_created_at');
74
+ const row = db.get('SELECT value FROM meta WHERE key = ?', 'schema_created_at');
61
75
  if (!row)
62
- await db.run('INSERT INTO meta(key,value) VALUES(?,?)', 'schema_created_at', now());
76
+ db.run('INSERT INTO meta(key,value) VALUES(?,?)', 'schema_created_at', now());
63
77
  }
@@ -0,0 +1,232 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import type { Readable, Writable } from 'node:stream';
3
+ /** Oldest Engine API the agent speaks (Docker 20.10+, which the nested daemons in the integration tests run). */
4
+ export declare const API_VERSION = "v1.41";
5
+ export declare const DEFAULT_SOCKET = "/var/run/docker.sock";
6
+ /** An Engine API error; `status` is the HTTP status and `message` the daemon's own text. */
7
+ export declare class DockerError extends Error {
8
+ status: number;
9
+ constructor(status: number, message: string);
10
+ }
11
+ export declare const isNotFound: (error: unknown) => boolean;
12
+ export interface ContainerSummary {
13
+ Id: string;
14
+ Names: string[];
15
+ Image: string;
16
+ ImageID?: string;
17
+ State: string;
18
+ Status?: string;
19
+ Created?: number;
20
+ Labels: Record<string, string>;
21
+ Ports?: {
22
+ IP?: string;
23
+ PrivatePort: number;
24
+ PublicPort?: number;
25
+ Type: string;
26
+ }[];
27
+ }
28
+ export interface ContainerInspect {
29
+ Id: string;
30
+ Name: string;
31
+ Image?: string;
32
+ Config?: {
33
+ Image?: string;
34
+ Labels?: Record<string, string>;
35
+ Env?: string[];
36
+ };
37
+ State?: {
38
+ Status?: string;
39
+ Running?: boolean;
40
+ ExitCode?: number;
41
+ Health?: {
42
+ Status?: string;
43
+ };
44
+ };
45
+ NetworkSettings?: {
46
+ Networks?: Record<string, {
47
+ IPAddress?: string;
48
+ GlobalIPv6Address?: string;
49
+ }>;
50
+ };
51
+ [key: string]: unknown;
52
+ }
53
+ export interface ContainerFilter {
54
+ /** `key` or `key=value` label filters, all of which must match. */
55
+ label?: string[];
56
+ /** Containers created from this image reference or ID. */
57
+ ancestor?: string;
58
+ name?: string;
59
+ }
60
+ export interface ImageInspect {
61
+ Id: string;
62
+ Os?: string;
63
+ Architecture?: string;
64
+ RepoTags?: string[];
65
+ RepoDigests?: string[];
66
+ }
67
+ export interface NetworkInspect {
68
+ Name: string;
69
+ Id: string;
70
+ Labels?: Record<string, string>;
71
+ Containers?: Record<string, unknown>;
72
+ }
73
+ export interface VolumeInspect {
74
+ Name: string;
75
+ Driver?: string;
76
+ Labels?: Record<string, string> | null;
77
+ Options?: Record<string, string> | null;
78
+ }
79
+ export interface DaemonInfo {
80
+ OSType?: string;
81
+ Architecture?: string;
82
+ ServerVersion?: string;
83
+ [key: string]: unknown;
84
+ }
85
+ export interface DaemonVersion {
86
+ Version?: string;
87
+ ApiVersion?: string;
88
+ MinAPIVersion?: string;
89
+ [key: string]: unknown;
90
+ }
91
+ /** `POST /containers/create` body; the agent builds these in `containerSpecs`. */
92
+ export interface CreateContainerBody {
93
+ Image: string;
94
+ Env?: string[];
95
+ Cmd?: string[] | null;
96
+ Entrypoint?: string[] | null;
97
+ User?: string;
98
+ WorkingDir?: string;
99
+ Hostname?: string;
100
+ Labels?: Record<string, string>;
101
+ ExposedPorts?: Record<string, object>;
102
+ StopSignal?: string;
103
+ StopTimeout?: number;
104
+ Healthcheck?: Record<string, unknown>;
105
+ HostConfig?: Record<string, unknown>;
106
+ NetworkingConfig?: {
107
+ EndpointsConfig: Record<string, {
108
+ Aliases?: string[];
109
+ [key: string]: unknown;
110
+ }>;
111
+ };
112
+ }
113
+ /** A process attached to a container: exec or logs. Shaped like a child process so `live.ts` treats both alike. */
114
+ export interface AttachedProcess extends EventEmitter {
115
+ stdin: Writable | null;
116
+ stdout: Readable;
117
+ stderr: Readable;
118
+ kill(signal?: NodeJS.Signals): boolean;
119
+ resize?(columns: number, rows: number): void;
120
+ }
121
+ export interface ExecOptions {
122
+ cmd: string[];
123
+ stdin?: boolean;
124
+ tty?: boolean;
125
+ columns?: number;
126
+ rows?: number;
127
+ }
128
+ export interface LogsOptions {
129
+ follow?: boolean;
130
+ tail?: number;
131
+ timestamps?: boolean;
132
+ }
133
+ export interface DockerApi {
134
+ version(): Promise<DaemonVersion>;
135
+ info(): Promise<DaemonInfo>;
136
+ listContainers(filter?: ContainerFilter, all?: boolean): Promise<ContainerSummary[]>;
137
+ inspectContainer(id: string): Promise<ContainerInspect>;
138
+ createContainer(name: string, body: CreateContainerBody): Promise<string>;
139
+ startContainer(id: string): Promise<void>;
140
+ stopContainer(id: string, timeoutSeconds?: number): Promise<void>;
141
+ restartContainer(id: string): Promise<void>;
142
+ /** Removes a container even when running; a missing container is not an error. */
143
+ removeContainer(id: string): Promise<void>;
144
+ /** Connects a running container to a network, optionally under service aliases. */
145
+ connectNetwork(network: string, container: string, aliases?: string[]): Promise<void>;
146
+ /** Container output as demultiplexed, ended streams (a TTY container's output is all stdout). */
147
+ logs(id: string, options?: LogsOptions): Promise<AttachedProcess>;
148
+ /** Copies the tar stream into the container directory `dir`. */
149
+ putArchive(id: string, dir: string, tar: Readable | Buffer): Promise<void>;
150
+ /** Runs a command to completion and returns its output; a non-zero exit is reported in `code`. */
151
+ execRun(id: string, cmd: string[]): Promise<{
152
+ code: number;
153
+ out: string;
154
+ err: string;
155
+ }>;
156
+ /** Starts an interactive or streaming exec; `close` carries the exit code. */
157
+ execStart(id: string, options: ExecOptions): Promise<AttachedProcess>;
158
+ inspectNetwork(name: string): Promise<NetworkInspect | null>;
159
+ createNetwork(name: string, labels: Record<string, string>): Promise<void>;
160
+ inspectVolume(name: string): Promise<VolumeInspect | null>;
161
+ createVolume(name: string, options: {
162
+ driver?: string;
163
+ opts?: Record<string, string>;
164
+ labels?: Record<string, string>;
165
+ }): Promise<void>;
166
+ removeVolume(name: string): Promise<void>;
167
+ inspectImage(ref: string): Promise<ImageInspect | null>;
168
+ /** Pulls `ref`; `auth` becomes `X-Registry-Auth`. */
169
+ pullImage(ref: string, auth?: Record<string, string>): Promise<void>;
170
+ loadImage(archive: Readable | Buffer): Promise<void>;
171
+ removeImage(ref: string): Promise<void>;
172
+ }
173
+ /** Splits a reference into `fromImage` and `tag` for `POST /images/create`. */
174
+ export declare function splitImageRef(ref: string): {
175
+ fromImage: string;
176
+ tag: string;
177
+ };
178
+ /**
179
+ * Splits the 8-byte-header multiplexed stream (`[stream, 0, 0, 0, size(4, big endian)]` frames) that
180
+ * non-TTY attach, exec and logs use into `stdout` and `stderr`. `end` fires once the source ends.
181
+ */
182
+ export declare function demux(source: Readable, stdout: Writable, stderr: Writable): void;
183
+ /** `unix:///path` or a bare path from `DOCKER_HOST`; the agent talks to the local daemon only. */
184
+ export declare function socketFromEnv(env?: NodeJS.ProcessEnv): string;
185
+ export declare class DockerEngine implements DockerApi {
186
+ private readonly socketPath;
187
+ constructor(socketPath?: string);
188
+ private request;
189
+ /** Reads the response; non-2xx becomes a `DockerError` carrying the daemon's message. */
190
+ private call;
191
+ private json;
192
+ /** Consumes the JSON-lines progress stream of pull and load; a line with `error` fails the call. */
193
+ private progress;
194
+ version: () => Promise<DaemonVersion>;
195
+ info: () => Promise<DaemonInfo>;
196
+ listContainers(filter?: ContainerFilter, all?: boolean): Promise<ContainerSummary[]>;
197
+ inspectContainer: (id: string) => Promise<ContainerInspect>;
198
+ createContainer(name: string, body: CreateContainerBody): Promise<string>;
199
+ startContainer(id: string): Promise<void>;
200
+ stopContainer(id: string, timeoutSeconds?: number): Promise<void>;
201
+ restartContainer(id: string): Promise<void>;
202
+ removeContainer(id: string): Promise<void>;
203
+ connectNetwork(network: string, container: string, aliases?: string[]): Promise<void>;
204
+ logs(id: string, options?: LogsOptions): Promise<AttachedProcess>;
205
+ private isTty;
206
+ /**
207
+ * Wraps a response or hijacked socket as a process whose output is demultiplexed unless a TTY.
208
+ * `close` fires with `exitCode()` once the source and both output streams have ended.
209
+ */
210
+ private attachedFromStream;
211
+ putArchive(id: string, dir: string, tar: Readable | Buffer): Promise<void>;
212
+ execRun(id: string, cmd: string[]): Promise<{
213
+ code: number;
214
+ out: string;
215
+ err: string;
216
+ }>;
217
+ execStart(id: string, options: ExecOptions): Promise<AttachedProcess>;
218
+ private resizeExec;
219
+ inspectNetwork(name: string): Promise<NetworkInspect | null>;
220
+ createNetwork(name: string, labels: Record<string, string>): Promise<void>;
221
+ inspectVolume(name: string): Promise<VolumeInspect | null>;
222
+ createVolume(name: string, options: {
223
+ driver?: string;
224
+ opts?: Record<string, string>;
225
+ labels?: Record<string, string>;
226
+ }): Promise<void>;
227
+ removeVolume(name: string): Promise<void>;
228
+ inspectImage(ref: string): Promise<ImageInspect | null>;
229
+ pullImage(ref: string, auth?: Record<string, string>): Promise<void>;
230
+ loadImage(archive: Readable | Buffer): Promise<void>;
231
+ removeImage(ref: string): Promise<void>;
232
+ }