Files
dockhand-upstream/src/routes/api/environments/+server.ts
T

181 lines
7.7 KiB
TypeScript

import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getEnvironments, getEnvironmentByName, createEnvironment, assignUserRole, getRoleByName, getEnvironmentPublicIps, setEnvironmentPublicIp, getEnvUpdateCheckSettings, getEnvironmentTimezone, getImagePruneSettings, type Environment } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditEnvironment } from '$lib/server/audit';
import { invalidateTokenCacheForUser } from '$lib/server/api-tokens';
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
import { resetHostDetection, detectHostDataDir } from '$lib/server/host-path';
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
import { cleanPem } from '$lib/utils/pem';
import { validateEnvName } from '$lib/utils/env-name';
/**
* @openapi
* summary: List all Docker environments (hosts) known to Dockhand
* resp-200: array<{id:integer!, name:string!, connectionType:string!, host:string, port:integer, protocol:string, icon:string, publicIp:string, timezone:string}>
* resp-200-desc: Environments accessible to the caller (filtered by RBAC in Enterprise mode; all environments in Free edition)
* resp-200-example: [{"id":1,"name":"hhdocker01","connectionType":"socket","host":null,"port":2375,"protocol":"http","icon":"server","publicIp":"203.0.113.10","timezone":"Europe/Berlin"},{"id":2,"name":"hhdocker02","connectionType":"hawser-edge","host":null,"port":2375,"protocol":"http","icon":"server","publicIp":null,"timezone":"Europe/Berlin"}]
* resp-403: Permission denied (RBAC 'environments:view' missing)
* resp-500: Unexpected error while loading environments
*/
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
let environments = await getEnvironments();
// In enterprise mode, filter environments by user's accessible environments
if (auth.authEnabled && auth.isEnterprise && auth.isAuthenticated && !auth.isAdmin) {
const accessibleIds = await auth.getAccessibleEnvironmentIds();
// accessibleIds is null if user has access to all environments
if (accessibleIds !== null) {
environments = environments.filter(env => accessibleIds.includes(env.id));
}
}
// Get public IPs for all environments
const publicIps = await getEnvironmentPublicIps();
// Get update check settings for all environments
const updateCheckSettingsMap = new Map<number, { enabled: boolean; autoUpdate: boolean }>();
for (const env of environments) {
const settings = await getEnvUpdateCheckSettings(env.id);
if (settings && settings.enabled) {
updateCheckSettingsMap.set(env.id, { enabled: true, autoUpdate: settings.autoUpdate });
}
}
// Parse labels from JSON string to array, add public IPs, update check settings, image prune settings, and timezone
const envWithParsedLabels = await Promise.all(environments.map(async env => {
const updateSettings = updateCheckSettingsMap.get(env.id);
const timezone = await getEnvironmentTimezone(env.id);
const imagePruneSettings = await getImagePruneSettings(env.id);
return {
...env,
labels: parseLabels(env.labels as string | null),
publicIp: publicIps[env.id.toString()] || null,
updateCheckEnabled: updateSettings?.enabled || false,
updateCheckAutoUpdate: updateSettings?.autoUpdate || false,
imagePruneEnabled: imagePruneSettings?.enabled || false,
timezone
};
}));
return json(envWithParsedLabels);
} catch (error) {
console.error('Failed to get environments:', error);
return json({ error: 'Failed to get environments' }, { status: 500 });
}
};
/**
* @openapi
* summary: Register a new Docker environment (host)
* body: {name:string!, connectionType:string, host:string, port:integer, protocol:string, socketPath:string, icon:string, publicIp:string, labels:string}
* body-example: {"name":"hhdocker03","connectionType":"socket","socketPath":"/var/run/docker.sock","icon":"server"}
* resp-200: {id:integer!, name:string!, connectionType:string!}
* resp-200-example: {"id":3,"name":"hhdocker03","connectionType":"socket"}
* resp-400: Invalid name, invalid connectionType, or host missing for direct/hawser-standard
* resp-403: Permission denied (RBAC 'environments:create' missing)
* resp-409: An environment with this name already exists
* resp-500: Unexpected error while creating the environment
*/
export const POST: RequestHandler = async (event) => {
const { request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'create')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const data = await request.json();
const nameCheck = validateEnvName(data.name);
if (!nameCheck.ok) {
return json({ error: nameCheck.reason }, { status: 400 });
}
// Check if environment with this name already exists
const existing = await getEnvironmentByName(data.name);
if (existing) {
return json({ error: 'An environment with this name already exists' }, { status: 409 });
}
// Validate connection type
const validConnectionTypes = ['socket', 'direct', 'hawser-standard', 'hawser-edge'];
const connectionType = data.connectionType || 'socket';
if (!validConnectionTypes.includes(connectionType)) {
return json({ error: `Invalid connection type: ${connectionType}` }, { status: 400 });
}
// Host is required for direct and hawser-standard connections
if ((connectionType === 'direct' || connectionType === 'hawser-standard') && !data.host) {
return json({ error: 'Host is required for this connection type' }, { status: 400 });
}
// Validate labels
const labels = Array.isArray(data.labels) ? data.labels.slice(0, MAX_LABELS) : [];
const env = await createEnvironment({
name: data.name,
host: data.host,
port: data.port || 2375,
protocol: data.protocol || 'http',
tlsCa: cleanPem(data.tlsCa),
tlsCert: cleanPem(data.tlsCert),
tlsKey: cleanPem(data.tlsKey),
tlsSkipVerify: data.tlsSkipVerify || false,
icon: data.icon || 'globe',
socketPath: data.socketPath || '/var/run/docker.sock',
collectActivity: data.collectActivity !== false,
collectMetrics: data.collectMetrics !== false,
highlightChanges: data.highlightChanges !== false,
labels: serializeLabels(labels),
connectionType: connectionType,
hawserToken: data.hawserToken
});
// Save public IP if provided
if (data.publicIp) {
await setEnvironmentPublicIp(env.id, data.publicIp);
}
// Notify event collectors to pick up the new environment
refreshSubprocessEnvironments();
// Re-run host detection: a socket-proxy deployment with no direct env at boot
// can now reach Docker through this env without a restart (#1203). Fire-and-forget.
resetHostDetection();
void detectHostDataDir();
// Auto-assign Admin role to creator (Enterprise only)
if (auth.isEnterprise && auth.authEnabled && auth.isAuthenticated && !auth.isAdmin) {
const user = auth.user;
if (user) {
try {
const adminRole = await getRoleByName('Admin');
if (adminRole) {
await assignUserRole(user.id, adminRole.id, env.id);
invalidateTokenCacheForUser(user.id);
}
} catch (roleError) {
// Log but don't fail - environment was created successfully
console.error(`Failed to auto-assign Admin role to user ${user.id} for environment ${env.id}:`, roleError);
}
}
}
// Audit log
await auditEnvironment(event, 'create', env.id, env.name);
return json(env);
} catch (error) {
console.error('Failed to create environment:', error);
return json({ error: 'Failed to create environment' }, { status: 500 });
}
};