361 lines
15 KiB
TypeScript
361 lines
15 KiB
TypeScript
import { json } from '@sveltejs/kit';
|
|
import { join } from 'path';
|
|
import { existsSync, rmSync, renameSync } from 'fs';
|
|
import type { RequestHandler } from './$types';
|
|
import { getEnvironment, updateEnvironment, deleteEnvironment, getEnvironmentPublicIps, setEnvironmentPublicIp, deleteEnvironmentPublicIp, deleteEnvUpdateCheckSettings, deleteImagePruneSettings, getGitStacksForEnvironmentOnly, deleteGitStack, getBackupConfigs } from '$lib/server/db';
|
|
import { clearDockerClientCache } from '$lib/server/docker';
|
|
import { deleteGitStackFiles, getGitReposDir } from '$lib/server/git';
|
|
import { getStacksDir } from '$lib/server/stacks';
|
|
import { authorize } from '$lib/server/authorize';
|
|
import { auditEnvironment } from '$lib/server/audit';
|
|
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
|
|
import { resetHostDetection, detectHostDataDir } from '$lib/server/host-path';
|
|
import { redactEnvironment } from '$lib/server/environment-redact';
|
|
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
|
|
import { cleanPem } from '$lib/utils/pem';
|
|
import { validateEnvName } from '$lib/utils/env-name';
|
|
import { unregisterSchedule } from '$lib/server/scheduler';
|
|
import { closeEdgeConnection } from '$lib/server/hawser';
|
|
import { computeAuditDiff } from '$lib/utils/diff';
|
|
import { deleteEnvironmentIcon } from '$lib/server/env-icons';
|
|
|
|
/**
|
|
* @openapi
|
|
* summary: Get a single environment by id, including its parsed labels and public IP
|
|
* path: id:integer! Environment id (from GET /api/environments)
|
|
* resp-200: {id:integer!, name:string!, connectionType:string!, labels:array<string>, publicIp:string, hasTlsKey:boolean, hasHawserToken:boolean}
|
|
* resp-200-desc: The tlsKey (private TLS client key) and hawserToken secrets are NEVER returned; hasTlsKey / hasHawserToken indicate whether one is stored.
|
|
* resp-403: Permission denied (RBAC 'environments:view' missing)
|
|
* resp-404: Environment not found
|
|
* resp-500: Unexpected error while loading the environment
|
|
*/
|
|
export const GET: RequestHandler = async ({ params, cookies }) => {
|
|
const auth = await authorize(cookies);
|
|
if (auth.authEnabled && !await auth.can('environments', 'view')) {
|
|
return json({ error: 'Permission denied' }, { status: 403 });
|
|
}
|
|
const id = parseInt(params.id);
|
|
const envAccessDenied = await auth.requireEnvAccess(id);
|
|
if (envAccessDenied) return envAccessDenied;
|
|
|
|
try {
|
|
const env = await getEnvironment(id);
|
|
|
|
if (!env) {
|
|
return json({ error: 'Environment not found' }, { status: 404 });
|
|
}
|
|
|
|
// Get public IP for this environment
|
|
const publicIps = await getEnvironmentPublicIps();
|
|
const publicIp = publicIps[id.toString()] || null;
|
|
|
|
// Parse labels from JSON string to array
|
|
return json({
|
|
...redactEnvironment(env),
|
|
labels: parseLabels(env.labels as string | null),
|
|
publicIp
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to get environment:', error);
|
|
return json({ error: 'Failed to get environment' }, { status: 500 });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* @openapi
|
|
* summary: Update an environment; renaming also renames its on-disk stacks/git-repos directories
|
|
* path: id:integer! Environment id (from GET /api/environments)
|
|
* body: {name:string, host:string, port:integer, protocol:string, tlsCa:string, tlsCert:string, tlsKey:string, tlsSkipVerify:boolean, icon:string, socketPath:string, collectActivity:boolean, collectMetrics:boolean, highlightChanges:boolean, labels:string, connectionType:string, hawserToken:string, publicIp:string}
|
|
* body-desc: tlsKey/hawserToken are write-only - a blank value keeps the stored secret; a non-blank value replaces it.
|
|
* body-example: {"name":"hhdocker03","collectMetrics":true}
|
|
* resp-200: {id:integer!, name:string!, connectionType:string!, labels:array<string>, publicIp:string, hasTlsKey:boolean, hasHawserToken:boolean}
|
|
* resp-200-desc: The tlsKey/hawserToken secrets are NEVER returned; hasTlsKey / hasHawserToken indicate whether one is stored.
|
|
* resp-400: Invalid new name (rename validation)
|
|
* resp-403: Permission denied (RBAC 'environments:edit' missing)
|
|
* resp-404: Environment not found
|
|
* resp-409: Rename target directory already exists, or the on-disk rename failed (e.g. EXDEV across filesystems)
|
|
* resp-500: Unexpected error while updating the environment
|
|
*/
|
|
export const PUT: RequestHandler = async (event) => {
|
|
const { params, request, cookies } = event;
|
|
const auth = await authorize(cookies);
|
|
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
|
|
return json({ error: 'Permission denied' }, { status: 403 });
|
|
}
|
|
const id = parseInt(params.id);
|
|
const envAccessDenied = await auth.requireEnvAccess(id);
|
|
if (envAccessDenied) return envAccessDenied;
|
|
|
|
try {
|
|
// Get old values before update for diff
|
|
const oldEnv = await getEnvironment(id);
|
|
if (!oldEnv) {
|
|
return json({ error: 'Environment not found' }, { status: 404 });
|
|
}
|
|
|
|
const data = await request.json();
|
|
|
|
// #1179: validate name if it's being changed. Existing invalid names are
|
|
// not auto-corrected — only writes go through this check.
|
|
const isRename = data.name !== undefined && data.name !== oldEnv.name;
|
|
if (isRename) {
|
|
const nameCheck = validateEnvName(data.name);
|
|
if (!nameCheck.ok) {
|
|
return json({ error: nameCheck.reason }, { status: 400 });
|
|
}
|
|
}
|
|
|
|
// Rename on-disk directories BEFORE the DB write. If the fs rename
|
|
// fails (cross-mount EXDEV, perm error, target exists), we surface a
|
|
// 409 and leave the DB untouched — better than the previous behavior
|
|
// of silently orphaning stacks under the old name.
|
|
//
|
|
// Applies to ALL connection types. For socket/direct envs the staging
|
|
// dir IS the deployed dir, so containers need a redeploy after rename
|
|
// (see client warning). For Hawser envs the agent owns the deployed
|
|
// dir on the remote host and isn't affected, but Dockhand still keeps
|
|
// a local staging copy under stacks/<envName>/<stackName>/ (for the
|
|
// in-app editor) and ALL git stacks clone to git-repos/<envName>/
|
|
// regardless of where they ultimately deploy — so the rename matters
|
|
// locally for every env type.
|
|
if (isRename) {
|
|
const stacksDir = getStacksDir();
|
|
const gitReposDir = getGitReposDir();
|
|
const oldStacks = join(stacksDir, oldEnv.name);
|
|
const newStacks = join(stacksDir, data.name);
|
|
const oldRepos = join(gitReposDir, oldEnv.name);
|
|
const newRepos = join(gitReposDir, data.name);
|
|
|
|
// Refuse to overwrite a target dir that already holds someone
|
|
// else's data.
|
|
if (existsSync(oldStacks) && existsSync(newStacks)) {
|
|
return json({
|
|
error: `Cannot rename: ${newStacks} already exists. Pick a different name or move that directory out of the way.`
|
|
}, { status: 409 });
|
|
}
|
|
if (existsSync(oldRepos) && existsSync(newRepos)) {
|
|
return json({
|
|
error: `Cannot rename: ${newRepos} already exists. Pick a different name or move that directory out of the way.`
|
|
}, { status: 409 });
|
|
}
|
|
|
|
try {
|
|
if (existsSync(oldStacks)) renameSync(oldStacks, newStacks);
|
|
if (existsSync(oldRepos)) renameSync(oldRepos, newRepos);
|
|
} catch (err: any) {
|
|
// Best-effort rollback if the second rename failed after the first
|
|
// succeeded. Avoids leaving the filesystem in a split state.
|
|
try { if (existsSync(newStacks) && !existsSync(oldStacks)) renameSync(newStacks, oldStacks); } catch {}
|
|
try { if (existsSync(newRepos) && !existsSync(oldRepos)) renameSync(newRepos, oldRepos); } catch {}
|
|
const code = err?.code === 'EXDEV'
|
|
? 'EXDEV: stacks dir is on a different filesystem from the rename target. Move it back to the same filesystem to rename this environment.'
|
|
: (err?.message || 'Rename failed');
|
|
return json({ error: code }, { status: 409 });
|
|
}
|
|
}
|
|
|
|
// Clear cached Docker client before updating
|
|
clearDockerClientCache(id);
|
|
|
|
// Handle labels - only update if provided in the request
|
|
const labels = data.labels !== undefined
|
|
? serializeLabels(Array.isArray(data.labels) ? data.labels.slice(0, MAX_LABELS) : [])
|
|
: undefined;
|
|
|
|
// The GET/list responses never return the tlsKey / hawserToken secrets, so the
|
|
// edit form can't round-trip them. A blank value therefore means "keep the stored
|
|
// secret" (pass undefined so updateEnvironment leaves the column untouched); a
|
|
// non-blank value replaces it. Same "leave blank to keep" pattern as registries /
|
|
// git credentials. (Removing a secret entirely is done by deleting the env.)
|
|
const cleanedTlsKey = cleanPem(data.tlsKey);
|
|
const trimmedToken = typeof data.hawserToken === 'string' ? data.hawserToken.trim() : data.hawserToken;
|
|
const env = await updateEnvironment(id, {
|
|
name: data.name,
|
|
host: data.host,
|
|
port: data.port,
|
|
protocol: data.protocol,
|
|
tlsCa: cleanPem(data.tlsCa),
|
|
tlsCert: cleanPem(data.tlsCert),
|
|
tlsKey: cleanedTlsKey || undefined,
|
|
tlsSkipVerify: data.tlsSkipVerify,
|
|
icon: data.icon,
|
|
socketPath: data.socketPath,
|
|
collectActivity: data.collectActivity,
|
|
collectMetrics: data.collectMetrics,
|
|
highlightChanges: data.highlightChanges,
|
|
labels: labels,
|
|
connectionType: data.connectionType,
|
|
hawserToken: trimmedToken || undefined
|
|
});
|
|
|
|
if (!env) {
|
|
return json({ error: 'Environment not found' }, { status: 404 });
|
|
}
|
|
|
|
// Re-run host detection: editing an env (e.g. pointing it at a socket proxy)
|
|
// lets a socketless deployment reach Docker without a restart (#1203).
|
|
resetHostDetection();
|
|
void detectHostDataDir();
|
|
|
|
// Notify event collectors if collectActivity or collectMetrics setting changed
|
|
if (data.collectActivity !== undefined || data.collectMetrics !== undefined) {
|
|
refreshSubprocessEnvironments();
|
|
}
|
|
|
|
// Handle public IP - update if provided in request
|
|
if (data.publicIp !== undefined) {
|
|
await setEnvironmentPublicIp(id, data.publicIp || null);
|
|
}
|
|
|
|
// Get current public IP for response
|
|
const publicIps = await getEnvironmentPublicIps();
|
|
const publicIp = publicIps[id.toString()] || null;
|
|
|
|
// Compute diff for audit (exclude sensitive TLS fields)
|
|
const diff = computeAuditDiff(oldEnv, env, {
|
|
excludeFields: ['tlsCa', 'tlsCert', 'tlsKey', 'hawserToken', 'labels']
|
|
});
|
|
|
|
// Audit log
|
|
await auditEnvironment(event, 'update', env.id, env.name, diff);
|
|
|
|
// Parse labels from JSON string to array
|
|
return json({
|
|
...redactEnvironment(env),
|
|
labels: parseLabels(env.labels as string | null),
|
|
publicIp
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to update environment:', error);
|
|
return json({ error: 'Failed to update environment' }, { status: 500 });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* @openapi
|
|
* summary: Delete an environment and all its associated git stacks, schedules, icons, and on-disk directories
|
|
* path: id:integer! Environment id (from GET /api/environments)
|
|
* resp-200: {success:boolean!}
|
|
* resp-400: Invalid environment id, or the environment could not be deleted
|
|
* resp-403: Permission denied (RBAC 'environments:delete' missing)
|
|
* resp-404: Environment not found
|
|
* resp-500: Environment name is empty/whitespace (refuses to delete to avoid an unsafe directory cleanup), or an unexpected error
|
|
*/
|
|
export const DELETE: RequestHandler = async (event) => {
|
|
const { params, cookies } = event;
|
|
const auth = await authorize(cookies);
|
|
// Deleting an environment cascades a filesystem cleanup of its stack dirs, git
|
|
// files, and schedules - an instance-level action, not an env-scoped one. Require
|
|
// admin under RBAC so an env-scoped role can't remove environments it doesn't own.
|
|
if (auth.authEnabled && auth.isEnterprise && !auth.isAdmin) {
|
|
return json({ error: 'Permission denied' }, { status: 403 });
|
|
}
|
|
if (auth.authEnabled && !await auth.can('environments', 'delete')) {
|
|
return json({ error: 'Permission denied' }, { status: 403 });
|
|
}
|
|
|
|
try {
|
|
const id = parseInt(params.id);
|
|
if (isNaN(id) || id <= 0) {
|
|
return json({ error: 'Invalid environment ID' }, { status: 400 });
|
|
}
|
|
|
|
// Get environment name before deletion for audit log
|
|
const env = await getEnvironment(id);
|
|
if (!env) {
|
|
return json({ error: 'Environment not found' }, { status: 404 });
|
|
}
|
|
|
|
// Safety: never delete directories if env name is empty/whitespace
|
|
if (!env.name?.trim()) {
|
|
return json({ error: 'Cannot delete environment with empty name' }, { status: 500 });
|
|
}
|
|
|
|
// Close Edge connection if this is a Hawser Edge environment
|
|
// This rejects any pending requests and closes the WebSocket
|
|
closeEdgeConnection(id);
|
|
|
|
// Clear cached Docker client before deleting
|
|
clearDockerClientCache(id);
|
|
|
|
// Clean up git stacks for this environment
|
|
const gitStacks = await getGitStacksForEnvironmentOnly(id);
|
|
for (const stack of gitStacks) {
|
|
// Unregister schedule if auto-update was enabled
|
|
if (stack.autoUpdate) {
|
|
unregisterSchedule(stack.id, 'git_stack_sync');
|
|
}
|
|
// Delete git stack files from filesystem
|
|
await deleteGitStackFiles(stack.id, stack.stackName, stack.environmentId);
|
|
// Delete git stack from database
|
|
await deleteGitStack(stack.id);
|
|
}
|
|
|
|
const success = await deleteEnvironment(id);
|
|
|
|
if (!success) {
|
|
return json({ error: 'Cannot delete this environment' }, { status: 400 });
|
|
}
|
|
|
|
// Clean up custom icon file if exists
|
|
deleteEnvironmentIcon(id);
|
|
|
|
// Clean up public IP entry for this environment
|
|
await deleteEnvironmentPublicIp(id);
|
|
|
|
// Clean up update check settings and unregister schedule
|
|
await deleteEnvUpdateCheckSettings(id);
|
|
unregisterSchedule(id, 'env_update_check');
|
|
|
|
// Clean up image prune settings and unregister schedule
|
|
await deleteImagePruneSettings(id);
|
|
unregisterSchedule(id, 'image_prune');
|
|
|
|
// Unregister backup config schedules for this environment (audit #10).
|
|
// The config rows themselves cascade-delete with the environment, but the
|
|
// in-memory croner jobs would otherwise stay registered as orphans.
|
|
try {
|
|
const envBackupConfigs = await getBackupConfigs({ environmentId: id });
|
|
for (const config of envBackupConfigs) {
|
|
unregisterSchedule(config.id, 'backup');
|
|
}
|
|
} catch (err) {
|
|
console.error(`Failed to unregister backup schedules for environment "${env.name}":`, err);
|
|
}
|
|
|
|
// Clean up stack directory for this environment
|
|
// Safety: only delete subdirectory named after the env, never the parent
|
|
try {
|
|
const stacksDir = getStacksDir();
|
|
const envStackDir = join(stacksDir, env.name);
|
|
if (envStackDir !== stacksDir && envStackDir.startsWith(stacksDir) && existsSync(envStackDir)) {
|
|
rmSync(envStackDir, { recursive: true, force: true });
|
|
}
|
|
} catch (err) {
|
|
console.error(`Failed to clean up stack directory for environment "${env.name}":`, err);
|
|
}
|
|
|
|
// Clean up git-repos directory for this environment
|
|
try {
|
|
const gitReposDir = getGitReposDir();
|
|
const envGitDir = join(gitReposDir, env.name);
|
|
if (envGitDir !== gitReposDir && envGitDir.startsWith(gitReposDir) && existsSync(envGitDir)) {
|
|
rmSync(envGitDir, { recursive: true, force: true });
|
|
}
|
|
} catch (err) {
|
|
console.error(`Failed to clean up git-repos directory for environment "${env.name}":`, err);
|
|
}
|
|
|
|
// Notify event collectors to stop collecting from deleted environment
|
|
refreshSubprocessEnvironments();
|
|
|
|
// Audit log
|
|
await auditEnvironment(event, 'delete', id, env.name);
|
|
|
|
return json({ success: true });
|
|
} catch (error) {
|
|
console.error('Failed to delete environment:', error);
|
|
return json({ error: 'Failed to delete environment' }, { status: 500 });
|
|
}
|
|
};
|