1.0.33
This commit is contained in:
85 files changed
+10118
-1046
No files matched your search
+31
-8
@@ -388,15 +388,17 @@ export async function getUserThemePreferences(userId: number): Promise<{
|
||||
gridFontSize: string;
|
||||
terminalFont: string;
|
||||
editorFont: string;
|
||||
animateIcons: boolean;
|
||||
}> {
|
||||
const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont] = await Promise.all([
|
||||
const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, animateIcons] = await Promise.all([
|
||||
getUserSetting(userId, 'light_theme'),
|
||||
getUserSetting(userId, 'dark_theme'),
|
||||
getUserSetting(userId, 'font'),
|
||||
getUserSetting(userId, 'font_size'),
|
||||
getUserSetting(userId, 'grid_font_size'),
|
||||
getUserSetting(userId, 'terminal_font'),
|
||||
getUserSetting(userId, 'editor_font')
|
||||
getUserSetting(userId, 'editor_font'),
|
||||
getUserSetting(userId, 'animate_icons')
|
||||
]);
|
||||
return {
|
||||
lightTheme: lightTheme || 'default',
|
||||
@@ -405,13 +407,15 @@ export async function getUserThemePreferences(userId: number): Promise<{
|
||||
fontSize: fontSize || 'normal',
|
||||
gridFontSize: gridFontSize || 'normal',
|
||||
terminalFont: terminalFont || 'system-mono',
|
||||
editorFont: editorFont || 'system-mono'
|
||||
editorFont: editorFont || 'system-mono',
|
||||
// Default ON — only false when explicitly stored
|
||||
animateIcons: animateIcons === 'false' ? false : true
|
||||
};
|
||||
}
|
||||
|
||||
export async function setUserThemePreferences(
|
||||
userId: number,
|
||||
prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string }
|
||||
prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean }
|
||||
): Promise<void> {
|
||||
const updates: Promise<void>[] = [];
|
||||
if (prefs.lightTheme !== undefined) {
|
||||
@@ -435,6 +439,9 @@ export async function setUserThemePreferences(
|
||||
if (prefs.editorFont !== undefined) {
|
||||
updates.push(setUserSetting(userId, 'editor_font', prefs.editorFont));
|
||||
}
|
||||
if (prefs.animateIcons !== undefined) {
|
||||
updates.push(setUserSetting(userId, 'animate_icons', prefs.animateIcons ? 'true' : 'false'));
|
||||
}
|
||||
await Promise.all(updates);
|
||||
}
|
||||
|
||||
@@ -2097,6 +2104,7 @@ export interface GitStackData {
|
||||
lastCommit: string | null;
|
||||
syncStatus: GitSyncStatus;
|
||||
syncError: string | null;
|
||||
syncedFiles?: string | null; // JSON manifest { commit, files: { relPath: sha256 } } from last successful deploy
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -2303,6 +2311,7 @@ export async function getGitStack(id: number): Promise<GitStackWithRepo | null>
|
||||
lastCommit: gitStacks.lastCommit,
|
||||
syncStatus: gitStacks.syncStatus,
|
||||
syncError: gitStacks.syncError,
|
||||
syncedFiles: gitStacks.syncedFiles,
|
||||
createdAt: gitStacks.createdAt,
|
||||
updatedAt: gitStacks.updatedAt,
|
||||
repoName: gitRepositories.name,
|
||||
@@ -2337,6 +2346,7 @@ export async function getGitStack(id: number): Promise<GitStackWithRepo | null>
|
||||
lastCommit: row.lastCommit,
|
||||
syncStatus: row.syncStatus,
|
||||
syncError: row.syncError,
|
||||
syncedFiles: row.syncedFiles ?? null,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
repository: {
|
||||
@@ -2548,6 +2558,7 @@ export async function updateGitStack(id: number, data: Partial<GitStackData>): P
|
||||
if (data.lastCommit !== undefined) updateData.lastCommit = data.lastCommit;
|
||||
if (data.syncStatus !== undefined) updateData.syncStatus = data.syncStatus;
|
||||
if (data.syncError !== undefined) updateData.syncError = data.syncError;
|
||||
if (data.syncedFiles !== undefined) updateData.syncedFiles = data.syncedFiles;
|
||||
|
||||
await db.update(gitStacks).set(updateData).where(eq(gitStacks.id, id));
|
||||
return getGitStack(id);
|
||||
@@ -3592,9 +3603,15 @@ export async function getContainerEventActions(): Promise<string[]> {
|
||||
|
||||
export async function deleteOldContainerEvents(keepDays = 30): Promise<number> {
|
||||
const cutoffDate = new Date(Date.now() - keepDays * 24 * 60 * 60 * 1000).toISOString();
|
||||
await db.delete(containerEvents)
|
||||
const countResult = await db.select({ count: sql<number>`count(*)` })
|
||||
.from(containerEvents)
|
||||
.where(sql`timestamp < ${cutoffDate}`);
|
||||
return 0;
|
||||
const count = Number(countResult[0]?.count ?? 0);
|
||||
if (count > 0) {
|
||||
await db.delete(containerEvents)
|
||||
.where(sql`timestamp < ${cutoffDate}`);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4082,9 +4099,15 @@ export async function getRecentExecutionsForSchedule(
|
||||
|
||||
export async function cleanupOldExecutions(retentionDays: number): Promise<number> {
|
||||
const cutoffDate = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString();
|
||||
const result = await db.delete(scheduleExecutions)
|
||||
const countResult = await db.select({ count: sql<number>`count(*)` })
|
||||
.from(scheduleExecutions)
|
||||
.where(sql`triggered_at < ${cutoffDate}`);
|
||||
return 0; // SQLite/PG don't return count consistently
|
||||
const count = Number(countResult[0]?.count ?? 0);
|
||||
if (count > 0) {
|
||||
await db.delete(scheduleExecutions)
|
||||
.where(sql`triggered_at < ${cutoffDate}`);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// Settings helpers for retention
|
||||
|
||||
@@ -324,6 +324,7 @@ export const gitStacks = sqliteTable('git_stacks', {
|
||||
lastCommit: text('last_commit'),
|
||||
syncStatus: text('sync_status').default('pending'),
|
||||
syncError: text('sync_error'),
|
||||
syncedFiles: text('synced_files'), // JSON manifest { relativePath: sha256hex } of files written on last sync
|
||||
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
|
||||
}, (table) => ({
|
||||
|
||||
@@ -327,6 +327,7 @@ export const gitStacks = pgTable('git_stacks', {
|
||||
lastCommit: text('last_commit'),
|
||||
syncStatus: text('sync_status').default('pending'),
|
||||
syncError: text('sync_error'),
|
||||
syncedFiles: text('synced_files'), // JSON manifest { relativePath: sha256hex } of files written on last sync
|
||||
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
|
||||
}, (table) => ({
|
||||
|
||||
+157
-17
@@ -15,6 +15,7 @@ import { createHash } from 'node:crypto';
|
||||
import type { Environment } from './db';
|
||||
import { getStackEnvVarsAsRecord } from './db';
|
||||
import { getAdditionalVolumeBinds } from './mount-dedupe';
|
||||
import { encodeRegistryAuth } from './registry-auth';
|
||||
import { isSystemContainer } from './scheduler/tasks/update-utils';
|
||||
import { deepDiff } from '../utils/diff.js';
|
||||
|
||||
@@ -286,7 +287,7 @@ const envCache = new Map<number, CachedEnv>();
|
||||
const CACHE_TTL = 30 * 60 * 1000;
|
||||
|
||||
// All known Docker Hub hostname variations for credential matching
|
||||
const DOCKER_HUB_HOSTS = new Set([
|
||||
export const DOCKER_HUB_HOSTS = new Set([
|
||||
'docker.io', 'hub.docker.com', 'registry.hub.docker.com',
|
||||
'index.docker.io', 'registry-1.docker.io', 'registry.docker.io', 'docker.com'
|
||||
]);
|
||||
@@ -1215,6 +1216,61 @@ export async function renameContainer(id: string, newName: string, envId?: numbe
|
||||
await assertDockerResponse(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* In-place container property update. Wraps Docker's POST /containers/{id}/update,
|
||||
* which is the only API that changes container properties WITHOUT recreating it.
|
||||
*
|
||||
* The accepted body field set is fixed by Docker — anything outside this set
|
||||
* requires container recreation (image, env, ports, networks, mounts, etc).
|
||||
* Whitelist enforced here so callers can't accidentally smuggle a
|
||||
* recreate-only field through this code path.
|
||||
*/
|
||||
export const IN_PLACE_UPDATE_FIELDS = [
|
||||
// Restart policy — the headline use case (#1153)
|
||||
'RestartPolicy',
|
||||
// CPU
|
||||
'CpuShares', 'CpuPeriod', 'CpuQuota', 'CpuRealtimePeriod', 'CpuRealtimeRuntime',
|
||||
'CpusetCpus', 'CpusetMems', 'NanoCpus',
|
||||
// Memory
|
||||
'Memory', 'MemorySwap', 'MemoryReservation', 'MemorySwappiness', 'KernelMemory',
|
||||
// Block I/O
|
||||
'BlkioWeight', 'BlkioWeightDevice',
|
||||
'BlkioDeviceReadBps', 'BlkioDeviceWriteBps',
|
||||
'BlkioDeviceReadIOps', 'BlkioDeviceWriteIOps',
|
||||
// Misc
|
||||
'PidsLimit'
|
||||
] as const;
|
||||
export type InPlaceUpdateField = typeof IN_PLACE_UPDATE_FIELDS[number];
|
||||
|
||||
export interface UpdateContainerRuntimeResult {
|
||||
Warnings: string[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an in-place update. `updates` keys MUST be from IN_PLACE_UPDATE_FIELDS;
|
||||
* unknown keys are silently dropped (not passed to Docker) so a malicious or
|
||||
* confused caller can't sneak a recreate-only field through.
|
||||
*/
|
||||
export async function updateContainerRuntime(
|
||||
id: string,
|
||||
updates: Partial<Record<InPlaceUpdateField, unknown>>,
|
||||
envId?: number | null
|
||||
): Promise<UpdateContainerRuntimeResult> {
|
||||
const allowed = new Set<string>(IN_PLACE_UPDATE_FIELDS);
|
||||
const body: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (allowed.has(key) && value !== undefined) body[key] = value;
|
||||
}
|
||||
if (Object.keys(body).length === 0) {
|
||||
throw new Error('No updatable fields provided');
|
||||
}
|
||||
return dockerJsonRequest<UpdateContainerRuntimeResult>(
|
||||
`/containers/${id}/update`,
|
||||
{ method: 'POST', body: JSON.stringify(body) },
|
||||
envId
|
||||
);
|
||||
}
|
||||
|
||||
export async function getContainerLogs(id: string, tail: number | 'all' = 100, envId?: number | null, since?: string, until?: string): Promise<string> {
|
||||
// Check if container has TTY enabled
|
||||
const info = await inspectContainer(id, envId);
|
||||
@@ -2553,6 +2609,28 @@ export async function listImages(envId?: number | null): Promise<ImageInfo[]> {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic log for registry auth headers (#1105).
|
||||
* Logs lengths and boundary char codes — never the plaintext credentials.
|
||||
* Header prefix is safe to log: it encodes the start of `{"username":"...`.
|
||||
*/
|
||||
function logAuthDiagnostics(
|
||||
tag: string,
|
||||
registry: string,
|
||||
serveraddress: string,
|
||||
username: string,
|
||||
password: string,
|
||||
authHeader: string
|
||||
): void {
|
||||
const userLast = username.length ? username.charCodeAt(username.length - 1).toString(16) : 'na';
|
||||
const pwLast = password.length ? password.charCodeAt(password.length - 1).toString(16) : 'na';
|
||||
console.log(
|
||||
`${tag} auth: registry=${registry} user(len=${username.length},last=0x${userLast}) ` +
|
||||
`pw(len=${password.length},last=0x${pwLast}) serveraddress=${serveraddress} ` +
|
||||
`authHeader(len=${authHeader.length},prefix=${authHeader.slice(0, 16)})`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build X-Registry-Auth header for authenticated Docker image pulls.
|
||||
* Looks up stored registry credentials and returns a headers object
|
||||
@@ -2566,16 +2644,19 @@ export async function buildRegistryAuthHeader(imageName: string): Promise<Record
|
||||
if (creds) {
|
||||
// Docker Engine requires 'https://index.docker.io/v1/' as serveraddress
|
||||
// for Docker Hub auth — just the hostname is treated as unauthenticated
|
||||
const serveraddress = DOCKER_HUB_HOSTS.has(registry)
|
||||
? 'https://index.docker.io/v1/'
|
||||
: registry;
|
||||
console.log(`[Pull] Using credentials for ${serveraddress} (user: ${creds.username})`);
|
||||
const isHub = DOCKER_HUB_HOSTS.has(registry);
|
||||
const serveraddress = isHub ? 'https://index.docker.io/v1/' : registry;
|
||||
if (isHub) {
|
||||
console.log(`[Registry] docker-hub variant '${registry}' canonicalized to https://index.docker.io/v1/ for auth`);
|
||||
}
|
||||
const authConfig = {
|
||||
username: creds.username,
|
||||
password: creds.password,
|
||||
serveraddress
|
||||
};
|
||||
headers['X-Registry-Auth'] = Buffer.from(JSON.stringify(authConfig)).toString('base64');
|
||||
const authHeader = encodeRegistryAuth(authConfig);
|
||||
headers['X-Registry-Auth'] = authHeader;
|
||||
logAuthDiagnostics('[Pull]', registry, serveraddress, creds.username, creds.password, authHeader);
|
||||
} else {
|
||||
console.log(`[Pull] No credentials found for ${registry}`);
|
||||
}
|
||||
@@ -2618,11 +2699,19 @@ export async function pullImage(imageName: string, onProgress?: (data: any) => v
|
||||
// Look up registry credentials for authenticated pulls
|
||||
const headers = await buildRegistryAuthHeader(imageName);
|
||||
|
||||
// Diagnostic logging (#1105): what we're sending to the daemon
|
||||
console.log(`[Pull] POST ${url} headers=${Object.keys(headers).join(',') || '(none)'}`);
|
||||
|
||||
// Use streaming: true for longer timeout on edge environments
|
||||
const response = await dockerFetch(url, { method: 'POST', streaming: true, headers }, envId);
|
||||
|
||||
// Diagnostic logging (#1105): daemon response status
|
||||
console.log(`[Pull] response status=${response.status} ${response.statusText}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to pull image: ${await response.text()}`);
|
||||
const body = await response.text();
|
||||
console.error(`[Pull] error body: ${body}`);
|
||||
throw new Error(`Failed to pull image: ${body}`);
|
||||
}
|
||||
|
||||
// Stream the response for progress updates
|
||||
@@ -2644,6 +2733,9 @@ export async function pullImage(imageName: string, onProgress?: (data: any) => v
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
if (data.error || data.errorDetail) {
|
||||
console.error(`[Pull] stream error: ${line}`);
|
||||
}
|
||||
if (onProgress) onProgress(data);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
@@ -2780,7 +2872,10 @@ async function findRegistryCredentials(registryHost: string): Promise<{ username
|
||||
if (stored.fullRegistry === requested.fullRegistry ||
|
||||
(stored.host === requested.host && !stored.path)) {
|
||||
if (reg.username && reg.password) {
|
||||
return { username: reg.username, password: reg.password };
|
||||
const via = stored.fullRegistry === requested.fullRegistry ? 'full' : 'host-only';
|
||||
console.log(`[Registry] matched stored=${reg.url} requested=${registryHost} via=${via}`);
|
||||
// Normalize legacy creds saved before #1105 trim fix
|
||||
return { username: reg.username.trim(), password: reg.password.trim() };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2792,12 +2887,20 @@ async function findRegistryCredentials(registryHost: string): Promise<{ username
|
||||
const stored = parseRegistryUrl(reg.url);
|
||||
if (DOCKER_HUB_HOSTS.has(stored.host)) {
|
||||
if (reg.username && reg.password) {
|
||||
return { username: reg.username, password: reg.password };
|
||||
console.log(`[Registry] matched stored=${reg.url} requested=${registryHost} via=hub-alias`);
|
||||
return { username: reg.username.trim(), password: reg.password.trim() };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No match — log what we tried so support cases are diagnosable
|
||||
const candidates = registries.map(r => parseRegistryUrl(r.url).host).join(', ');
|
||||
console.log(
|
||||
`[Registry] no match for requested=${registryHost} ` +
|
||||
`(hub-alias=${DOCKER_HUB_HOSTS.has(requested.host)}); ` +
|
||||
`candidates=[${candidates || 'none configured'}]`
|
||||
);
|
||||
return null;
|
||||
} catch (e) {
|
||||
const errorMsg = e instanceof Error ? e.message : String(e);
|
||||
@@ -2875,10 +2978,14 @@ async function getRegistryBearerToken(registry: string, repo: string): Promise<s
|
||||
const service = serviceMatch ? serviceMatch[1] : '';
|
||||
const scope = `repository:${repo}:pull`;
|
||||
|
||||
// Step 3: Request token from realm (with credentials if available)
|
||||
// Step 3: Request token from realm (with credentials if available).
|
||||
// Empty scope is allowed — means "no specific resource permission".
|
||||
// Useful for credential validation: some registries (Docker Hub)
|
||||
// reject privileged scopes like registry:catalog:* even for valid
|
||||
// users, so omitting scope is the only reliable login check.
|
||||
const tokenUrl = new URL(realm);
|
||||
if (service) tokenUrl.searchParams.set('service', service);
|
||||
tokenUrl.searchParams.set('scope', scope);
|
||||
if (scope) tokenUrl.searchParams.set('scope', scope);
|
||||
|
||||
const tokenHeaders: Record<string, string> = { 'User-Agent': 'Dockhand/1.0' };
|
||||
|
||||
@@ -2992,10 +3099,14 @@ export async function getRegistryAuthHeader(
|
||||
const realm = realmMatch[1];
|
||||
const service = serviceMatch ? serviceMatch[1] : '';
|
||||
|
||||
// Step 3: Request token from realm (with credentials if available)
|
||||
// Step 3: Request token from realm (with credentials if available).
|
||||
// Empty scope is allowed — means "no specific resource permission".
|
||||
// Useful for credential validation: some registries (Docker Hub)
|
||||
// reject privileged scopes like registry:catalog:* even for valid
|
||||
// users, so omitting scope is the only reliable login check.
|
||||
const tokenUrl = new URL(realm);
|
||||
if (service) tokenUrl.searchParams.set('service', service);
|
||||
tokenUrl.searchParams.set('scope', scope);
|
||||
if (scope) tokenUrl.searchParams.set('scope', scope);
|
||||
|
||||
const tokenHeaders: Record<string, string> = { 'User-Agent': 'Dockhand/1.0' };
|
||||
|
||||
@@ -3140,6 +3251,17 @@ export async function checkImageUpdateAvailable(
|
||||
};
|
||||
}
|
||||
|
||||
// Skip update check for images tagged against a localhost registry.
|
||||
// These never resolve from inside Dockhand's container and only produce
|
||||
// ECONNREFUSED noise (see #1083).
|
||||
if (/^localhost(:\d+)?\//.test(imageName)) {
|
||||
return {
|
||||
hasUpdate: false,
|
||||
isLocalImage: true,
|
||||
currentDigest: currentImageId
|
||||
};
|
||||
}
|
||||
|
||||
// Get current image info to get RepoDigests
|
||||
let currentImageInfo: any;
|
||||
try {
|
||||
@@ -3470,7 +3592,11 @@ export async function listVolumes(envId?: number | null): Promise<VolumeInfo[]>
|
||||
scope: volume.Scope,
|
||||
created: volume.CreatedAt,
|
||||
labels: volume.Labels || {},
|
||||
usedBy: volumeUsageMap.get(volume.Name) || []
|
||||
usedBy: volumeUsageMap.get(volume.Name) || [],
|
||||
// Surface driver_opts (e.g. NFS/CIFS type+device+o) so the UI can
|
||||
// distinguish network-backed volumes from plain local ones. Docker's
|
||||
// /volumes endpoint already includes this — we were dropping it.
|
||||
options: volume.Options && Object.keys(volume.Options).length > 0 ? volume.Options : undefined
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -4458,11 +4584,21 @@ export async function pushImage(
|
||||
// Parse tag to get registry info
|
||||
const [repo, tag = 'latest'] = imageTag.split(':');
|
||||
|
||||
// Create X-Registry-Auth header
|
||||
const authHeader = Buffer.from(JSON.stringify(authConfig)).toString('base64');
|
||||
const authHeader = encodeRegistryAuth(authConfig);
|
||||
logAuthDiagnostics(
|
||||
'[Push]',
|
||||
authConfig.serveraddress,
|
||||
authConfig.serveraddress,
|
||||
authConfig.username ?? '',
|
||||
authConfig.password ?? '',
|
||||
authHeader
|
||||
);
|
||||
|
||||
const pushUrl = `/images/${encodeURIComponent(imageTag)}/push`;
|
||||
console.log(`[Push] POST ${pushUrl} headers=X-Registry-Auth`);
|
||||
|
||||
const response = await dockerFetch(
|
||||
`/images/${encodeURIComponent(imageTag)}/push`,
|
||||
pushUrl,
|
||||
{
|
||||
method: 'POST',
|
||||
streaming: true,
|
||||
@@ -4473,8 +4609,11 @@ export async function pushImage(
|
||||
envId
|
||||
);
|
||||
|
||||
console.log(`[Push] response status=${response.status} ${response.statusText}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
console.error(`[Push] error body: ${error}`);
|
||||
throw new Error(`Failed to push image: ${error}`);
|
||||
}
|
||||
|
||||
@@ -4498,6 +4637,7 @@ export async function pushImage(
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
if (data.error) {
|
||||
console.error(`[Push] stream error: ${line}`);
|
||||
throw new Error(data.error);
|
||||
}
|
||||
if (onProgress) onProgress(data);
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* Git stack deletion sync (#966, #1162).
|
||||
*
|
||||
* Propagates upstream file deletions to the stack deploy directory using the
|
||||
* per-stack manifest: a file is deleted ONLY when the manifest of files
|
||||
* Dockhand wrote on the previous sync lists it, the new clone no longer
|
||||
* contains it, AND the bytes on disk still match what Dockhand wrote
|
||||
* (nobody modified it locally).
|
||||
*
|
||||
* Every failure mode degrades to "delete less" — never to user-data loss:
|
||||
* - user-created files (volume data) → never in the manifest → untouchable
|
||||
* - locally modified files → hash mismatch → skip
|
||||
* - first sync after upgrade / fresh DB → empty manifest → nothing to delete
|
||||
* - broken clone walk (empty / compose missing) → deletionSafetyCheck blocks
|
||||
* ALL deletions for that sync (guards against mass-deleting managed files
|
||||
* due to a Dockhand bug; those files are repo-restorable anyway)
|
||||
*
|
||||
* History rewrites are irrelevant by design: deletion converges the deploy
|
||||
* dir toward the clone state, regardless of how the commits got there.
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readdirSync, readFileSync, unlinkSync, rmdirSync, lstatSync } from 'node:fs';
|
||||
import { join, resolve, sep, dirname, basename, isAbsolute } from 'node:path';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export type DeletionSkipReason =
|
||||
| 'locally-modified' // disk bytes differ from what Dockhand wrote
|
||||
| 'load-bearing' // compose/.env files are never auto-deleted
|
||||
| 'invalid-path' // absolute or escaping the stack directory
|
||||
| 'already-absent' // nothing to do (benign)
|
||||
| 'agent-no-support' // Hawser agent too old to apply deletions
|
||||
| 'apply-failed'; // unexpected error during unlink
|
||||
|
||||
export interface FileToDelete {
|
||||
path: string; // relative to the stack deploy dir, '/' separators
|
||||
hash: string; // sha256 hex of the content Dockhand wrote
|
||||
}
|
||||
|
||||
export interface DeletionSkip {
|
||||
path: string;
|
||||
reason: DeletionSkipReason;
|
||||
}
|
||||
|
||||
export interface DeletionPlan {
|
||||
toDelete: FileToDelete[];
|
||||
skipped: DeletionSkip[];
|
||||
}
|
||||
|
||||
export interface DeletionApplyResult {
|
||||
deleted: string[];
|
||||
skipped: DeletionSkip[];
|
||||
}
|
||||
|
||||
/** Manifest of files Dockhand wrote on the last successful sync. */
|
||||
export interface SyncManifest {
|
||||
/** Full commit hash the manifest files were taken from. Null = legacy/bootstrap. */
|
||||
commit: string | null;
|
||||
/** relative path → sha256 hex of written content */
|
||||
files: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface SyncFileChange {
|
||||
file: string;
|
||||
status: 'added' | 'updated' | 'removed' | 'skipped';
|
||||
reason?: string; // human-readable, only for skipped
|
||||
}
|
||||
|
||||
export interface SyncChangeSummary {
|
||||
changes: SyncFileChange[];
|
||||
unchangedCount: number;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Constants
|
||||
// =============================================================================
|
||||
|
||||
/** Files that are never auto-deleted, regardless of what the sources say. */
|
||||
export const LOAD_BEARING_FILES = new Set([
|
||||
'docker-compose.yml',
|
||||
'docker-compose.yaml',
|
||||
'compose.yml',
|
||||
'compose.yaml',
|
||||
'.env'
|
||||
]);
|
||||
|
||||
// NOTE: deletion skips are FINAL by design. A deletion is attempted exactly
|
||||
// once — at the sync where the file first disappears from the clone. Any
|
||||
// skip (old agent, hash mismatch, apply error) is logged and the file simply
|
||||
// stays on disk as unmanaged residue. There is deliberately no
|
||||
// carry-forward/retry state: it would require tracking per-file retry status
|
||||
// indefinitely (e.g. waiting for an agent upgrade that may never happen).
|
||||
// Worst case is always "a stale file survives" — visible in the logs,
|
||||
// recoverable manually. With an old Hawser agent the behavior is identical
|
||||
// to before this feature existed: nothing is ever deleted remotely.
|
||||
|
||||
/** Human-readable explanation for each skip reason (shown in logs and activity). */
|
||||
export function skipReasonMessage(reason: DeletionSkipReason): string {
|
||||
switch (reason) {
|
||||
case 'locally-modified':
|
||||
return 'deleted from the repository, but the file was modified on this machine since Dockhand deployed it — refusing to delete local changes';
|
||||
case 'load-bearing':
|
||||
return 'core stack file — never auto-deleted';
|
||||
case 'invalid-path':
|
||||
return 'invalid path outside the stack directory — ignored';
|
||||
case 'already-absent':
|
||||
return 'already absent';
|
||||
case 'agent-no-support':
|
||||
return 'the Hawser agent does not support file deletion sync — file left on the remote host (upgrade the agent to enable cleanup of future deletions)';
|
||||
case 'apply-failed':
|
||||
return 'could not be deleted — leaving the file in place';
|
||||
default:
|
||||
// Unknown reason (e.g., from a newer agent)
|
||||
return 'could not be deleted — leaving the file in place';
|
||||
}
|
||||
}
|
||||
|
||||
const KNOWN_SKIP_REASONS: ReadonlySet<string> = new Set<DeletionSkipReason>([
|
||||
'locally-modified',
|
||||
'load-bearing',
|
||||
'invalid-path',
|
||||
'already-absent',
|
||||
'agent-no-support',
|
||||
'apply-failed'
|
||||
]);
|
||||
|
||||
/** Normalize a reason string from an external source (Hawser agent). */
|
||||
export function normalizeSkipReason(reason: string): DeletionSkipReason {
|
||||
return (KNOWN_SKIP_REASONS.has(reason) ? reason : 'apply-failed') as DeletionSkipReason;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Manifest (de)serialization
|
||||
// =============================================================================
|
||||
|
||||
export function parseManifest(raw: string | null | undefined): SyncManifest {
|
||||
if (!raw) return { commit: null, files: {} };
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object' && typeof parsed.files === 'object' && parsed.files !== null) {
|
||||
const files: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(parsed.files)) {
|
||||
if (typeof v === 'string') files[k] = v;
|
||||
}
|
||||
return { commit: typeof parsed.commit === 'string' ? parsed.commit : null, files };
|
||||
}
|
||||
} catch {
|
||||
// Corrupt manifest → behave like a fresh bootstrap (fail closed: no deletions)
|
||||
}
|
||||
return { commit: null, files: {} };
|
||||
}
|
||||
|
||||
export function serializeManifest(manifest: SyncManifest): string {
|
||||
return JSON.stringify(manifest);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Hashing
|
||||
// =============================================================================
|
||||
|
||||
export function hashContent(content: Buffer | string): string {
|
||||
return createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a directory and hash every regular file (raw bytes).
|
||||
* Returns { relativePath: sha256hex } with '/' separators.
|
||||
* Skips .git directories (mirrors the cpSync filter used by the deploy copy).
|
||||
*/
|
||||
export function hashDirFiles(dir: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
const root = resolve(dir);
|
||||
|
||||
const walk = (current: string, relPrefix: string) => {
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(current, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.name === '.git') continue;
|
||||
const abs = join(current, entry.name);
|
||||
const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
walk(abs, rel);
|
||||
} else if (entry.isFile()) {
|
||||
try {
|
||||
result[rel] = hashContent(readFileSync(abs));
|
||||
} catch {
|
||||
// Unreadable file: leave out of the manifest → never a deletion candidate
|
||||
}
|
||||
}
|
||||
// Symlinks and other special entries are intentionally excluded:
|
||||
// Dockhand only writes regular files, so only regular files are managed.
|
||||
}
|
||||
};
|
||||
|
||||
walk(root, '');
|
||||
return result;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Path safety
|
||||
// =============================================================================
|
||||
|
||||
/** A relative path is safe when it cannot escape the stack directory. */
|
||||
export function isSafeRelPath(p: string): boolean {
|
||||
if (!p || isAbsolute(p) || p.includes('\\')) return false;
|
||||
const segments = p.split('/');
|
||||
return segments.every((s) => s !== '' && s !== '.' && s !== '..');
|
||||
}
|
||||
|
||||
/** Resolve relPath inside root; returns null when it would escape root. */
|
||||
function containedPath(root: string, relPath: string): string | null {
|
||||
if (!isSafeRelPath(relPath)) return null;
|
||||
const abs = resolve(root, relPath);
|
||||
if (abs !== root && abs.startsWith(root + sep)) return abs;
|
||||
return null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Core: manifest vs clone
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Sanity guard run BEFORE computing any deletions: when the new-clone walk
|
||||
* looks broken (no files at all, or the compose file itself is missing from
|
||||
* the walk even though it was just read from that tree), every manifest
|
||||
* entry would become a deletion candidate — a Dockhand bug, not a repo
|
||||
* change. Returns a human-readable reason to skip ALL deletions this sync,
|
||||
* or null when it is safe to proceed.
|
||||
*/
|
||||
export function deletionSafetyCheck(
|
||||
manifestFiles: Record<string, string>,
|
||||
newFiles: Record<string, string>,
|
||||
composeFileName: string | undefined
|
||||
): string | null {
|
||||
if (Object.keys(manifestFiles).length === 0) return null; // nothing to delete anyway
|
||||
|
||||
if (Object.keys(newFiles).length === 0) {
|
||||
return 'the new clone appears empty — skipping all deletions this sync (likely a sync problem, not repository changes)';
|
||||
}
|
||||
if (composeFileName && !(composeFileName in newFiles)) {
|
||||
return `the compose file "${composeFileName}" is missing from the new clone walk — skipping all deletions this sync (likely a sync problem, not repository changes)`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the deletion plan: manifest entries that are absent from the new
|
||||
* clone. The hash recorded in the manifest travels with each entry — the
|
||||
* applier deletes only files whose disk bytes still match it.
|
||||
*
|
||||
* @param manifestFiles files Dockhand wrote on the last sync (path → hash)
|
||||
* @param newFiles files in the new clone that will be written (path → hash)
|
||||
*/
|
||||
export function computeDeletions(
|
||||
manifestFiles: Record<string, string>,
|
||||
newFiles: Record<string, string>
|
||||
): DeletionPlan {
|
||||
const toDelete: FileToDelete[] = [];
|
||||
const skipped: DeletionSkip[] = [];
|
||||
|
||||
for (const [path, hash] of Object.entries(manifestFiles)) {
|
||||
if (path in newFiles) continue; // still present in the repo
|
||||
|
||||
if (!isSafeRelPath(path)) {
|
||||
skipped.push({ path, reason: 'invalid-path' });
|
||||
continue;
|
||||
}
|
||||
if (LOAD_BEARING_FILES.has(basename(path))) {
|
||||
skipped.push({ path, reason: 'load-bearing' });
|
||||
continue;
|
||||
}
|
||||
toDelete.push({ path, hash });
|
||||
}
|
||||
|
||||
return { toDelete, skipped };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Applier — the single chokepoint that touches the filesystem
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Apply a deletion list inside a stack directory.
|
||||
*
|
||||
* Structurally incapable of touching anything outside stackDir:
|
||||
* every path is containment-checked, only regular files whose content still
|
||||
* matches the recorded hash are unlinked, and directory cleanup uses rmdir
|
||||
* (never recursive) so directories holding any other content survive.
|
||||
*/
|
||||
export function applyFileDeletions(stackDir: string, files: FileToDelete[]): DeletionApplyResult {
|
||||
const root = resolve(stackDir);
|
||||
const deleted: string[] = [];
|
||||
const skipped: DeletionSkip[] = [];
|
||||
const parentDirs = new Set<string>();
|
||||
|
||||
for (const { path, hash } of files) {
|
||||
const abs = containedPath(root, path);
|
||||
if (!abs) {
|
||||
skipped.push({ path, reason: 'invalid-path' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Defense in depth: computeDeletions already filters these, but the
|
||||
// applier also runs on lists from external sources (Hawser payloads).
|
||||
if (LOAD_BEARING_FILES.has(basename(path))) {
|
||||
skipped.push({ path, reason: 'load-bearing' });
|
||||
continue;
|
||||
}
|
||||
|
||||
let stat;
|
||||
try {
|
||||
stat = lstatSync(abs);
|
||||
} catch {
|
||||
skipped.push({ path, reason: 'already-absent' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Dockhand only writes regular files. Anything else (symlink, dir,
|
||||
// socket) means the user replaced it — treat as locally modified.
|
||||
if (!stat.isFile()) {
|
||||
skipped.push({ path, reason: 'locally-modified' });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (hashContent(readFileSync(abs)) !== hash) {
|
||||
skipped.push({ path, reason: 'locally-modified' });
|
||||
continue;
|
||||
}
|
||||
unlinkSync(abs);
|
||||
deleted.push(path);
|
||||
} catch {
|
||||
skipped.push({ path, reason: 'apply-failed' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect parent dir chain (inside root) for empty-dir cleanup
|
||||
let dir = dirname(abs);
|
||||
while (dir !== root && dir.startsWith(root + sep)) {
|
||||
parentDirs.add(dir);
|
||||
dir = dirname(dir);
|
||||
}
|
||||
}
|
||||
|
||||
// Deepest-first rmdir; fails harmlessly when a directory still has content
|
||||
const dirsByDepth = [...parentDirs].sort((a, b) => b.length - a.length);
|
||||
for (const dir of dirsByDepth) {
|
||||
try {
|
||||
rmdirSync(dir);
|
||||
} catch {
|
||||
// ENOTEMPTY/ENOENT/etc. — directory stays, which is always safe
|
||||
}
|
||||
}
|
||||
|
||||
return { deleted, skipped };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Manifest evolution
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Build the manifest to persist after a sync.
|
||||
*
|
||||
* Trivial by design: the manifest is always exactly the files written this
|
||||
* sync, at this sync's commit. Skipped deletions are FINAL (see note above) —
|
||||
* the affected files drop out of the manifest and become unmanaged residue.
|
||||
*/
|
||||
export function buildNextManifest(newCommit: string, newFiles: Record<string, string>): SyncManifest {
|
||||
return { commit: newCommit, files: { ...newFiles } };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Sync summary (per-file status table)
|
||||
// =============================================================================
|
||||
|
||||
export function buildSyncChangeSummary(
|
||||
previousFiles: Record<string, string>,
|
||||
newFiles: Record<string, string>,
|
||||
applyResult: DeletionApplyResult,
|
||||
planSkipped: DeletionSkip[]
|
||||
): SyncChangeSummary {
|
||||
const changes: SyncFileChange[] = [];
|
||||
let unchangedCount = 0;
|
||||
|
||||
for (const [path, hash] of Object.entries(newFiles)) {
|
||||
const oldHash = previousFiles[path];
|
||||
if (oldHash === undefined) {
|
||||
changes.push({ file: path, status: 'added' });
|
||||
} else if (oldHash !== hash) {
|
||||
changes.push({ file: path, status: 'updated' });
|
||||
} else {
|
||||
unchangedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
for (const path of applyResult.deleted) {
|
||||
changes.push({ file: path, status: 'removed' });
|
||||
}
|
||||
|
||||
// Benign "already absent" results are not interesting in the summary
|
||||
const interestingSkips = [...planSkipped, ...applyResult.skipped].filter(
|
||||
(s) => s.reason !== 'already-absent'
|
||||
);
|
||||
for (const skip of interestingSkips) {
|
||||
changes.push({ file: skip.path, status: 'skipped', reason: skipReasonMessage(skip.reason) });
|
||||
}
|
||||
|
||||
return { changes, unchangedCount };
|
||||
}
|
||||
|
||||
/** Render the summary as aligned text lines for console and job output. */
|
||||
export function formatChangeTable(summary: SyncChangeSummary): string[] {
|
||||
const { changes, unchangedCount } = summary;
|
||||
const counts = { added: 0, updated: 0, removed: 0, skipped: 0 };
|
||||
for (const c of changes) counts[c.status]++;
|
||||
|
||||
const header = `${counts.added} added, ${counts.updated} updated, ${counts.removed} removed, ${counts.skipped} skipped, ${unchangedCount} unchanged`;
|
||||
if (changes.length === 0) {
|
||||
return [header];
|
||||
}
|
||||
|
||||
const fileWidth = Math.min(60, Math.max(4, ...changes.map((c) => c.file.length)));
|
||||
const lines = [header, `${'STATUS'.padEnd(9)} ${'FILE'.padEnd(fileWidth)} REASON`];
|
||||
for (const c of changes) {
|
||||
lines.push(`${c.status.padEnd(9)} ${c.file.padEnd(fileWidth)} ${c.reason ?? ''}`.trimEnd());
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
+167
-3
@@ -15,6 +15,21 @@ import {
|
||||
type GitStackWithRepo
|
||||
} from './db';
|
||||
import { deployStack, getStackDir } from './stacks';
|
||||
import {
|
||||
parseManifest,
|
||||
serializeManifest,
|
||||
hashDirFiles,
|
||||
computeDeletions,
|
||||
buildNextManifest,
|
||||
buildSyncChangeSummary,
|
||||
formatChangeTable,
|
||||
skipReasonMessage,
|
||||
deletionSafetyCheck,
|
||||
type DeletionPlan,
|
||||
type DeletionApplyResult,
|
||||
type DeletionSkip,
|
||||
type SyncManifest
|
||||
} from './git-deletions';
|
||||
|
||||
const MERGED_CA_BUNDLE_PATH = '/tmp/dockhand-merged-ca-bundle.crt';
|
||||
let mergedCaBundleReady = false;
|
||||
@@ -363,6 +378,93 @@ async function getChangedFilesInDir(
|
||||
return { changed: changedFiles.length > 0, files: changedFiles };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the deletion plan for a sync: hash the new clone's compose dir and
|
||||
* diff against the manifest from the last sync. Deletions converge the deploy
|
||||
* dir toward the clone state; the applier additionally verifies each file's
|
||||
* disk hash. A sanity guard blocks ALL deletions when the clone walk looks
|
||||
* broken (empty, or missing the compose file).
|
||||
*/
|
||||
async function computeSyncDeletionPlan(options: {
|
||||
logPrefix: string;
|
||||
composeDir: string; // absolute path inside the clone
|
||||
composeFileName: string | undefined; // compose file relative to composeDir
|
||||
rawManifest: string | null | undefined;
|
||||
}): Promise<{ plan: DeletionPlan; newFiles: Record<string, string>; previousManifest: SyncManifest }> {
|
||||
const { logPrefix, composeDir, composeFileName, rawManifest } = options;
|
||||
|
||||
const previousManifest = parseManifest(rawManifest);
|
||||
const newFiles = hashDirFiles(composeDir);
|
||||
|
||||
const manifestSize = Object.keys(previousManifest.files).length;
|
||||
console.log(`${logPrefix} Deletion sync: manifest has ${manifestSize} file(s)${manifestSize === 0 ? ' (first sync — nothing will be deleted)' : ''}`);
|
||||
|
||||
// First sync / legacy manifest: nothing was recorded, so nothing can be deleted
|
||||
if (manifestSize === 0) {
|
||||
return { plan: { toDelete: [], skipped: [] }, newFiles, previousManifest };
|
||||
}
|
||||
|
||||
const blocked = deletionSafetyCheck(previousManifest.files, newFiles, composeFileName);
|
||||
if (blocked) {
|
||||
console.warn(`${logPrefix} Deletion sync: ${blocked}`);
|
||||
return { plan: { toDelete: [], skipped: [] }, newFiles, previousManifest };
|
||||
}
|
||||
|
||||
const plan = computeDeletions(previousManifest.files, newFiles);
|
||||
|
||||
for (const file of plan.toDelete) {
|
||||
console.log(`${logPrefix} Deletion sync: will remove "${file.path}" — deleted from the repository`);
|
||||
}
|
||||
for (const skip of plan.skipped) {
|
||||
console.warn(`${logPrefix} Deletion sync: keeping "${skip.path}" — ${skipReasonMessage(skip.reason)}`);
|
||||
}
|
||||
|
||||
return { plan, newFiles, previousManifest };
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the manifest after a deploy and log the per-file change summary.
|
||||
* Called only after a successful deploy (locally applied or agent-confirmed).
|
||||
*/
|
||||
async function finalizeDeletionSync(options: {
|
||||
stackId: number;
|
||||
logPrefix: string;
|
||||
previousManifest: SyncManifest;
|
||||
newCommitFull: string;
|
||||
newFiles: Record<string, string>;
|
||||
plan: DeletionPlan;
|
||||
applyResult: DeletionApplyResult | undefined;
|
||||
onLine?: (line: string) => void; // extra sink (job output)
|
||||
}): Promise<void> {
|
||||
const { stackId, logPrefix, previousManifest, newCommitFull, newFiles, plan, applyResult, onLine } = options;
|
||||
|
||||
// No apply result means deletions were requested but nothing reported back
|
||||
// (defensive — executors always return one). Logged as skips; skips are final.
|
||||
const effectiveApply: DeletionApplyResult = applyResult ?? {
|
||||
deleted: [],
|
||||
skipped: plan.toDelete.map((f): DeletionSkip => ({ path: f.path, reason: 'apply-failed' }))
|
||||
};
|
||||
|
||||
// Pass only the plan-stage skips; buildSyncChangeSummary already merges
|
||||
// in effectiveApply.skipped itself. Concatenating here duplicated every
|
||||
// apply-stage skip (locally-modified, agent-no-support, apply-failed).
|
||||
const summary = buildSyncChangeSummary(previousManifest.files, newFiles, effectiveApply, plan.skipped);
|
||||
const tableLines = formatChangeTable(summary);
|
||||
|
||||
console.log(`${logPrefix} Sync file changes: ${tableLines[0]}`);
|
||||
for (const line of tableLines.slice(1)) {
|
||||
console.log(`${logPrefix} ${line}`);
|
||||
}
|
||||
if (onLine) {
|
||||
onLine(`File changes: ${tableLines[0]}`);
|
||||
for (const line of tableLines.slice(1)) onLine(line);
|
||||
}
|
||||
|
||||
const nextManifest = buildNextManifest(newCommitFull, newFiles);
|
||||
await updateGitStack(stackId, { syncedFiles: serializeManifest(nextManifest) });
|
||||
console.log(`${logPrefix} Manifest persisted: ${Object.keys(nextManifest.files).length} file(s) at commit ${nextManifest.commit?.substring(0, 7)}`);
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
success: boolean;
|
||||
commit?: string;
|
||||
@@ -375,6 +477,11 @@ export interface SyncResult {
|
||||
error?: string;
|
||||
updated?: boolean;
|
||||
changedFiles?: string[]; // List of files that changed (for logging/debugging)
|
||||
// Deletion sync (#966/#1162): manifest-vs-clone data
|
||||
deletionPlan?: DeletionPlan; // Files safe to delete (manifest entries absent from the new clone) + plan-stage skips
|
||||
newFiles?: Record<string, string>; // path → sha256 of files in the new clone (next manifest)
|
||||
newCommitFull?: string; // Full 40-char commit hash (manifest commit)
|
||||
previousManifest?: SyncManifest; // Manifest from the last successful sync
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
@@ -954,6 +1061,14 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
|
||||
console.log(`${logPrefix} No env file path configured`);
|
||||
}
|
||||
|
||||
// Deletion sync (#966): manifest-vs-clone deletion plan
|
||||
const deletionData = await computeSyncDeletionPlan({
|
||||
logPrefix,
|
||||
composeDir,
|
||||
composeFileName,
|
||||
rawManifest: gitStack.syncedFiles
|
||||
});
|
||||
|
||||
// Update git stack status
|
||||
await updateGitStack(stackId, {
|
||||
syncStatus: 'synced',
|
||||
@@ -982,7 +1097,11 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
|
||||
envFileVars,
|
||||
envFileName,
|
||||
updated,
|
||||
changedFiles
|
||||
changedFiles,
|
||||
deletionPlan: deletionData.plan,
|
||||
newFiles: deletionData.newFiles,
|
||||
newCommitFull: newCommit,
|
||||
previousManifest: deletionData.previousManifest
|
||||
};
|
||||
} catch (error: any) {
|
||||
cleanupSshKey(credential);
|
||||
@@ -1065,7 +1184,8 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
|
||||
forceRecreate,
|
||||
build: gitStack.buildOnDeploy,
|
||||
noBuildCache: gitStack.noBuildCache,
|
||||
pullPolicy: gitStack.repullImages ? 'always' : undefined
|
||||
pullPolicy: gitStack.repullImages ? 'always' : undefined,
|
||||
filesToDelete: syncResult.deletionPlan?.toDelete
|
||||
});
|
||||
|
||||
console.log(`${logPrefix} ----------------------------------------`);
|
||||
@@ -1076,6 +1196,19 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
|
||||
if (result.error) console.log(`${logPrefix} Error:`, result.error);
|
||||
|
||||
if (result.success) {
|
||||
// Deletion sync: persist manifest + log per-file change summary
|
||||
if (syncResult.previousManifest && syncResult.newFiles && syncResult.newCommitFull && syncResult.deletionPlan) {
|
||||
await finalizeDeletionSync({
|
||||
stackId,
|
||||
logPrefix,
|
||||
previousManifest: syncResult.previousManifest,
|
||||
newCommitFull: syncResult.newCommitFull,
|
||||
newFiles: syncResult.newFiles,
|
||||
plan: syncResult.deletionPlan,
|
||||
applyResult: result.deletion
|
||||
});
|
||||
}
|
||||
|
||||
// Record the stack source with resolved compose path for consistency
|
||||
const stackDir = await getStackDir(gitStack.stackName, gitStack.environmentId);
|
||||
const resolvedComposePath = syncResult.composeFileName
|
||||
@@ -1306,6 +1439,15 @@ export async function deployGitStackWithProgress(
|
||||
}
|
||||
}
|
||||
|
||||
// Deletion sync (#966): manifest-vs-clone deletion plan
|
||||
const logPrefix = `[Stack:${gitStack.stackName}]`;
|
||||
const deletionData = await computeSyncDeletionPlan({
|
||||
logPrefix,
|
||||
composeDir,
|
||||
composeFileName: progressComposeFileName,
|
||||
rawManifest: gitStack.syncedFiles
|
||||
});
|
||||
|
||||
// Update git stack status
|
||||
await updateGitStack(stackId, {
|
||||
syncStatus: 'synced',
|
||||
@@ -1319,6 +1461,14 @@ export async function deployGitStackWithProgress(
|
||||
// Step 5: Deploying stack
|
||||
// Uses `docker compose up -d --remove-orphans` which only recreates changed services
|
||||
onProgress({ status: 'deploying', message: `Deploying ${gitStack.stackName}...`, step: 5, totalSteps });
|
||||
if (deletionData.plan.toDelete.length > 0) {
|
||||
onProgress({
|
||||
status: 'deploying',
|
||||
message: `Removing ${deletionData.plan.toDelete.length} file(s) deleted from the repository...`,
|
||||
step: 5,
|
||||
totalSteps
|
||||
});
|
||||
}
|
||||
|
||||
// Determine env filename relative to compose dir (same logic as syncGitStack)
|
||||
let envFileName: string | undefined;
|
||||
@@ -1338,10 +1488,24 @@ export async function deployGitStackWithProgress(
|
||||
envFileName, // Env file relative to compose dir (for --env-file flag, optional)
|
||||
build: gitStack.buildOnDeploy,
|
||||
noBuildCache: gitStack.noBuildCache,
|
||||
pullPolicy: gitStack.repullImages ? 'always' : undefined
|
||||
pullPolicy: gitStack.repullImages ? 'always' : undefined,
|
||||
filesToDelete: deletionData.plan.toDelete
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// Deletion sync: persist manifest + log per-file change summary.
|
||||
// onLine feeds the per-file change table into the deploy progress popover.
|
||||
await finalizeDeletionSync({
|
||||
stackId,
|
||||
logPrefix,
|
||||
previousManifest: deletionData.previousManifest,
|
||||
newCommitFull: newCommit,
|
||||
newFiles: deletionData.newFiles,
|
||||
plan: deletionData.plan,
|
||||
applyResult: result.deletion,
|
||||
onLine: (line) => onProgress({ status: 'deploying', message: line, step: 5, totalSteps })
|
||||
});
|
||||
|
||||
// Record the stack source with resolved compose path for consistency
|
||||
const stackDir = await getStackDir(gitStack.stackName, gitStack.environmentId);
|
||||
const resolvedComposePath = join(stackDir, progressComposeFileName);
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
import { db, hawserTokens, environments, eq, and } from './db/drizzle.js';
|
||||
import { logContainerEvent, type ContainerEventAction } from './db.js';
|
||||
import { containerEventEmitter } from './event-collector.js';
|
||||
import { sendEnvironmentNotification } from './notifications.js';
|
||||
import { sendEnvironmentNotification } from './notifications/index.js';
|
||||
import { isNotifyDisabledByLabel } from './container-labels.js';
|
||||
import { isHealthTransition } from './subprocess-manager.js';
|
||||
import { pushMetric } from './metrics-store.js';
|
||||
import { secureGetRandomValues, secureRandomUUID } from './crypto-fallback.js';
|
||||
import { hashPassword, verifyPassword } from './auth.js';
|
||||
@@ -178,6 +179,12 @@ export async function handleEdgeContainerEvent(
|
||||
// Log the event
|
||||
console.log(`[Hawser] Container event from env ${environmentId}: ${event.action} ${event.containerName || event.containerId}`);
|
||||
|
||||
// Only store health_status events on transitions (healthy↔unhealthy)
|
||||
// to avoid flooding the DB with repeated identical health checks
|
||||
if (!isHealthTransition(environmentId, event.containerId, event.action)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Save to database
|
||||
const savedEvent = await logContainerEvent({
|
||||
environmentId,
|
||||
|
||||
@@ -1,822 +0,0 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import {
|
||||
getEnabledNotificationSettings,
|
||||
getEnabledEnvironmentNotifications,
|
||||
getEnvironment,
|
||||
type NotificationSettingData,
|
||||
type SmtpConfig,
|
||||
type AppriseConfig,
|
||||
type NotificationEventType
|
||||
} from './db';
|
||||
|
||||
import { escapeTelegramMarkdown, parseTelegramUrl, buildGotifyUrl, parseWorkflowsUrl, buildWorkflowsHttpUrl } from '$lib/utils/notification-parsers';
|
||||
|
||||
/** Drain a response body to release the underlying socket/TLS connection. */
|
||||
async function drainResponse(response: Response): Promise<void> {
|
||||
if (!response.bodyUsed) {
|
||||
try { await response.arrayBuffer(); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export interface NotificationPayload {
|
||||
title: string;
|
||||
message: string;
|
||||
type?: 'info' | 'success' | 'warning' | 'error';
|
||||
environmentId?: number;
|
||||
environmentName?: string;
|
||||
}
|
||||
|
||||
// Result type for functions that can return detailed errors
|
||||
export interface NotificationResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Send notification via SMTP
|
||||
async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
auth: config.username ? {
|
||||
user: config.username,
|
||||
pass: config.password
|
||||
} : undefined,
|
||||
tls: config.skipTlsVerify ? {
|
||||
rejectUnauthorized: false
|
||||
} : undefined
|
||||
});
|
||||
|
||||
const envBadge = payload.environmentName
|
||||
? `<span style="display: inline-block; background: #3b82f6; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin-left: 8px;">${payload.environmentName}</span>`
|
||||
: '';
|
||||
const envText = payload.environmentName ? ` [${payload.environmentName}]` : '';
|
||||
|
||||
const html = `
|
||||
<div style="font-family: sans-serif; padding: 20px;">
|
||||
<h2 style="margin: 0 0 10px 0;">${payload.title}${envBadge}</h2>
|
||||
<p style="margin: 0; white-space: pre-wrap;">${payload.message}</p>
|
||||
<hr style="margin: 20px 0; border: none; border-top: 1px solid #eee;">
|
||||
<p style="margin: 0; font-size: 12px; color: #666;">Sent by Dockhand</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
await transporter.sendMail({
|
||||
from: config.from_name ? `"${config.from_name}" <${config.from_email}>` : config.from_email,
|
||||
to: config.to_emails.join(', '),
|
||||
subject: `[Dockhand]${envText} ${payload.title}`,
|
||||
text: `${payload.title}${envText}\n\n${payload.message}`,
|
||||
html
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return { success: false, error: `SMTP error: ${errorMsg}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Parse Apprise URL and send notification
|
||||
async function sendAppriseNotification(config: AppriseConfig, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const url of config.urls) {
|
||||
try {
|
||||
const result = await sendToAppriseUrl(url, payload);
|
||||
if (!result.success && result.error) {
|
||||
errors.push(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
errors.push(`Failed to send: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return { success: false, error: errors.join('; ') };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Send to a single Apprise URL
|
||||
async function sendToAppriseUrl(url: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
try {
|
||||
// Extract protocol from Apprise URL format (protocol://...)
|
||||
// Note: Can't use new URL() because custom schemes like 'tgram://' are not valid URLs
|
||||
const protocolMatch = url.match(/^([a-z]+):\/\//i);
|
||||
if (!protocolMatch) {
|
||||
return { success: false, error: 'Invalid Apprise URL format - missing protocol' };
|
||||
}
|
||||
const protocol = protocolMatch[1].toLowerCase();
|
||||
|
||||
// Handle different notification services
|
||||
switch (protocol) {
|
||||
case 'discord':
|
||||
case 'discords':
|
||||
return await sendDiscord(url, payload);
|
||||
case 'slack':
|
||||
case 'slacks':
|
||||
return await sendSlack(url, payload);
|
||||
case 'mmost':
|
||||
case 'mmosts':
|
||||
return await sendMattermost(url, payload);
|
||||
case 'tgram':
|
||||
return await sendTelegram(url, payload);
|
||||
case 'gotify':
|
||||
case 'gotifys':
|
||||
return await sendGotify(url, payload);
|
||||
case 'ntfy':
|
||||
case 'ntfys':
|
||||
return await sendNtfy(url, payload);
|
||||
case 'bark':
|
||||
case 'barks':
|
||||
return await sendBark(url, payload);
|
||||
case 'pushover':
|
||||
return await sendPushover(url, payload);
|
||||
case 'json':
|
||||
case 'jsons':
|
||||
return await sendGenericWebhook(url, payload);
|
||||
case 'workflows':
|
||||
return await sendWorkflows(url, payload);
|
||||
default:
|
||||
return { success: false, error: `Unsupported Apprise protocol: ${protocol}` };
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return { success: false, error: `Failed to parse Apprise URL: ${errorMsg}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Discord webhook
|
||||
async function sendDiscord(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// discord://webhook_id/webhook_token or discords://...
|
||||
const url = appriseUrl.replace(/^discords?:\/\//, 'https://discord.com/api/webhooks/');
|
||||
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
embeds: [{
|
||||
title: titleWithEnv,
|
||||
description: payload.message,
|
||||
color: payload.type === 'error' ? 0xff0000 : payload.type === 'warning' ? 0xffaa00 : payload.type === 'success' ? 0x00ff00 : 0x0099ff,
|
||||
...(payload.environmentName && {
|
||||
footer: { text: `Environment: ${payload.environmentName}` }
|
||||
})
|
||||
}]
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Discord error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Discord connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Slack webhook
|
||||
async function sendSlack(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// slack://token_a/token_b/token_c or webhook URL
|
||||
let url: string;
|
||||
if (appriseUrl.includes('hooks.slack.com')) {
|
||||
url = appriseUrl.replace(/^slacks?:\/\//, 'https://');
|
||||
} else {
|
||||
const parts = appriseUrl.replace(/^slacks?:\/\//, '').split('/');
|
||||
url = `https://hooks.slack.com/services/${parts.join('/')}`;
|
||||
}
|
||||
|
||||
const envTag = payload.environmentName ? ` \`${payload.environmentName}\`` : '';
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: `*${payload.title}*${envTag}\n${payload.message}`
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Slack error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Slack connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Mattermost webhook
|
||||
async function sendMattermost(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// mmost://[botname@]hostname[:port][/path]/token or mmosts://...
|
||||
const isSecure = appriseUrl.startsWith('mmosts');
|
||||
const protocol = isSecure ? 'https' : 'http';
|
||||
|
||||
// Remove the scheme
|
||||
let urlPart = appriseUrl.replace(/^mmosts?:\/\//, '');
|
||||
|
||||
// Check for botname (username@hostname format)
|
||||
let username: string | undefined;
|
||||
const atIndex = urlPart.indexOf('@');
|
||||
if (atIndex !== -1) {
|
||||
username = urlPart.substring(0, atIndex);
|
||||
urlPart = urlPart.substring(atIndex + 1);
|
||||
}
|
||||
|
||||
// The token is the last segment, everything else is hostname[:port][/path]
|
||||
const lastSlashIndex = urlPart.lastIndexOf('/');
|
||||
if (lastSlashIndex === -1) {
|
||||
return { success: false, error: 'Invalid Mattermost URL format. Expected: mmost://[botname@]hostname[:port][/path]/token' };
|
||||
}
|
||||
|
||||
const token = urlPart.substring(lastSlashIndex + 1);
|
||||
const hostAndPath = urlPart.substring(0, lastSlashIndex);
|
||||
|
||||
// Build the webhook URL: {protocol}://{hostname}[:{port}][/{path}]/hooks/{token}
|
||||
const url = `${protocol}://${hostAndPath}/hooks/${token}`;
|
||||
|
||||
const envTag = payload.environmentName ? ` \`${payload.environmentName}\`` : '';
|
||||
const body: Record<string, string> = {
|
||||
text: `*${payload.title}*${envTag}\n${payload.message}`
|
||||
};
|
||||
|
||||
if (username) {
|
||||
body.username = username;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Mattermost error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Mattermost connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Telegram
|
||||
async function sendTelegram(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
const parsed = parseTelegramUrl(appriseUrl);
|
||||
if (!parsed) {
|
||||
return { success: false, error: 'Invalid Telegram URL format. Expected: tgram://bot_token/chat_id or tgram://bot_token/chat_id:topic_id' };
|
||||
}
|
||||
|
||||
const { botToken, chatId, topicId } = parsed;
|
||||
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
|
||||
|
||||
// Escape markdown special characters in title and message
|
||||
const escapedTitle = escapeTelegramMarkdown(payload.title);
|
||||
const escapedMessage = escapeTelegramMarkdown(payload.message);
|
||||
const envTag = payload.environmentName ? ` [${escapeTelegramMarkdown(payload.environmentName)}]` : '';
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text: `*${escapedTitle}*${envTag}\n${escapedMessage}`,
|
||||
...(topicId ? { message_thread_id: topicId } : {}),
|
||||
parse_mode: 'Markdown',
|
||||
link_preview_options: {
|
||||
is_disabled: true
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({})) as { description?: string };
|
||||
const errorMsg = errorData.description || response.statusText;
|
||||
return { success: false, error: `Telegram error ${response.status}: ${errorMsg}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Telegram connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Gotify
|
||||
async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
const parsed = buildGotifyUrl(appriseUrl);
|
||||
if (!parsed) {
|
||||
return { success: false, error: 'Invalid Gotify URL format. Expected: gotify://hostname/token' };
|
||||
}
|
||||
|
||||
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
|
||||
const defaultPriority = payload.type === 'error' ? 8 : payload.type === 'warning' ? 5 : 2;
|
||||
|
||||
try {
|
||||
const response = await fetch(parsed.url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: titleWithEnv,
|
||||
message: payload.message,
|
||||
priority: parsed.priority ?? defaultPriority
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Gotify error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Gotify connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
// ntfy
|
||||
async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// Supported formats:
|
||||
// ntfy://topic (public ntfy.sh)
|
||||
// ntfy://host/topic (custom server, no auth)
|
||||
// ntfy://user:pass@host/topic (custom server with basic auth)
|
||||
// ntfy://token@host/topic (custom server with bearer token)
|
||||
// ntfy://host/topic?auth=BASE64 (custom server with base64-encoded bearer token)
|
||||
// Query params: ?tags=ship,whale &title=Custom &priority=5
|
||||
// ntfys:// variants for HTTPS
|
||||
const isSecure = appriseUrl.startsWith('ntfys');
|
||||
const path = appriseUrl.replace(/^ntfys?:\/\//, '');
|
||||
|
||||
let url: string;
|
||||
let authHeader: string | null = null;
|
||||
|
||||
// Extract query parameters (?auth=, ?tags=, ?title=, ?priority=)
|
||||
let queryAuth: string | null = null;
|
||||
let queryTags: string | null = null;
|
||||
let queryTitle: string | null = null;
|
||||
let queryPriority: string | null = null;
|
||||
let cleanPath = path;
|
||||
const qIndex = path.indexOf('?');
|
||||
if (qIndex !== -1) {
|
||||
const params = new URLSearchParams(path.substring(qIndex + 1));
|
||||
queryAuth = params.get('auth');
|
||||
queryTags = params.get('tags');
|
||||
queryTitle = params.get('title');
|
||||
queryPriority = params.get('priority');
|
||||
cleanPath = path.substring(0, qIndex);
|
||||
}
|
||||
|
||||
// Check for user:pass@host/topic format (Basic auth)
|
||||
const basicMatch = cleanPath.match(/^([^:]+):([^@]+)@(.+)$/);
|
||||
if (basicMatch) {
|
||||
const [, user, pass, hostAndTopic] = basicMatch;
|
||||
const basic = Buffer.from(`${user}:${pass}`).toString('base64');
|
||||
authHeader = `Basic ${basic}`;
|
||||
url = `${isSecure ? 'https' : 'http'}://${hostAndTopic}`;
|
||||
} else if (cleanPath.includes('@') && cleanPath.includes('/')) {
|
||||
// token@host/topic -> Bearer token auth
|
||||
const tokenMatch = cleanPath.match(/^([^@]+)@(.+)$/);
|
||||
if (tokenMatch) {
|
||||
const [, token, hostAndTopic] = tokenMatch;
|
||||
authHeader = `Bearer ${token}`;
|
||||
url = `${isSecure ? 'https' : 'http'}://${hostAndTopic}`;
|
||||
} else {
|
||||
// Fallback to custom server without auth
|
||||
url = `${isSecure ? 'https' : 'http'}://${cleanPath}`;
|
||||
}
|
||||
} else if (cleanPath.includes('/')) {
|
||||
// Custom server without auth
|
||||
url = `${isSecure ? 'https' : 'http'}://${cleanPath}`;
|
||||
} else {
|
||||
// Default ntfy.sh
|
||||
url = `https://ntfy.sh/${cleanPath}`;
|
||||
}
|
||||
|
||||
// Apply ?auth= as fallback if no explicit auth was set
|
||||
if (!authHeader && queryAuth) {
|
||||
const decoded = Buffer.from(queryAuth, 'base64').toString();
|
||||
authHeader = decoded.startsWith('Bearer ') ? decoded : `Bearer ${decoded}`;
|
||||
}
|
||||
|
||||
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
|
||||
const defaultTags = payload.type || 'info';
|
||||
const headers: Record<string, string> = {
|
||||
'Title': queryTitle || titleWithEnv,
|
||||
'Priority': queryPriority || (payload.type === 'error' ? '5' : payload.type === 'warning' ? '4' : '3'),
|
||||
'Tags': queryTags ? `${queryTags},${defaultTags}` : defaultTags
|
||||
};
|
||||
|
||||
if (authHeader) {
|
||||
headers['Authorization'] = authHeader;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: payload.message
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `ntfy error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `ntfy connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Bark
|
||||
async function sendBark(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// Supported formats:
|
||||
// bark://device_key (official api.day.app server)
|
||||
// bark://host/device_key (custom server over HTTP)
|
||||
// barks://host/device_key (custom server over HTTPS)
|
||||
const isSecure = appriseUrl.startsWith('barks');
|
||||
const path = appriseUrl.replace(/^barks?:\/\//, '');
|
||||
|
||||
let url: string;
|
||||
let deviceKey: string;
|
||||
|
||||
if (!path.includes('/')) {
|
||||
if (!path) {
|
||||
return { success: false, error: 'Invalid Bark URL format. Expected: bark://device_key, bark://host/device_key, or barks://host/device_key' };
|
||||
}
|
||||
url = 'https://api.day.app/push';
|
||||
deviceKey = path;
|
||||
} else {
|
||||
const parts = path.split('/');
|
||||
if (parts.length !== 2) {
|
||||
return { success: false, error: 'Invalid Bark URL format. Expected: bark://device_key, bark://host/device_key, or barks://host/device_key' };
|
||||
}
|
||||
const [host, key] = parts;
|
||||
deviceKey = key;
|
||||
if (!host || !deviceKey) {
|
||||
return { success: false, error: 'Invalid Bark URL format. Expected: bark://device_key, bark://host/device_key, or barks://host/device_key' };
|
||||
}
|
||||
url = `${isSecure ? 'https' : 'http'}://${host}/push`;
|
||||
}
|
||||
|
||||
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
|
||||
const body: Record<string, string> = {
|
||||
device_key: deviceKey,
|
||||
title: titleWithEnv,
|
||||
body: payload.message
|
||||
};
|
||||
|
||||
if (payload.type === 'error') {
|
||||
body.level = 'timeSensitive';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Bark error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Bark connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Pushover
|
||||
async function sendPushover(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// pushover://user_key/api_token
|
||||
const match = appriseUrl.match(/^pushover:\/\/([^/]+)\/(.+)/);
|
||||
if (!match) {
|
||||
return { success: false, error: 'Invalid Pushover URL format. Expected: pushover://user_key/api_token' };
|
||||
}
|
||||
|
||||
const [, userKey, apiToken] = match;
|
||||
const url = 'https://api.pushover.net/1/messages.json';
|
||||
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: apiToken,
|
||||
user: userKey,
|
||||
title: titleWithEnv,
|
||||
message: payload.message,
|
||||
priority: payload.type === 'error' ? 1 : 0
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Pushover error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Pushover connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Generic JSON webhook
|
||||
async function sendGenericWebhook(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// json://hostname/path or jsons://hostname/path
|
||||
const url = appriseUrl.replace(/^jsons?:\/\//, appriseUrl.startsWith('jsons') ? 'https://' : 'http://');
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: payload.title,
|
||||
message: payload.message,
|
||||
type: payload.type || 'info',
|
||||
environment: payload.environmentName || null,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Webhook error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Webhook connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
// Microsoft Power Automate Workflows, for e.g. Microsoft Teams
|
||||
async function sendWorkflows(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
const parsed = parseWorkflowsUrl(appriseUrl);
|
||||
if (!parsed) {
|
||||
return { success: false, error: 'Invalid Workflows URL format. Expected: workflows://hostname/workflow/signature' };
|
||||
}
|
||||
|
||||
const url = buildWorkflowsHttpUrl(parsed.hostname, parsed.workflow, parsed.signature);
|
||||
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'message',
|
||||
attachments: [
|
||||
{
|
||||
contentType: 'application/vnd.microsoft.card.adaptive',
|
||||
content: {
|
||||
$schema: 'https://adaptivecards.io/schemas/adaptive-card.json',
|
||||
type: 'AdaptiveCard',
|
||||
version: '1.2',
|
||||
body: [
|
||||
{
|
||||
type: 'TextBlock',
|
||||
style: 'heading',
|
||||
wrap: true,
|
||||
text: titleWithEnv
|
||||
},
|
||||
{
|
||||
type: 'TextBlock',
|
||||
style: 'default',
|
||||
wrap: true,
|
||||
text: payload.message
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Workflows error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Workflows connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Send notification to all enabled channels
|
||||
export async function sendNotification(payload: NotificationPayload): Promise<{ success: boolean; results: { name: string; success: boolean }[] }> {
|
||||
const settings = await getEnabledNotificationSettings();
|
||||
const results: { name: string; success: boolean }[] = [];
|
||||
|
||||
for (const setting of settings) {
|
||||
let result: NotificationResult = { success: false };
|
||||
|
||||
if (setting.type === 'smtp') {
|
||||
result = await sendSmtpNotification(setting.config as SmtpConfig, payload);
|
||||
} else if (setting.type === 'apprise') {
|
||||
result = await sendAppriseNotification(setting.config as AppriseConfig, payload);
|
||||
}
|
||||
|
||||
results.push({ name: setting.name, success: result.success });
|
||||
}
|
||||
|
||||
return {
|
||||
success: results.every(r => r.success),
|
||||
results
|
||||
};
|
||||
}
|
||||
|
||||
// Test a specific notification setting
|
||||
export async function testNotification(setting: NotificationSettingData): Promise<NotificationResult> {
|
||||
const payload: NotificationPayload = {
|
||||
title: 'Dockhand Test Notification',
|
||||
message: 'This is a test notification from Dockhand. If you receive this, your notification settings are configured correctly.',
|
||||
type: 'info'
|
||||
};
|
||||
|
||||
if (setting.type === 'smtp') {
|
||||
return await sendSmtpNotification(setting.config as SmtpConfig, payload);
|
||||
} else if (setting.type === 'apprise') {
|
||||
return await sendAppriseNotification(setting.config as AppriseConfig, payload);
|
||||
}
|
||||
|
||||
return { success: false, error: 'Unknown notification type' };
|
||||
}
|
||||
|
||||
// Map Docker action to notification event type
|
||||
function mapActionToEventType(action: string): NotificationEventType | null {
|
||||
const mapping: Record<string, NotificationEventType> = {
|
||||
'start': 'container_started',
|
||||
'stop': 'container_stopped',
|
||||
'restart': 'container_restarted',
|
||||
'die': 'container_exited',
|
||||
'kill': 'container_exited',
|
||||
'oom': 'container_oom',
|
||||
'health_status: unhealthy': 'container_unhealthy',
|
||||
'health_status: healthy': 'container_healthy',
|
||||
'pull': 'image_pulled'
|
||||
};
|
||||
return mapping[action] || null;
|
||||
}
|
||||
|
||||
// Scanner image patterns to exclude from notifications
|
||||
const SCANNER_IMAGE_PATTERNS = [
|
||||
'anchore/grype',
|
||||
'aquasec/trivy',
|
||||
'ghcr.io/anchore/grype',
|
||||
'ghcr.io/aquasecurity/trivy'
|
||||
];
|
||||
|
||||
function isScannerContainer(image: string | null | undefined): boolean {
|
||||
if (!image) return false;
|
||||
const lowerImage = image.toLowerCase();
|
||||
return SCANNER_IMAGE_PATTERNS.some(pattern => lowerImage.includes(pattern.toLowerCase()));
|
||||
}
|
||||
|
||||
// Send notification for an environment-specific event
|
||||
export async function sendEnvironmentNotification(
|
||||
environmentId: number,
|
||||
action: string,
|
||||
payload: Omit<NotificationPayload, 'environmentId' | 'environmentName'>,
|
||||
image?: string | null
|
||||
): Promise<{ success: boolean; sent: number }> {
|
||||
const eventType = mapActionToEventType(action);
|
||||
if (!eventType) {
|
||||
// Not a notifiable event type
|
||||
return { success: true, sent: 0 };
|
||||
}
|
||||
|
||||
// Get environment name
|
||||
const env = await getEnvironment(environmentId);
|
||||
if (!env) {
|
||||
return { success: false, sent: 0 };
|
||||
}
|
||||
|
||||
// Get enabled notification channels for this environment and event type
|
||||
const envNotifications = await getEnabledEnvironmentNotifications(environmentId, eventType);
|
||||
if (envNotifications.length === 0) {
|
||||
return { success: true, sent: 0 };
|
||||
}
|
||||
|
||||
const enrichedPayload: NotificationPayload = {
|
||||
...payload,
|
||||
environmentId,
|
||||
environmentName: env.name
|
||||
};
|
||||
|
||||
// Check if this is a scanner container
|
||||
const isScanner = isScannerContainer(image);
|
||||
|
||||
let sent = 0;
|
||||
let allSuccess = true;
|
||||
|
||||
// Skip all notifications for scanner containers (Trivy, Grype)
|
||||
if (isScanner) {
|
||||
return { success: true, sent: 0 };
|
||||
}
|
||||
|
||||
for (const notif of envNotifications) {
|
||||
try {
|
||||
let result: NotificationResult = { success: false };
|
||||
if (notif.channelType === 'smtp') {
|
||||
result = await sendSmtpNotification(notif.config as SmtpConfig, enrichedPayload);
|
||||
} else if (notif.channelType === 'apprise') {
|
||||
result = await sendAppriseNotification(notif.config as AppriseConfig, enrichedPayload);
|
||||
}
|
||||
if (result.success) sent++;
|
||||
else allSuccess = false;
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[Notifications] Failed to send to channel ${notif.channelName}:`, errorMsg);
|
||||
allSuccess = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { success: allSuccess, sent };
|
||||
}
|
||||
|
||||
// Send notification for a specific event type (not mapped from Docker action)
|
||||
// Used for auto-update, git sync, vulnerability, and system events
|
||||
export async function sendEventNotification(
|
||||
eventType: NotificationEventType,
|
||||
payload: NotificationPayload,
|
||||
environmentId?: number
|
||||
): Promise<{ success: boolean; sent: number }> {
|
||||
// Get environment name if provided
|
||||
let enrichedPayload = { ...payload };
|
||||
if (environmentId) {
|
||||
const env = await getEnvironment(environmentId);
|
||||
if (env) {
|
||||
enrichedPayload.environmentId = environmentId;
|
||||
enrichedPayload.environmentName = env.name;
|
||||
}
|
||||
}
|
||||
|
||||
// Get enabled notification channels for this event type
|
||||
let channels: Array<{
|
||||
channel_type: 'smtp' | 'apprise';
|
||||
channel_name: string;
|
||||
config: SmtpConfig | AppriseConfig;
|
||||
}> = [];
|
||||
|
||||
if (environmentId) {
|
||||
// Environment-specific: get channels subscribed to this env and event type
|
||||
const envNotifications = await getEnabledEnvironmentNotifications(environmentId, eventType);
|
||||
channels = envNotifications
|
||||
.filter(n => n.channelType && n.channelName)
|
||||
.map(n => ({
|
||||
channel_type: n.channelType!,
|
||||
channel_name: n.channelName!,
|
||||
config: n.config
|
||||
}));
|
||||
} else {
|
||||
// System-wide: get all globally enabled channels that subscribe to this event type
|
||||
const globalSettings = await getEnabledNotificationSettings();
|
||||
channels = globalSettings
|
||||
.filter(s => s.eventTypes?.includes(eventType))
|
||||
.map(s => ({
|
||||
channel_type: s.type,
|
||||
channel_name: s.name,
|
||||
config: s.config
|
||||
}));
|
||||
}
|
||||
|
||||
if (channels.length === 0) {
|
||||
return { success: true, sent: 0 };
|
||||
}
|
||||
|
||||
let sent = 0;
|
||||
let allSuccess = true;
|
||||
|
||||
for (const channel of channels) {
|
||||
try {
|
||||
let result: NotificationResult = { success: false };
|
||||
if (channel.channel_type === 'smtp') {
|
||||
result = await sendSmtpNotification(channel.config as SmtpConfig, enrichedPayload);
|
||||
} else if (channel.channel_type === 'apprise') {
|
||||
result = await sendAppriseNotification(channel.config as AppriseConfig, enrichedPayload);
|
||||
}
|
||||
if (result.success) sent++;
|
||||
else allSuccess = false;
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[Notifications] Failed to send to channel ${channel.channel_name}:`, errorMsg);
|
||||
allSuccess = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { success: allSuccess, sent };
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Apprise passthrough — POST to a self-hosted caronc/apprise-api server.
|
||||
*
|
||||
* Users configure all their providers (Signal, Matrix, MQTT, IFTTT, AWS SNS,
|
||||
* dozens more) in their own Apprise server; Dockhand just forwards each
|
||||
* notification once. The big win: every provider Apprise upstream supports
|
||||
* is now reachable from Dockhand without us having to write a sender for it.
|
||||
*
|
||||
* Supported formats:
|
||||
* apprise://host[:port]/key → HTTP, stateful (Apprise stored config key)
|
||||
* apprises://host[:port]/key → HTTPS variant
|
||||
* apprise://host[:port]/prefix/key → path-prefixed Apprise behind a reverse proxy
|
||||
* apprise://host[:port]/key?tag=devops → optional tag filter
|
||||
*
|
||||
* Setup docs: https://github.com/caronc/apprise-api
|
||||
*/
|
||||
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
|
||||
|
||||
export async function sendApprise(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
const isSecure = appriseUrl.startsWith('apprises');
|
||||
const raw = appriseUrl.replace(/^apprises?:\/\//, '');
|
||||
|
||||
let cleanPath = raw;
|
||||
let queryParams = new URLSearchParams();
|
||||
const qIndex = raw.indexOf('?');
|
||||
if (qIndex !== -1) {
|
||||
queryParams = new URLSearchParams(raw.substring(qIndex + 1));
|
||||
cleanPath = raw.substring(0, qIndex);
|
||||
}
|
||||
|
||||
const parts = cleanPath.split('/').filter(Boolean);
|
||||
if (parts.length < 2) {
|
||||
return { success: false, error: 'Invalid Apprise URL. Expected: apprise://host[:port]/key' };
|
||||
}
|
||||
const hostPort = parts[0];
|
||||
// The Apprise key is the last path segment. Anything between host and key
|
||||
// is a path prefix (some users mount Apprise behind a reverse proxy
|
||||
// at /apprise/ — we preserve that).
|
||||
const key = parts[parts.length - 1];
|
||||
const pathPrefix = parts.slice(1, -1).join('/');
|
||||
const baseUrl = `${isSecure ? 'https' : 'http'}://${hostPort}${pathPrefix ? '/' + pathPrefix : ''}`;
|
||||
|
||||
// Map our payload type to Apprise's NotifyType. 'error' → 'failure' is
|
||||
// the only rename; everything else lines up.
|
||||
const apprisesType = payload.type === 'error'
|
||||
? 'failure'
|
||||
: payload.type === 'warning'
|
||||
? 'warning'
|
||||
: payload.type === 'success'
|
||||
? 'success'
|
||||
: 'info';
|
||||
|
||||
const titleWithEnv = payload.environmentName
|
||||
? `${payload.title} [${payload.environmentName}]`
|
||||
: payload.title;
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
title: titleWithEnv,
|
||||
body: payload.message,
|
||||
type: apprisesType
|
||||
};
|
||||
const tag = queryParams.get('tag');
|
||||
if (tag) body.tag = tag;
|
||||
const format = queryParams.get('format');
|
||||
if (format) body.format = format; // text | markdown | html
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/notify/${encodeURIComponent(key)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
// Apprise-API uses specific status codes:
|
||||
// 200 → success, 204 → key not configured, 424 → at least one
|
||||
// downstream provider failed or tag didn't match.
|
||||
if (response.status === 204) {
|
||||
return { success: false, error: `Apprise: no configuration found for key "${key}"` };
|
||||
}
|
||||
if (response.status === 424) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Apprise: at least one downstream provider failed${text ? ` — ${text}` : ''}` };
|
||||
}
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Apprise error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Apprise connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Bark — iOS push via bark-server (https://github.com/Finb/bark-server).
|
||||
*
|
||||
* Supported formats:
|
||||
* bark://device_key → uses official api.day.app over HTTPS
|
||||
* bark://host/device_key → custom server over HTTP
|
||||
* bark://host[:port]/k1/k2/... → multi-device batch (Apprise convention)
|
||||
* barks://host[:port]/... → HTTPS variant
|
||||
*
|
||||
* Query params honored (per https://bark.day.app/#/en-us/tutorial):
|
||||
* ?sound=name, ?level=active|timeSensitive|critical|passive,
|
||||
* ?group=, ?icon=, ?url=, ?badge=N, ?copy=, ?subtitle=,
|
||||
* ?volume=, ?ttl=, ?call=1, ?autoCopy=1, ?isArchive=1, ?action=none
|
||||
*/
|
||||
import type { NotificationPayload, NotificationResult } from './shared';
|
||||
|
||||
export async function sendBark(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
const isSecure = appriseUrl.startsWith('barks');
|
||||
const path = appriseUrl.replace(/^barks?:\/\//, '');
|
||||
|
||||
// Split off query string before slicing the path so '?' in a device key
|
||||
// (in principle possible, though Bark's keys are 22-char base62) doesn't
|
||||
// confuse the parser.
|
||||
let cleanPath = path;
|
||||
let queryParams = new URLSearchParams();
|
||||
const qIndex = path.indexOf('?');
|
||||
if (qIndex !== -1) {
|
||||
queryParams = new URLSearchParams(path.substring(qIndex + 1));
|
||||
cleanPath = path.substring(0, qIndex);
|
||||
}
|
||||
|
||||
if (!cleanPath) {
|
||||
return { success: false, error: 'Invalid Bark URL format. Expected: bark://device_key, bark://host/device_key, or barks://host/device_key' };
|
||||
}
|
||||
|
||||
let baseUrl: string;
|
||||
let deviceKeys: string[];
|
||||
if (!cleanPath.includes('/')) {
|
||||
// bark://device_key → official server, HTTPS regardless of bark:// vs barks://
|
||||
baseUrl = 'https://api.day.app';
|
||||
deviceKeys = [cleanPath];
|
||||
} else {
|
||||
const parts = cleanPath.split('/').filter(Boolean);
|
||||
if (parts.length < 2) {
|
||||
return { success: false, error: 'Invalid Bark URL format. Expected: bark://device_key, bark://host/device_key, or barks://host/device_key' };
|
||||
}
|
||||
const hostPort = parts[0];
|
||||
deviceKeys = parts.slice(1);
|
||||
baseUrl = `${isSecure ? 'https' : 'http'}://${hostPort}`;
|
||||
}
|
||||
|
||||
// Map our payload type to Bark's `level`. Query-supplied level wins.
|
||||
// info → active (banner + sound, doesn't bypass DND)
|
||||
// warning → timeSensitive (cuts through Focus modes)
|
||||
// error → critical (cuts through silent mode; user must enable)
|
||||
const defaultLevel = payload.type === 'error'
|
||||
? 'critical'
|
||||
: payload.type === 'warning'
|
||||
? 'timeSensitive'
|
||||
: 'active';
|
||||
const level = queryParams.get('level') || defaultLevel;
|
||||
|
||||
const titleWithEnv = payload.environmentName
|
||||
? `${payload.title} [${payload.environmentName}]`
|
||||
: payload.title;
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
title: titleWithEnv,
|
||||
body: payload.message,
|
||||
level
|
||||
};
|
||||
// Single-target uses device_key; batch uses device_keys (per Bark API v2).
|
||||
if (deviceKeys.length === 1) {
|
||||
body.device_key = deviceKeys[0];
|
||||
} else {
|
||||
body.device_keys = deviceKeys;
|
||||
}
|
||||
|
||||
// String passthroughs Bark understands. Unknown params are dropped on the
|
||||
// server side anyway so no point forwarding them.
|
||||
const passthroughString = ['sound', 'group', 'icon', 'url', 'copy', 'subtitle', 'category', 'ciphertext', 'isArchive', 'autoCopy', 'call', 'action', 'volume'];
|
||||
for (const key of passthroughString) {
|
||||
const v = queryParams.get(key);
|
||||
if (v !== null && v !== '') body[key] = v;
|
||||
}
|
||||
// Numeric passthroughs.
|
||||
for (const key of ['badge', 'ttl']) {
|
||||
const v = queryParams.get(key);
|
||||
if (v !== null && v !== '') {
|
||||
const n = parseInt(v, 10);
|
||||
if (!Number.isNaN(n)) body[key] = n;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/push`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Bark error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
// Bark returns HTTP 200 with { code, message, timestamp } — `code !== 200`
|
||||
// signals a logical failure (e.g. invalid device key) that we'd otherwise
|
||||
// swallow as a success.
|
||||
const json: any = await response.json().catch(() => null);
|
||||
if (json && typeof json.code === 'number' && json.code !== 200) {
|
||||
return { success: false, error: `Bark error: ${json.message || `code ${json.code}`}` };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Bark connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/** Discord webhook notifications. discord:// or discords://. */
|
||||
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
|
||||
|
||||
export async function sendDiscord(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// discord://webhook_id/webhook_token or discords://...
|
||||
const url = appriseUrl.replace(/^discords?:\/\//, 'https://discord.com/api/webhooks/');
|
||||
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
embeds: [{
|
||||
title: titleWithEnv,
|
||||
description: payload.message,
|
||||
color: payload.type === 'error' ? 0xff0000 : payload.type === 'warning' ? 0xffaa00 : payload.type === 'success' ? 0x00ff00 : 0x0099ff,
|
||||
...(payload.environmentName && {
|
||||
footer: { text: `Environment: ${payload.environmentName}` }
|
||||
})
|
||||
}]
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Discord error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Discord connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Generic JSON webhook. json:// or jsons:// (HTTPS). */
|
||||
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
|
||||
|
||||
export async function sendGenericWebhook(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// json://hostname/path or jsons://hostname/path
|
||||
const url = appriseUrl.replace(/^jsons?:\/\//, appriseUrl.startsWith('jsons') ? 'https://' : 'http://');
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: payload.title,
|
||||
message: payload.message,
|
||||
type: payload.type || 'info',
|
||||
environment: payload.environmentName || null,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Webhook error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Webhook connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/** Gotify. gotify:// or gotifys:// (HTTPS). */
|
||||
import { buildGotifyUrl } from '$lib/utils/notification-parsers';
|
||||
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
|
||||
|
||||
export async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
const parsed = buildGotifyUrl(appriseUrl);
|
||||
if (!parsed) {
|
||||
return { success: false, error: 'Invalid Gotify URL format. Expected: gotify://hostname/token' };
|
||||
}
|
||||
|
||||
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
|
||||
const defaultPriority = payload.type === 'error' ? 8 : payload.type === 'warning' ? 5 : 2;
|
||||
|
||||
try {
|
||||
const response = await fetch(parsed.url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: titleWithEnv,
|
||||
message: payload.message,
|
||||
priority: parsed.priority ?? defaultPriority
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Gotify error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Gotify connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Notification router — picks the right per-provider sender based on the
|
||||
* channel type (SMTP / Apprise URL) and (for Apprise URLs) the URL scheme.
|
||||
*
|
||||
* Public surface used by API routes and the rest of the app:
|
||||
* - sendNotification (fan out to every enabled channel)
|
||||
* - testNotification (one channel, with a fixed test payload)
|
||||
* - sendEnvironmentNotification (Docker container event → matching channels)
|
||||
* - sendEventNotification (auto-update / git / vuln / system events)
|
||||
* - NotificationPayload, NotificationResult types
|
||||
*
|
||||
* Per-provider implementations live in sibling files (./bark, ./discord, …).
|
||||
* This file orchestrates only — it never knows what's inside a Bark or
|
||||
* Telegram URL.
|
||||
*/
|
||||
|
||||
import {
|
||||
getEnabledNotificationSettings,
|
||||
getEnabledEnvironmentNotifications,
|
||||
getEnvironment,
|
||||
type NotificationSettingData,
|
||||
type SmtpConfig,
|
||||
type AppriseConfig,
|
||||
type NotificationEventType
|
||||
} from '../db';
|
||||
|
||||
import type { NotificationPayload, NotificationResult } from './shared';
|
||||
export type { NotificationPayload, NotificationResult } from './shared';
|
||||
|
||||
import { sendSmtpNotification } from './smtp';
|
||||
import { sendDiscord } from './discord';
|
||||
import { sendSlack } from './slack';
|
||||
import { sendMattermost } from './mattermost';
|
||||
import { sendTelegram } from './telegram';
|
||||
import { sendGotify } from './gotify';
|
||||
import { sendNtfy } from './ntfy';
|
||||
import { sendBark } from './bark';
|
||||
import { sendSignal } from './signal';
|
||||
import { sendApprise } from './apprise';
|
||||
import { sendPushover } from './pushover';
|
||||
import { sendGenericWebhook } from './generic-webhook';
|
||||
import { sendWorkflows } from './workflows';
|
||||
|
||||
// Send to every URL in an Apprise channel. Errors are aggregated so a single
|
||||
// bad URL doesn't silently mask a healthy one.
|
||||
async function sendAppriseNotification(config: AppriseConfig, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const url of config.urls) {
|
||||
try {
|
||||
const result = await sendToAppriseUrl(url, payload);
|
||||
if (!result.success && result.error) {
|
||||
errors.push(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
errors.push(`Failed to send: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return { success: false, error: errors.join('; ') };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Route a single Apprise URL to the right sender. The switch is the ONLY
|
||||
// place that needs to grow when a new provider is added.
|
||||
async function sendToAppriseUrl(url: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
try {
|
||||
// Custom schemes like 'tgram://' aren't valid URLs to new URL(),
|
||||
// so we match the prefix directly.
|
||||
const protocolMatch = url.match(/^([a-z]+):\/\//i);
|
||||
if (!protocolMatch) {
|
||||
return { success: false, error: 'Invalid Apprise URL format - missing protocol' };
|
||||
}
|
||||
const protocol = protocolMatch[1].toLowerCase();
|
||||
|
||||
switch (protocol) {
|
||||
case 'discord':
|
||||
case 'discords':
|
||||
return await sendDiscord(url, payload);
|
||||
case 'slack':
|
||||
case 'slacks':
|
||||
return await sendSlack(url, payload);
|
||||
case 'mmost':
|
||||
case 'mmosts':
|
||||
return await sendMattermost(url, payload);
|
||||
case 'tgram':
|
||||
return await sendTelegram(url, payload);
|
||||
case 'gotify':
|
||||
case 'gotifys':
|
||||
return await sendGotify(url, payload);
|
||||
case 'ntfy':
|
||||
case 'ntfys':
|
||||
return await sendNtfy(url, payload);
|
||||
case 'bark':
|
||||
case 'barks':
|
||||
return await sendBark(url, payload);
|
||||
case 'signal':
|
||||
case 'signals':
|
||||
return await sendSignal(url, payload);
|
||||
case 'apprise':
|
||||
case 'apprises':
|
||||
return await sendApprise(url, payload);
|
||||
case 'pushover':
|
||||
return await sendPushover(url, payload);
|
||||
case 'json':
|
||||
case 'jsons':
|
||||
return await sendGenericWebhook(url, payload);
|
||||
case 'workflows':
|
||||
return await sendWorkflows(url, payload);
|
||||
default:
|
||||
return { success: false, error: `Unsupported Apprise protocol: ${protocol}` };
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return { success: false, error: `Failed to parse Apprise URL: ${errorMsg}` };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function sendNotification(payload: NotificationPayload): Promise<{ success: boolean; results: { name: string; success: boolean }[] }> {
|
||||
const settings = await getEnabledNotificationSettings();
|
||||
const results: { name: string; success: boolean }[] = [];
|
||||
|
||||
for (const setting of settings) {
|
||||
let result: NotificationResult = { success: false };
|
||||
|
||||
if (setting.type === 'smtp') {
|
||||
result = await sendSmtpNotification(setting.config as SmtpConfig, payload);
|
||||
} else if (setting.type === 'apprise') {
|
||||
result = await sendAppriseNotification(setting.config as AppriseConfig, payload);
|
||||
}
|
||||
|
||||
results.push({ name: setting.name, success: result.success });
|
||||
}
|
||||
|
||||
return {
|
||||
success: results.every(r => r.success),
|
||||
results
|
||||
};
|
||||
}
|
||||
|
||||
export async function testNotification(setting: NotificationSettingData): Promise<NotificationResult> {
|
||||
const payload: NotificationPayload = {
|
||||
title: 'Dockhand Test Notification',
|
||||
message: 'This is a test notification from Dockhand. If you receive this, your notification settings are configured correctly.',
|
||||
type: 'info'
|
||||
};
|
||||
|
||||
if (setting.type === 'smtp') {
|
||||
return await sendSmtpNotification(setting.config as SmtpConfig, payload);
|
||||
} else if (setting.type === 'apprise') {
|
||||
return await sendAppriseNotification(setting.config as AppriseConfig, payload);
|
||||
}
|
||||
|
||||
return { success: false, error: 'Unknown notification type' };
|
||||
}
|
||||
|
||||
// Map Docker action to notification event type
|
||||
function mapActionToEventType(action: string): NotificationEventType | null {
|
||||
const mapping: Record<string, NotificationEventType> = {
|
||||
'start': 'container_started',
|
||||
'stop': 'container_stopped',
|
||||
'restart': 'container_restarted',
|
||||
'die': 'container_exited',
|
||||
'kill': 'container_exited',
|
||||
'oom': 'container_oom',
|
||||
'health_status: unhealthy': 'container_unhealthy',
|
||||
'health_status: healthy': 'container_healthy',
|
||||
'pull': 'image_pulled'
|
||||
};
|
||||
return mapping[action] || null;
|
||||
}
|
||||
|
||||
// Scanner image patterns to exclude from notifications
|
||||
const SCANNER_IMAGE_PATTERNS = [
|
||||
'anchore/grype',
|
||||
'aquasec/trivy',
|
||||
'ghcr.io/anchore/grype',
|
||||
'ghcr.io/aquasecurity/trivy'
|
||||
];
|
||||
|
||||
function isScannerContainer(image: string | null | undefined): boolean {
|
||||
if (!image) return false;
|
||||
const lowerImage = image.toLowerCase();
|
||||
return SCANNER_IMAGE_PATTERNS.some(pattern => lowerImage.includes(pattern.toLowerCase()));
|
||||
}
|
||||
|
||||
export async function sendEnvironmentNotification(
|
||||
environmentId: number,
|
||||
action: string,
|
||||
payload: Omit<NotificationPayload, 'environmentId' | 'environmentName'>,
|
||||
image?: string | null
|
||||
): Promise<{ success: boolean; sent: number }> {
|
||||
const eventType = mapActionToEventType(action);
|
||||
if (!eventType) {
|
||||
return { success: true, sent: 0 };
|
||||
}
|
||||
|
||||
const env = await getEnvironment(environmentId);
|
||||
if (!env) {
|
||||
return { success: false, sent: 0 };
|
||||
}
|
||||
|
||||
const envNotifications = await getEnabledEnvironmentNotifications(environmentId, eventType);
|
||||
if (envNotifications.length === 0) {
|
||||
return { success: true, sent: 0 };
|
||||
}
|
||||
|
||||
const enrichedPayload: NotificationPayload = {
|
||||
...payload,
|
||||
environmentId,
|
||||
environmentName: env.name
|
||||
};
|
||||
|
||||
// Skip all notifications for scanner containers (Trivy, Grype)
|
||||
if (isScannerContainer(image)) {
|
||||
return { success: true, sent: 0 };
|
||||
}
|
||||
|
||||
let sent = 0;
|
||||
let allSuccess = true;
|
||||
|
||||
for (const notif of envNotifications) {
|
||||
try {
|
||||
let result: NotificationResult = { success: false };
|
||||
if (notif.channelType === 'smtp') {
|
||||
result = await sendSmtpNotification(notif.config as SmtpConfig, enrichedPayload);
|
||||
} else if (notif.channelType === 'apprise') {
|
||||
result = await sendAppriseNotification(notif.config as AppriseConfig, enrichedPayload);
|
||||
}
|
||||
if (result.success) sent++;
|
||||
else allSuccess = false;
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[Notifications] Failed to send to channel ${notif.channelName}:`, errorMsg);
|
||||
allSuccess = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { success: allSuccess, sent };
|
||||
}
|
||||
|
||||
export async function sendEventNotification(
|
||||
eventType: NotificationEventType,
|
||||
payload: NotificationPayload,
|
||||
environmentId?: number
|
||||
): Promise<{ success: boolean; sent: number }> {
|
||||
let enrichedPayload = { ...payload };
|
||||
if (environmentId) {
|
||||
const env = await getEnvironment(environmentId);
|
||||
if (env) {
|
||||
enrichedPayload.environmentId = environmentId;
|
||||
enrichedPayload.environmentName = env.name;
|
||||
}
|
||||
}
|
||||
|
||||
let channels: Array<{
|
||||
channel_type: 'smtp' | 'apprise';
|
||||
channel_name: string;
|
||||
config: SmtpConfig | AppriseConfig;
|
||||
}> = [];
|
||||
|
||||
if (environmentId) {
|
||||
const envNotifications = await getEnabledEnvironmentNotifications(environmentId, eventType);
|
||||
channels = envNotifications
|
||||
.filter(n => n.channelType && n.channelName)
|
||||
.map(n => ({
|
||||
channel_type: n.channelType!,
|
||||
channel_name: n.channelName!,
|
||||
config: n.config
|
||||
}));
|
||||
} else {
|
||||
const globalSettings = await getEnabledNotificationSettings();
|
||||
channels = globalSettings
|
||||
.filter(s => s.eventTypes?.includes(eventType))
|
||||
.map(s => ({
|
||||
channel_type: s.type,
|
||||
channel_name: s.name,
|
||||
config: s.config
|
||||
}));
|
||||
}
|
||||
|
||||
if (channels.length === 0) {
|
||||
return { success: true, sent: 0 };
|
||||
}
|
||||
|
||||
let sent = 0;
|
||||
let allSuccess = true;
|
||||
|
||||
for (const channel of channels) {
|
||||
try {
|
||||
let result: NotificationResult = { success: false };
|
||||
if (channel.channel_type === 'smtp') {
|
||||
result = await sendSmtpNotification(channel.config as SmtpConfig, enrichedPayload);
|
||||
} else if (channel.channel_type === 'apprise') {
|
||||
result = await sendAppriseNotification(channel.config as AppriseConfig, enrichedPayload);
|
||||
}
|
||||
if (result.success) sent++;
|
||||
else allSuccess = false;
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[Notifications] Failed to send to channel ${channel.channel_name}:`, errorMsg);
|
||||
allSuccess = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { success: allSuccess, sent };
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/** Mattermost incoming webhook. mmost:// or mmosts:// (HTTPS). */
|
||||
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
|
||||
|
||||
export async function sendMattermost(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// mmost://[botname@]hostname[:port][/path]/token or mmosts://...
|
||||
const isSecure = appriseUrl.startsWith('mmosts');
|
||||
const protocol = isSecure ? 'https' : 'http';
|
||||
|
||||
let urlPart = appriseUrl.replace(/^mmosts?:\/\//, '');
|
||||
|
||||
// Check for botname (username@hostname format)
|
||||
let username: string | undefined;
|
||||
const atIndex = urlPart.indexOf('@');
|
||||
if (atIndex !== -1) {
|
||||
username = urlPart.substring(0, atIndex);
|
||||
urlPart = urlPart.substring(atIndex + 1);
|
||||
}
|
||||
|
||||
// The token is the last segment, everything else is hostname[:port][/path]
|
||||
const lastSlashIndex = urlPart.lastIndexOf('/');
|
||||
if (lastSlashIndex === -1) {
|
||||
return { success: false, error: 'Invalid Mattermost URL format. Expected: mmost://[botname@]hostname[:port][/path]/token' };
|
||||
}
|
||||
|
||||
const token = urlPart.substring(lastSlashIndex + 1);
|
||||
const hostAndPath = urlPart.substring(0, lastSlashIndex);
|
||||
|
||||
const url = `${protocol}://${hostAndPath}/hooks/${token}`;
|
||||
|
||||
const envTag = payload.environmentName ? ` \`${payload.environmentName}\`` : '';
|
||||
const body: Record<string, string> = {
|
||||
text: `*${payload.title}*${envTag}\n${payload.message}`
|
||||
};
|
||||
|
||||
if (username) {
|
||||
body.username = username;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Mattermost error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Mattermost connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/** ntfy.sh + self-hosted ntfy. ntfy:// or ntfys:// (HTTPS). */
|
||||
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
|
||||
|
||||
export async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// Supported formats:
|
||||
// ntfy://topic (public ntfy.sh)
|
||||
// ntfy://host/topic (custom server, no auth)
|
||||
// ntfy://user:pass@host/topic (custom server with basic auth)
|
||||
// ntfy://token@host/topic (custom server with bearer token)
|
||||
// ntfy://host/topic?auth=BASE64 (custom server with base64-encoded bearer token)
|
||||
// Query params: ?tags=ship,whale &title=Custom &priority=5
|
||||
// ntfys:// variants for HTTPS
|
||||
const isSecure = appriseUrl.startsWith('ntfys');
|
||||
const path = appriseUrl.replace(/^ntfys?:\/\//, '');
|
||||
|
||||
let url: string;
|
||||
let authHeader: string | null = null;
|
||||
|
||||
let queryAuth: string | null = null;
|
||||
let queryTags: string | null = null;
|
||||
let queryTitle: string | null = null;
|
||||
let queryPriority: string | null = null;
|
||||
let cleanPath = path;
|
||||
const qIndex = path.indexOf('?');
|
||||
if (qIndex !== -1) {
|
||||
const params = new URLSearchParams(path.substring(qIndex + 1));
|
||||
queryAuth = params.get('auth');
|
||||
queryTags = params.get('tags');
|
||||
queryTitle = params.get('title');
|
||||
queryPriority = params.get('priority');
|
||||
cleanPath = path.substring(0, qIndex);
|
||||
}
|
||||
|
||||
const basicMatch = cleanPath.match(/^([^:]+):([^@]+)@(.+)$/);
|
||||
if (basicMatch) {
|
||||
const [, user, pass, hostAndTopic] = basicMatch;
|
||||
const basic = Buffer.from(`${user}:${pass}`).toString('base64');
|
||||
authHeader = `Basic ${basic}`;
|
||||
url = `${isSecure ? 'https' : 'http'}://${hostAndTopic}`;
|
||||
} else if (cleanPath.includes('@') && cleanPath.includes('/')) {
|
||||
const tokenMatch = cleanPath.match(/^([^@]+)@(.+)$/);
|
||||
if (tokenMatch) {
|
||||
const [, token, hostAndTopic] = tokenMatch;
|
||||
authHeader = `Bearer ${token}`;
|
||||
url = `${isSecure ? 'https' : 'http'}://${hostAndTopic}`;
|
||||
} else {
|
||||
url = `${isSecure ? 'https' : 'http'}://${cleanPath}`;
|
||||
}
|
||||
} else if (cleanPath.includes('/')) {
|
||||
url = `${isSecure ? 'https' : 'http'}://${cleanPath}`;
|
||||
} else {
|
||||
url = `https://ntfy.sh/${cleanPath}`;
|
||||
}
|
||||
|
||||
if (!authHeader && queryAuth) {
|
||||
const decoded = Buffer.from(queryAuth, 'base64').toString();
|
||||
authHeader = decoded.startsWith('Bearer ') ? decoded : `Bearer ${decoded}`;
|
||||
}
|
||||
|
||||
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
|
||||
const defaultTags = payload.type || 'info';
|
||||
const headers: Record<string, string> = {
|
||||
'Title': queryTitle || titleWithEnv,
|
||||
'Priority': queryPriority || (payload.type === 'error' ? '5' : payload.type === 'warning' ? '4' : '3'),
|
||||
'Tags': queryTags ? `${queryTags},${defaultTags}` : defaultTags
|
||||
};
|
||||
|
||||
if (authHeader) {
|
||||
headers['Authorization'] = authHeader;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: payload.message
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `ntfy error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `ntfy connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/** Pushover. pushover://user_key/api_token. */
|
||||
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
|
||||
|
||||
export async function sendPushover(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
const match = appriseUrl.match(/^pushover:\/\/([^/]+)\/(.+)/);
|
||||
if (!match) {
|
||||
return { success: false, error: 'Invalid Pushover URL format. Expected: pushover://user_key/api_token' };
|
||||
}
|
||||
|
||||
const [, userKey, apiToken] = match;
|
||||
const url = 'https://api.pushover.net/1/messages.json';
|
||||
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: apiToken,
|
||||
user: userKey,
|
||||
title: titleWithEnv,
|
||||
message: payload.message,
|
||||
priority: payload.type === 'error' ? 1 : 0
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Pushover error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Pushover connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Shared types + helpers used by every notification provider.
|
||||
*
|
||||
* Imported by the router (./index.ts) and by every per-provider file
|
||||
* (discord.ts, slack.ts, …). Keeps the providers free of cross-imports —
|
||||
* each provider only depends on this module.
|
||||
*/
|
||||
|
||||
export interface NotificationPayload {
|
||||
title: string;
|
||||
message: string;
|
||||
type?: 'info' | 'success' | 'warning' | 'error';
|
||||
environmentId?: number;
|
||||
environmentName?: string;
|
||||
}
|
||||
|
||||
export interface NotificationResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Drain a response body to release the underlying socket/TLS connection. */
|
||||
export async function drainResponse(response: Response): Promise<void> {
|
||||
if (!response.bodyUsed) {
|
||||
try { await response.arrayBuffer(); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
/** Append `[env name]` to a title when present. Used by every provider. */
|
||||
export function titleWithEnv(payload: NotificationPayload): string {
|
||||
return payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Signal — via bbernhard/signal-cli-rest-api
|
||||
* (https://github.com/bbernhard/signal-cli-rest-api).
|
||||
*
|
||||
* Supported formats:
|
||||
* signal://host[:port]/+source/+target1[/+target2/...]
|
||||
* signals://host[:port]/+source/+target1[/+target2/...] (HTTPS)
|
||||
*
|
||||
* `+source` is the sender's registered Signal number (E.164 format). The '+'
|
||||
* is optional in the URL — we re-add it. Recipients can be Signal phone
|
||||
* numbers (numeric, '+' gets added) or group IDs (signal-cli's "group.<base64>"
|
||||
* form, passed through untouched).
|
||||
*/
|
||||
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
|
||||
|
||||
export async function sendSignal(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
const isSecure = appriseUrl.startsWith('signals');
|
||||
const raw = appriseUrl.replace(/^signals?:\/\//, '');
|
||||
|
||||
// Strip query string so a future `?foo=bar` doesn't end up in the last
|
||||
// recipient. Currently we don't honor any params, but the parsing should
|
||||
// be forward-compatible.
|
||||
const qIndex = raw.indexOf('?');
|
||||
const cleanPath = qIndex === -1 ? raw : raw.substring(0, qIndex);
|
||||
|
||||
const parts = cleanPath.split('/').filter(Boolean);
|
||||
if (parts.length < 3) {
|
||||
return { success: false, error: 'Invalid Signal URL. Expected: signal://host[:port]/+source/+target1[/+target2/...]' };
|
||||
}
|
||||
const hostPort = parts[0];
|
||||
|
||||
// Phone numbers may or may not start with '+' in the URL — Signal needs
|
||||
// the '+'. Group IDs (signal-cli's "group.<base64>" form) and other
|
||||
// non-numeric recipients are passed through untouched.
|
||||
const normalize = (n: string) => {
|
||||
if (n.startsWith('+')) return n;
|
||||
if (/^\d+$/.test(n)) return `+${n}`;
|
||||
return n;
|
||||
};
|
||||
const source = normalize(parts[1]);
|
||||
const recipients = parts.slice(2).map(normalize);
|
||||
|
||||
// signal-cli-rest-api uses 'message' for body and 'number' for sender;
|
||||
// title is prepended to the body since Signal messages don't have a title field.
|
||||
const titleWithEnv = payload.environmentName
|
||||
? `${payload.title} [${payload.environmentName}]`
|
||||
: payload.title;
|
||||
const messageText = `${titleWithEnv}\n\n${payload.message}`;
|
||||
|
||||
const baseUrl = `${isSecure ? 'https' : 'http'}://${hostPort}`;
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/v2/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
number: source,
|
||||
recipients,
|
||||
message: messageText
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Signal error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Signal connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/** Slack incoming webhook. slack:// or slacks:// or a raw hooks.slack.com URL. */
|
||||
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
|
||||
|
||||
export async function sendSlack(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// slack://token_a/token_b/token_c or webhook URL
|
||||
let url: string;
|
||||
if (appriseUrl.includes('hooks.slack.com')) {
|
||||
url = appriseUrl.replace(/^slacks?:\/\//, 'https://');
|
||||
} else {
|
||||
const parts = appriseUrl.replace(/^slacks?:\/\//, '').split('/');
|
||||
url = `https://hooks.slack.com/services/${parts.join('/')}`;
|
||||
}
|
||||
|
||||
const envTag = payload.environmentName ? ` \`${payload.environmentName}\`` : '';
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: `*${payload.title}*${envTag}\n${payload.message}`
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Slack error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Slack connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/** SMTP email notifications via nodemailer. */
|
||||
import nodemailer from 'nodemailer';
|
||||
import type { SmtpConfig } from '../db';
|
||||
import type { NotificationPayload, NotificationResult } from './shared';
|
||||
|
||||
export async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
auth: config.username ? {
|
||||
user: config.username,
|
||||
pass: config.password
|
||||
} : undefined,
|
||||
tls: config.skipTlsVerify ? {
|
||||
rejectUnauthorized: false
|
||||
} : undefined
|
||||
});
|
||||
|
||||
const envBadge = payload.environmentName
|
||||
? `<span style="display: inline-block; background: #3b82f6; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin-left: 8px;">${payload.environmentName}</span>`
|
||||
: '';
|
||||
const envText = payload.environmentName ? ` [${payload.environmentName}]` : '';
|
||||
|
||||
const html = `
|
||||
<div style="font-family: sans-serif; padding: 20px;">
|
||||
<h2 style="margin: 0 0 10px 0;">${payload.title}${envBadge}</h2>
|
||||
<p style="margin: 0; white-space: pre-wrap;">${payload.message}</p>
|
||||
<hr style="margin: 20px 0; border: none; border-top: 1px solid #eee;">
|
||||
<p style="margin: 0; font-size: 12px; color: #666;">Sent by Dockhand</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
await transporter.sendMail({
|
||||
from: config.from_name ? `"${config.from_name}" <${config.from_email}>` : config.from_email,
|
||||
to: config.to_emails.join(', '),
|
||||
subject: `[Dockhand]${envText} ${payload.title}`,
|
||||
text: `${payload.title}${envText}\n\n${payload.message}`,
|
||||
html
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return { success: false, error: `SMTP error: ${errorMsg}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/** Telegram bot. tgram://bot_token/chat_id[:topic_id]. */
|
||||
import { escapeTelegramMarkdown, parseTelegramUrl } from '$lib/utils/notification-parsers';
|
||||
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
|
||||
|
||||
export async function sendTelegram(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
const parsed = parseTelegramUrl(appriseUrl);
|
||||
if (!parsed) {
|
||||
return { success: false, error: 'Invalid Telegram URL format. Expected: tgram://bot_token/chat_id or tgram://bot_token/chat_id:topic_id' };
|
||||
}
|
||||
|
||||
const { botToken, chatId, topicId } = parsed;
|
||||
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
|
||||
|
||||
const escapedTitle = escapeTelegramMarkdown(payload.title);
|
||||
const escapedMessage = escapeTelegramMarkdown(payload.message);
|
||||
const envTag = payload.environmentName ? ` [${escapeTelegramMarkdown(payload.environmentName)}]` : '';
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text: `*${escapedTitle}*${envTag}\n${escapedMessage}`,
|
||||
...(topicId ? { message_thread_id: topicId } : {}),
|
||||
parse_mode: 'Markdown',
|
||||
link_preview_options: {
|
||||
is_disabled: true
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({})) as { description?: string };
|
||||
const errorMsg = errorData.description || response.statusText;
|
||||
return { success: false, error: `Telegram error ${response.status}: ${errorMsg}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Telegram connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/** Microsoft Power Automate Workflows (e.g. Microsoft Teams). workflows://. */
|
||||
import { parseWorkflowsUrl, buildWorkflowsHttpUrl } from '$lib/utils/notification-parsers';
|
||||
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
|
||||
|
||||
export async function sendWorkflows(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
const parsed = parseWorkflowsUrl(appriseUrl);
|
||||
if (!parsed) {
|
||||
return { success: false, error: 'Invalid Workflows URL format. Expected: workflows://hostname/workflow/signature' };
|
||||
}
|
||||
|
||||
const url = buildWorkflowsHttpUrl(parsed.hostname, parsed.workflow, parsed.signature);
|
||||
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'message',
|
||||
attachments: [
|
||||
{
|
||||
contentType: 'application/vnd.microsoft.card.adaptive',
|
||||
content: {
|
||||
$schema: 'https://adaptivecards.io/schemas/adaptive-card.json',
|
||||
type: 'AdaptiveCard',
|
||||
version: '1.2',
|
||||
body: [
|
||||
{
|
||||
type: 'TextBlock',
|
||||
style: 'heading',
|
||||
wrap: true,
|
||||
text: titleWithEnv
|
||||
},
|
||||
{
|
||||
type: 'TextBlock',
|
||||
style: 'default',
|
||||
wrap: true,
|
||||
text: payload.message
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Workflows error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Workflows connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Encode the AuthConfig JSON as base64url **with `=` padding** for the
|
||||
* Docker X-Registry-Auth header. The Docker daemon decodes the header with
|
||||
* Go's `base64.URLEncoding.DecodeString`, which is base64url with padding —
|
||||
* unpadded base64url (Node's default 'base64url' Buffer encoding) is
|
||||
* silently treated as malformed, causing the daemon to fall back to
|
||||
* anonymous and trip the registry rate limit (#1105).
|
||||
*
|
||||
* Reference: moby/api/pkg/authconfig/authconfig.go uses
|
||||
* `base64.URLEncoding.EncodeToString` / `DecodeString`.
|
||||
*/
|
||||
export function encodeRegistryAuth(authConfig: object): string {
|
||||
const unpadded = Buffer.from(JSON.stringify(authConfig)).toString('base64url');
|
||||
return unpadded + '='.repeat((4 - (unpadded.length % 4)) % 4);
|
||||
}
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
import { getScannerSettings, scanImage, type ScanResult, type VulnerabilitySeverity } from '../../scanner';
|
||||
import { sendEventNotification } from '../../notifications';
|
||||
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
|
||||
import { isUpdateDisabledByLabel } from '../../container-labels';
|
||||
import { isUpdateDisabledByLabel, isHiddenByLabel } from '../../container-labels';
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
@@ -382,6 +382,18 @@ export async function runContainerUpdate(
|
||||
return;
|
||||
}
|
||||
|
||||
// Hidden containers are excluded from update polling and auto-updates (#1083)
|
||||
if (isHiddenByLabel(inspectData.Config?.Labels)) {
|
||||
log(`Skipping - dockhand.hidden=true label set on container`);
|
||||
await updateScheduleExecution(execution.id, {
|
||||
status: 'skipped',
|
||||
completedAt: new Date().toISOString(),
|
||||
duration: Date.now() - startTime,
|
||||
details: { reason: 'Skipped by dockhand.hidden=true label' }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip digest-pinned images - they are explicitly locked to a specific version
|
||||
if (isDigestBasedImage(imageNameFromConfig)) {
|
||||
log(`Skipping ${containerName} - image pinned to specific digest`);
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
import { sendEventNotification } from '../../notifications';
|
||||
import { getScannerSettings, scanImage, type VulnerabilitySeverity } from '../../scanner';
|
||||
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
|
||||
import { isUpdateDisabledByLabel } from '../../container-labels';
|
||||
import { isUpdateDisabledByLabel, isHiddenByLabel } from '../../container-labels';
|
||||
import { recreateContainer } from './container-update';
|
||||
|
||||
interface UpdateInfo {
|
||||
@@ -105,9 +105,12 @@ export async function runEnvUpdateCheckJob(
|
||||
// Clear pending updates at the start - we'll re-add as we discover updates
|
||||
await clearPendingContainerUpdates(environmentId);
|
||||
|
||||
// Get all containers in this environment
|
||||
const containers = await listContainers(true, environmentId);
|
||||
await log(`Found ${containers.length} containers`);
|
||||
// Get all containers in this environment, excluding ones hidden via
|
||||
// dockhand.hidden=true (consistent with manual check-updates, #1083).
|
||||
const allContainers = await listContainers(true, environmentId);
|
||||
const containers = allContainers.filter(c => !isHiddenByLabel(c.labels));
|
||||
const hiddenCount = allContainers.length - containers.length;
|
||||
await log(`Found ${containers.length} containers${hiddenCount ? ` (${hiddenCount} hidden by label)` : ''}`);
|
||||
|
||||
const updatesAvailable: UpdateInfo[] = [];
|
||||
let checkedCount = 0;
|
||||
|
||||
+127
-11
@@ -9,6 +9,15 @@ import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync, unlinkSyn
|
||||
import { join, resolve, dirname, basename } from 'node:path';
|
||||
import { spawn as nodeSpawn } from 'node:child_process';
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import {
|
||||
applyFileDeletions,
|
||||
hashDirFiles,
|
||||
skipReasonMessage,
|
||||
normalizeSkipReason,
|
||||
type FileToDelete,
|
||||
type DeletionApplyResult,
|
||||
type DeletionSkipReason
|
||||
} from './git-deletions';
|
||||
import {
|
||||
getEnvironment,
|
||||
getSecretEnvVarsAsRecord,
|
||||
@@ -61,6 +70,8 @@ export interface StackOperationResult {
|
||||
error?: string;
|
||||
/** The docker compose command that was executed (for debugging/testing) */
|
||||
command?: string;
|
||||
/** Result of applying git deletion sync (files removed / kept, with reasons) */
|
||||
deletion?: DeletionApplyResult;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,6 +122,8 @@ export interface DeployStackOptions {
|
||||
envPath?: string; // Custom env file path (for adopted/imported stacks)
|
||||
composeFileName?: string; // Compose filename to use (e.g., "docker-compose.yaml") for git stacks
|
||||
envFileName?: string; // Env filename relative to compose dir (e.g., ".env") for git stacks
|
||||
/** Git deletion sync (#966): files confirmed safe to delete from the stack dir */
|
||||
filesToDelete?: FileToDelete[];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -830,6 +843,10 @@ interface ComposeCommandOptions {
|
||||
serviceName?: string;
|
||||
/** Compose filename for Hawser (e.g., "docker-compose.prod.yml") - extracted from composePath */
|
||||
composeFileName?: string;
|
||||
/** Git deletion sync (#966): files to delete on the Hawser agent's stack dir */
|
||||
filesToDelete?: FileToDelete[];
|
||||
/** On down: ask the Hawser agent to remove the stack directory entirely (#1162, stack deletion only) */
|
||||
removeFiles?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1285,7 +1302,9 @@ async function executeComposeViaHawser(
|
||||
composeFileName?: string,
|
||||
build?: boolean,
|
||||
noBuildCache?: boolean,
|
||||
pullPolicy?: string
|
||||
pullPolicy?: string,
|
||||
filesToDelete?: FileToDelete[],
|
||||
removeFiles?: boolean
|
||||
): Promise<StackOperationResult> {
|
||||
const logPrefix = `[Stack:${stackName}]`;
|
||||
// Import dockerFetch dynamically to avoid circular dependency
|
||||
@@ -1362,7 +1381,14 @@ async function executeComposeViaHawser(
|
||||
noBuildCache: (build && noBuildCache) || false,
|
||||
pullPolicy: pullPolicy || '',
|
||||
registries, // Registry credentials for docker login
|
||||
serviceName // Target specific service only (with --no-deps)
|
||||
serviceName, // Target specific service only (with --no-deps)
|
||||
// Git deletion sync (#966): agent re-verifies containment + content
|
||||
// hash per file before deleting. Old agents ignore this field.
|
||||
filesToDelete: filesToDelete && filesToDelete.length > 0
|
||||
? filesToDelete.map(f => ({ path: f.path, sha256: f.hash }))
|
||||
: undefined,
|
||||
// Stack deletion (#1162): remove the agent-side stack dir on down
|
||||
removeFiles: removeFiles || false
|
||||
});
|
||||
|
||||
console.log(`${logPrefix} Sending request to Hawser agent...`);
|
||||
@@ -1380,6 +1406,8 @@ async function executeComposeViaHawser(
|
||||
success: boolean;
|
||||
output?: string;
|
||||
error?: string;
|
||||
deletedFiles?: string[];
|
||||
skippedFiles?: { path: string; reason: string }[];
|
||||
};
|
||||
|
||||
console.log(`${logPrefix} ----------------------------------------`);
|
||||
@@ -1393,16 +1421,50 @@ async function executeComposeViaHawser(
|
||||
console.log(`${logPrefix} Error:`, result.error);
|
||||
}
|
||||
|
||||
// Git deletion sync: interpret the agent's report. An agent that supports
|
||||
// the feature always returns deletedFiles/skippedFiles (possibly empty
|
||||
// arrays) when filesToDelete was sent. An old agent ignores the field and
|
||||
// returns neither — every requested deletion is marked agent-no-support.
|
||||
// Skips are FINAL (no carry-forward, no retry): the files stay on the
|
||||
// remote host as unmanaged residue, identical to pre-feature behavior.
|
||||
let deletion: DeletionApplyResult | undefined;
|
||||
if (filesToDelete && filesToDelete.length > 0) {
|
||||
if (result.deletedFiles !== undefined || result.skippedFiles !== undefined) {
|
||||
deletion = {
|
||||
deleted: result.deletedFiles ?? [],
|
||||
skipped: (result.skippedFiles ?? []).map(s => ({
|
||||
path: s.path,
|
||||
reason: normalizeSkipReason(s.reason || 'apply-failed')
|
||||
}))
|
||||
};
|
||||
for (const path of deletion.deleted) {
|
||||
console.log(`${logPrefix} Agent removed "${path}" — deleted from the repository`);
|
||||
}
|
||||
for (const skip of deletion.skipped) {
|
||||
if (skip.reason === 'already-absent') continue;
|
||||
console.warn(`${logPrefix} Agent kept "${skip.path}" — ${skipReasonMessage(skip.reason)}`);
|
||||
}
|
||||
} else {
|
||||
deletion = {
|
||||
deleted: [],
|
||||
skipped: filesToDelete.map(f => ({ path: f.path, reason: 'agent-no-support' as DeletionSkipReason }))
|
||||
};
|
||||
console.warn(`${logPrefix} ${skipReasonMessage('agent-no-support')} (${filesToDelete.length} file(s) affected)`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
return {
|
||||
success: true,
|
||||
output: result.output || `Stack "${stackName}" ${operation} completed via Hawser`
|
||||
output: result.output || `Stack "${stackName}" ${operation} completed via Hawser`,
|
||||
deletion
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
output: result.output || '',
|
||||
error: result.error || `Compose ${operation} failed`
|
||||
error: result.error || `Compose ${operation} failed`,
|
||||
deletion
|
||||
};
|
||||
}
|
||||
} catch (err: any) {
|
||||
@@ -1431,7 +1493,7 @@ async function executeComposeCommand(
|
||||
envVars?: Record<string, string>,
|
||||
secretVars?: Record<string, string>
|
||||
): Promise<StackOperationResult> {
|
||||
const { stackName, envId, forceRecreate, build, noBuildCache, pullPolicy, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile, serviceName, composeFileName } = options;
|
||||
const { stackName, envId, forceRecreate, build, noBuildCache, pullPolicy, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile, serviceName, composeFileName, filesToDelete, removeFiles } = options;
|
||||
|
||||
// Get environment configuration
|
||||
const env = envId ? await getEnvironment(envId) : null;
|
||||
@@ -1521,7 +1583,9 @@ async function executeComposeCommand(
|
||||
composeFileName,
|
||||
build,
|
||||
noBuildCache,
|
||||
pullPolicy
|
||||
pullPolicy,
|
||||
filesToDelete,
|
||||
removeFiles
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1560,12 +1624,20 @@ async function executeComposeCommand(
|
||||
}
|
||||
|
||||
case 'socket':
|
||||
default:
|
||||
default: {
|
||||
// Honor the environment's configured socket path. Without this,
|
||||
// docker compose falls back to /var/run/docker.sock regardless of
|
||||
// the env's setting — wrong daemon for rootless/multi-socket hosts
|
||||
// (#1172). Default '/var/run/docker.sock' is left as undefined so
|
||||
// the CLI's own default applies (preserves existing behavior).
|
||||
const sock = env.socketPath && env.socketPath !== '/var/run/docker.sock'
|
||||
? `unix://${env.socketPath}`
|
||||
: undefined;
|
||||
return executeLocalCompose(
|
||||
operation,
|
||||
stackName,
|
||||
composeContent,
|
||||
undefined, // dockerHost
|
||||
sock,
|
||||
undefined, // tlsConfig
|
||||
envVars,
|
||||
secretVars,
|
||||
@@ -1581,6 +1653,7 @@ async function executeComposeCommand(
|
||||
noBuildCache,
|
||||
pullPolicy
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2088,6 +2161,24 @@ export async function removeStack(
|
||||
if (composeResult.success) {
|
||||
const envVars = await getNonSecretEnvVarsAsRecord(stackName, envId);
|
||||
const secretVars = await getSecretEnvVarsAsRecord(stackName, envId);
|
||||
|
||||
// Stack removal cleanup (#1162): the agent deletes ONLY what Dockhand
|
||||
// explicitly lists. The list is the local staging dir contents — exactly
|
||||
// the files Dockhand ever wrote for this stack (compose, .env,
|
||||
// .env.dockhand, git files), never user volume data (that exists only on
|
||||
// the agent host). Each entry is hash-verified agent-side; the agent's
|
||||
// stack dir is removed only if nothing else remains in it.
|
||||
// Only built for Dockhand-managed staging dirs (inside DATA_DIR/stacks).
|
||||
let removalFiles: FileToDelete[] | undefined;
|
||||
if (composeResult.stackDir) {
|
||||
const resolvedStaging = resolve(composeResult.stackDir);
|
||||
if (resolvedStaging.startsWith(resolve(getStacksDir()) + '/')) {
|
||||
removalFiles = Object.entries(hashDirFiles(resolvedStaging)).map(
|
||||
([path, hash]) => ({ path, hash })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const downResult = await executeComposeCommand(
|
||||
'down',
|
||||
{
|
||||
@@ -2096,7 +2187,10 @@ export async function removeStack(
|
||||
removeVolumes,
|
||||
workingDir: composeResult.stackDir,
|
||||
composePath: composeResult.composePath ?? undefined,
|
||||
envPath: composeResult.envPath ?? undefined
|
||||
envPath: composeResult.envPath ?? undefined,
|
||||
// Full stack removal: the Hawser agent cleans its stack dir (#1162)
|
||||
removeFiles: true,
|
||||
filesToDelete: removalFiles
|
||||
},
|
||||
composeResult.content!,
|
||||
envVars,
|
||||
@@ -2267,7 +2361,7 @@ export async function removeStack(
|
||||
* Uses stack locking to prevent concurrent deployments.
|
||||
*/
|
||||
export async function deployStack(options: DeployStackOptions): Promise<StackOperationResult> {
|
||||
const { name, compose, envId, sourceDir, forceRecreate, build, noBuildCache, pullPolicy, composePath, envPath, composeFileName, envFileName } = options;
|
||||
const { name, compose, envId, sourceDir, forceRecreate, build, noBuildCache, pullPolicy, composePath, envPath, composeFileName, envFileName, filesToDelete } = options;
|
||||
const logPrefix = `[Stack:${name}]`;
|
||||
|
||||
console.log(`${logPrefix} ========================================`);
|
||||
@@ -2299,6 +2393,7 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
|
||||
let actualComposePath: string | undefined;
|
||||
let actualEnvPath: string | undefined = envPath; // Start with provided envPath (for adopted stacks)
|
||||
let stackFiles: Record<string, string> | undefined;
|
||||
let localDeletionResult: DeletionApplyResult | undefined;
|
||||
|
||||
if (composePath) {
|
||||
// Adopted/imported stack: use the original compose file location
|
||||
@@ -2351,6 +2446,21 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
|
||||
filter: (src) => !src.includes('/.git/') && !src.endsWith('/.git')
|
||||
});
|
||||
console.log(`${logPrefix} Copied ${sourceDir} -> ${workingDir}`);
|
||||
|
||||
// Git deletion sync (#966): remove files that were deleted from the
|
||||
// repository. The list is manifest entries absent from the new clone;
|
||||
// the applier re-verifies containment + content hash per file, so
|
||||
// volume data and locally modified files are never touched.
|
||||
if (filesToDelete && filesToDelete.length > 0) {
|
||||
localDeletionResult = applyFileDeletions(workingDir, filesToDelete);
|
||||
for (const path of localDeletionResult.deleted) {
|
||||
console.log(`${logPrefix} Removed "${path}" — deleted from the repository`);
|
||||
}
|
||||
for (const skip of localDeletionResult.skipped) {
|
||||
if (skip.reason === 'already-absent') continue;
|
||||
console.warn(`${logPrefix} Kept "${skip.path}" — ${skipReasonMessage(skip.reason)}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Internal stack: check if a custom path exists in DB (adopted/imported stacks)
|
||||
const source = await getStackSource(name, envId);
|
||||
@@ -2422,7 +2532,8 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
|
||||
envPath: actualEnvPath,
|
||||
useOverrideFile: isGitStack,
|
||||
// Pass compose filename for Hawser (extracted from path or provided explicitly)
|
||||
composeFileName: composeFileName || (actualComposePath ? basename(actualComposePath) : undefined)
|
||||
composeFileName: composeFileName || (actualComposePath ? basename(actualComposePath) : undefined),
|
||||
filesToDelete
|
||||
},
|
||||
compose,
|
||||
isGitStack ? dbNonSecretVars : undefined,
|
||||
@@ -2438,6 +2549,11 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
|
||||
if (result.error) {
|
||||
console.log(`${logPrefix} Error:`, result.error);
|
||||
}
|
||||
// Deletion result: the remote (Hawser) result is authoritative when present;
|
||||
// for local deployments the local applier's result is the truth.
|
||||
if (!result.deletion && localDeletionResult) {
|
||||
result.deletion = localDeletionResult;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -98,6 +98,35 @@ const envNames: Map<number, string> = new Map();
|
||||
// Track which envIds are currently configured in Go
|
||||
const configuredEnvs: Set<number> = new Set();
|
||||
|
||||
// Health status transition tracking: only store DB events when status changes
|
||||
// Key: `${envId}-${containerId}` → last known sub-status (e.g. "healthy", "unhealthy")
|
||||
const lastHealthStatus: Map<string, string> = new Map();
|
||||
|
||||
/**
|
||||
* Check if a health_status event represents a transition (and should be stored in DB).
|
||||
* Non-health events always return true. Repeated identical health statuses return false.
|
||||
* Also clears tracking on container destroy/die events.
|
||||
*/
|
||||
export function isHealthTransition(envId: number, containerId: string, action: string): boolean {
|
||||
// Clear tracking when container is removed
|
||||
if (action === 'destroy' || action === 'die') {
|
||||
lastHealthStatus.delete(`${envId}-${containerId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!action.startsWith('health_status')) return true;
|
||||
|
||||
// Extract sub-status: "health_status: healthy" → "healthy"
|
||||
const subStatus = action.includes(':') ? action.split(':').pop()!.trim() : action;
|
||||
const key = `${envId}-${containerId}`;
|
||||
const previous = lastHealthStatus.get(key);
|
||||
|
||||
if (previous === subStatus) return false; // Same status, skip DB write
|
||||
|
||||
lastHealthStatus.set(key, subStatus);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Dedup cleanup interval
|
||||
let dedupCleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
@@ -265,6 +294,12 @@ async function handleContainerEvent(msg: GoMessage): Promise<void> {
|
||||
|
||||
const timestamp = new Date(Math.floor(event.timeNano / 1000000)).toISOString();
|
||||
|
||||
// Skip redundant health_status events (only store transitions: healthy↔unhealthy)
|
||||
if (!isHealthTransition(msg.envId, containerId, action)) {
|
||||
rssAfterOp('events', before);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sub-category: DB insert
|
||||
const dbBefore = rssBeforeOp();
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user