Initial commit

This commit is contained in:
Jarek Krochmalski
2025-12-28 21:16:03 +01:00
commit 62e3c6439e
552 changed files with 104858 additions and 0 deletions

122
routes/api/stacks/[name]/env/+server.ts vendored Normal file
View File

@@ -0,0 +1,122 @@
import { json } from '@sveltejs/kit';
import { getStackEnvVars, setStackEnvVars } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
/**
* GET /api/stacks/[name]/env?env=X
* Get all environment variables for a stack.
* Secrets are masked with '***' in the response.
*/
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : null;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('stacks', 'view', envIdNum ?? undefined)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
try {
const stackName = decodeURIComponent(params.name);
const variables = await getStackEnvVars(stackName, envIdNum, true);
return json({
variables: variables.map(v => ({
key: v.key,
value: v.value,
isSecret: v.isSecret
}))
});
} catch (error) {
console.error('Error getting stack env vars:', error);
return json({ error: 'Failed to get environment variables' }, { status: 500 });
}
};
/**
* PUT /api/stacks/[name]/env?env=X
* Set/replace all environment variables for a stack.
* Body: { variables: [{ key, value, isSecret? }] }
*
* Note: For secrets, if the value is '***' (the masked placeholder), the original
* secret value from the database is preserved instead of overwriting with '***'.
*/
export const PUT: RequestHandler = async ({ params, url, cookies, request }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : null;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('stacks', 'edit', envIdNum ?? undefined)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
try {
const stackName = decodeURIComponent(params.name);
const body = await request.json();
if (!body.variables || !Array.isArray(body.variables)) {
return json({ error: 'Invalid request body: variables array required' }, { status: 400 });
}
// Validate variables
for (const v of body.variables) {
if (!v.key || typeof v.key !== 'string') {
return json({ error: 'Invalid variable: key is required and must be a string' }, { status: 400 });
}
if (typeof v.value !== 'string') {
return json({ error: `Invalid variable "${v.key}": value must be a string` }, { status: 400 });
}
// Validate key format (env var naming convention)
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(v.key)) {
return json({ error: `Invalid variable name "${v.key}": must start with a letter or underscore and contain only alphanumeric characters and underscores` }, { status: 400 });
}
}
// Check if any secrets have the masked placeholder '***'
// If so, we need to preserve their original values from the database
const secretsWithMaskedValue = body.variables.filter(
(v: { key: string; value: string; isSecret?: boolean }) =>
v.isSecret && v.value === '***'
);
let variablesToSave = body.variables;
if (secretsWithMaskedValue.length > 0) {
// Get existing variables (unmasked) to preserve secret values
const existingVars = await getStackEnvVars(stackName, envIdNum, false);
const existingByKey = new Map(existingVars.map(v => [v.key, v]));
// Replace masked secrets with their original values
variablesToSave = body.variables.map((v: { key: string; value: string; isSecret?: boolean }) => {
if (v.isSecret && v.value === '***') {
const existing = existingByKey.get(v.key);
if (existing && existing.isSecret) {
// Preserve the original secret value
return { ...v, value: existing.value };
}
}
return v;
});
}
await setStackEnvVars(stackName, envIdNum, variablesToSave);
return json({ success: true, count: variablesToSave.length });
} catch (error) {
console.error('Error setting stack env vars:', error);
return json({ error: 'Failed to set environment variables' }, { status: 500 });
}
};

View File

@@ -0,0 +1,148 @@
import { json } from '@sveltejs/kit';
import { getStackEnvVars } from '$lib/server/db';
import { getStackComposeFile } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
interface ValidationResult {
valid: boolean;
required: string[];
optional: string[];
defined: string[];
missing: string[];
unused: string[];
}
/**
* Extract environment variables from compose YAML content.
* Matches ${VAR_NAME} and ${VAR_NAME:-default} patterns.
* Returns { required: [...], optional: [...] }
*/
function extractComposeVars(yaml: string): { required: string[]; optional: string[] } {
const required: string[] = [];
const optional: string[] = [];
// Match ${VAR_NAME} (required) and ${VAR_NAME:-default} or ${VAR_NAME-default} (optional)
const regex = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:-?)[^}]*)?\}/g;
let match;
while ((match = regex.exec(yaml)) !== null) {
const varName = match[1];
const hasDefault = match[2] !== undefined;
if (hasDefault) {
if (!optional.includes(varName) && !required.includes(varName)) {
optional.push(varName);
}
} else {
// Move from optional to required if we find a non-default usage
const optIdx = optional.indexOf(varName);
if (optIdx !== -1) {
optional.splice(optIdx, 1);
}
if (!required.includes(varName)) {
required.push(varName);
}
}
}
// Also match $VAR_NAME (simple variable substitution)
const simpleRegex = /\$([A-Za-z_][A-Za-z0-9_]*)(?![{A-Za-z0-9_])/g;
while ((match = simpleRegex.exec(yaml)) !== null) {
const varName = match[1];
if (!required.includes(varName) && !optional.includes(varName)) {
required.push(varName);
}
}
return { required: required.sort(), optional: optional.sort() };
}
/**
* POST /api/stacks/[name]/env/validate?env=X
* Validate stack environment variables against compose file requirements.
* Can use saved compose file or accept compose content in body.
* Body (optional): { compose: "yaml content..." }
*/
export const POST: RequestHandler = async ({ params, url, cookies, request }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : null;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('stacks', 'view', envIdNum ?? undefined)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
try {
const stackName = decodeURIComponent(params.name);
let composeContent: string | null = null;
let providedVariables: string[] | null = null;
// Check if compose content and/or variables are provided in body
const contentType = request.headers.get('content-type');
if (contentType?.includes('application/json')) {
try {
const body = await request.json();
if (body.compose && typeof body.compose === 'string') {
composeContent = body.compose;
}
// Accept variables from UI for validation (overrides DB lookup)
if (Array.isArray(body.variables)) {
providedVariables = body.variables.filter((v: unknown) => typeof v === 'string');
}
} catch {
// Ignore JSON parse errors - will try to load from file
}
}
// If no compose in body, try to load from saved file
if (!composeContent) {
const savedCompose = await getStackComposeFile(stackName);
if (savedCompose.success && savedCompose.content) {
composeContent = savedCompose.content;
}
}
if (!composeContent) {
return json({ error: 'No compose content provided and no saved compose file found' }, { status: 400 });
}
// Extract variables from compose
const { required, optional } = extractComposeVars(composeContent);
// Get defined variables - either from request body or database
let defined: string[];
if (providedVariables !== null) {
// Use provided variables from UI
defined = providedVariables.sort();
} else {
// Fall back to database
const envVars = await getStackEnvVars(stackName, envIdNum, false);
defined = envVars.map(v => v.key).sort();
}
// Calculate missing and unused
const missing = required.filter(v => !defined.includes(v));
const unused = defined.filter(v => !required.includes(v) && !optional.includes(v));
const result: ValidationResult = {
valid: missing.length === 0,
required,
optional,
defined,
missing,
unused
};
return json(result);
} catch (error) {
console.error('Error validating stack env vars:', error);
return json({ error: 'Failed to validate environment variables' }, { status: 500 });
}
};