254 lines
9.5 KiB
TypeScript
254 lines
9.5 KiB
TypeScript
import { json } from '@sveltejs/kit';
|
|
import type { RequestHandler } from './$types';
|
|
import {
|
|
getGitStacks,
|
|
createGitStack,
|
|
getGitCredentials,
|
|
getGitRepository,
|
|
createGitRepository,
|
|
upsertStackSource,
|
|
setStackEnvVars,
|
|
getStackSource,
|
|
secretProviderExists
|
|
} from '$lib/server/db';
|
|
import { deployGitStack } from '$lib/server/git';
|
|
import { authorize } from '$lib/server/authorize';
|
|
import { registerSchedule } from '$lib/server/scheduler';
|
|
import { auditGitStack } from '$lib/server/audit';
|
|
import { createJobResponse } from '$lib/server/sse';
|
|
import { allowSecretlessWebhook, webhookConfigRequiresSecret } from '$lib/server/webhook-secret-policy';
|
|
|
|
// Stack name validation: Docker Compose requires lowercase; must start with a
|
|
// letter or number, and contain only lowercase letters, numbers, hyphens, underscores
|
|
const STACK_NAME_REGEX = /^[a-z0-9][a-z0-9_-]*$/;
|
|
|
|
/**
|
|
* @openapi
|
|
* summary: List git-deployed stacks (optionally scoped to one environment)
|
|
* query: env:integer Filter to a single environment id
|
|
* resp-403: Permission denied (needs stacks:view)
|
|
* resp-500: Failed to list git stacks
|
|
*/
|
|
export const GET: RequestHandler = async ({ url, cookies }) => {
|
|
const auth = await authorize(cookies);
|
|
|
|
const envId = url.searchParams.get('env');
|
|
const envIdNum = envId ? parseInt(envId) : undefined;
|
|
|
|
// Permission check with environment context
|
|
if (auth.authEnabled && !await auth.can('stacks', 'view', envIdNum)) {
|
|
return json({ error: 'Permission denied' }, { status: 403 });
|
|
}
|
|
|
|
try {
|
|
|
|
const stacks = await getGitStacks(envIdNum);
|
|
return json(stacks);
|
|
} catch (error) {
|
|
console.error('Failed to get git stacks:', error);
|
|
return json({ error: 'Failed to get git stacks' }, { status: 500 });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* @openapi
|
|
* summary: Create a git-deployed stack (from an existing repo or new repo url/branch)
|
|
* body: {stackName:string!, environmentId:integer, repositoryId:integer, secretProviderId:integer, webhookEnabled:boolean, webhookSecret:string}
|
|
* resp-400: Invalid stack name, or secretProviderId is not a number/null
|
|
* resp-403: Permission denied (needs stacks:create; binding a secret provider also needs secrets:view)
|
|
* resp-409: A git stack with this name already exists in the environment
|
|
* resp-500: Failed to create the git stack
|
|
*/
|
|
export const POST: RequestHandler = async (event) => {
|
|
const { request, cookies } = event;
|
|
const auth = await authorize(cookies);
|
|
|
|
try {
|
|
const data = await request.json();
|
|
|
|
// Permission check with environment context
|
|
if (auth.authEnabled && !await auth.can('stacks', 'create', data.environmentId || undefined)) {
|
|
return json({ error: 'Permission denied' }, { status: 403 });
|
|
}
|
|
|
|
if (!data.stackName || typeof data.stackName !== 'string') {
|
|
return json({ error: 'Stack name is required' }, { status: 400 });
|
|
}
|
|
|
|
const trimmedStackName = data.stackName.trim();
|
|
if (!STACK_NAME_REGEX.test(trimmedStackName)) {
|
|
return json({ error: 'Stack name must be lowercase, start with a letter or number, and contain only letters, numbers, hyphens, and underscores' }, { status: 400 });
|
|
}
|
|
|
|
if (
|
|
'secretProviderId' in data &&
|
|
data.secretProviderId !== null &&
|
|
typeof data.secretProviderId !== 'number'
|
|
) {
|
|
return json({ error: 'secretProviderId must be a number or null' }, { status: 400 });
|
|
}
|
|
|
|
// Binding a secret provider resolves its secrets into the container at deploy;
|
|
// require the secrets permission so a stacks-only user can't exfiltrate a
|
|
// provider's secrets by binding it and reading the container env.
|
|
if (
|
|
typeof data.secretProviderId === 'number' &&
|
|
auth.authEnabled &&
|
|
!(await auth.can('secrets', 'view', data.environmentId || undefined))
|
|
) {
|
|
return json({ error: 'Permission denied: binding a secret provider requires the secrets permission' }, { status: 403 });
|
|
}
|
|
|
|
// A stale provider id (e.g. the provider was deleted/recreated while the editor
|
|
// held the old list) would otherwise hit a raw foreign-key error on save. Reject
|
|
// it cleanly so the user knows to reselect a provider (#1522).
|
|
if (typeof data.secretProviderId === 'number' && !(await secretProviderExists(data.secretProviderId))) {
|
|
return json({ error: 'The selected secret provider no longer exists. Reopen the stack and pick a current provider.' }, { status: 400 });
|
|
}
|
|
|
|
// Check for name conflicts with existing stacks (regular/external/git)
|
|
const existing = await getStackSource(trimmedStackName, data.environmentId || null);
|
|
if (existing) {
|
|
return json({ error: 'A stack with this name already exists on this environment' }, { status: 409 });
|
|
}
|
|
|
|
// A secret is mandatory when the webhook is enabled.
|
|
if (webhookConfigRequiresSecret(!!data.webhookEnabled, !!data.webhookSecret?.trim(), allowSecretlessWebhook())) {
|
|
return json({ error: 'A webhook secret is required when the webhook is enabled' }, { status: 400 });
|
|
}
|
|
|
|
// Either repositoryId or new repo details (url, branch) must be provided
|
|
let repositoryId = data.repositoryId;
|
|
|
|
if (!repositoryId) {
|
|
// Create a new repository if URL is provided
|
|
if (!data.url || typeof data.url !== 'string') {
|
|
return json({ error: 'Repository URL or existing repository ID is required' }, { status: 400 });
|
|
}
|
|
|
|
// Validate credential if provided
|
|
if (data.credentialId) {
|
|
const credentials = await getGitCredentials();
|
|
const credential = credentials.find(c => c.id === data.credentialId);
|
|
if (!credential) {
|
|
return json({ error: 'Invalid credential ID' }, { status: 400 });
|
|
}
|
|
}
|
|
|
|
// Create the repository first
|
|
const repoName = data.repoName || data.stackName;
|
|
try {
|
|
const repo = await createGitRepository({
|
|
name: repoName,
|
|
url: data.url,
|
|
branch: data.branch || 'main',
|
|
credentialId: data.credentialId || null
|
|
});
|
|
repositoryId = repo.id;
|
|
} catch (error: any) {
|
|
if (error.message?.includes('UNIQUE constraint failed')) {
|
|
return json({ error: 'A repository with this name already exists' }, { status: 400 });
|
|
}
|
|
throw error;
|
|
}
|
|
} else {
|
|
// Verify repository exists
|
|
const repo = await getGitRepository(repositoryId);
|
|
if (!repo) {
|
|
return json({ error: 'Repository not found' }, { status: 400 });
|
|
}
|
|
}
|
|
|
|
const gitStack = await createGitStack({
|
|
stackName: trimmedStackName,
|
|
environmentId: data.environmentId || null,
|
|
repositoryId: repositoryId,
|
|
// Per-stack branch override — only when targeting an existing repository.
|
|
// In new-repo mode data.branch becomes the repository's default instead;
|
|
// the stack inherits it (branch stays null).
|
|
...(data.repositoryId && typeof data.branch === 'string' && data.branch.trim()
|
|
? { branch: data.branch.trim() }
|
|
: {}),
|
|
composePath: data.composePath || 'compose.yaml',
|
|
envFilePath: data.envFilePath || null,
|
|
autoUpdate: data.autoUpdate || false,
|
|
autoUpdateSchedule: data.autoUpdateSchedule || 'daily',
|
|
autoUpdateCron: data.autoUpdateCron || '0 3 * * *',
|
|
webhookEnabled: data.webhookEnabled || false,
|
|
webhookSecret: data.webhookSecret || null,
|
|
contextDir: data.contextDir || null,
|
|
buildOnDeploy: data.buildOnDeploy ?? false,
|
|
noBuildCache: data.noBuildCache ?? false,
|
|
repullImages: data.repullImages ?? false,
|
|
forceRedeploy: data.forceRedeploy ?? false
|
|
});
|
|
|
|
// Create stack_sources entry so the stack appears in the list immediately
|
|
await upsertStackSource({
|
|
stackName: trimmedStackName,
|
|
environmentId: data.environmentId || null,
|
|
sourceType: 'git',
|
|
gitRepositoryId: repositoryId,
|
|
gitStackId: gitStack.id,
|
|
secretProviderId: data.secretProviderId ?? null
|
|
});
|
|
|
|
// Register schedule with croner if auto-update is enabled
|
|
if (gitStack.autoUpdate && gitStack.autoUpdateCron) {
|
|
await registerSchedule(gitStack.id, 'git_stack_sync', gitStack.environmentId);
|
|
}
|
|
|
|
// Audit log
|
|
await auditGitStack(event, 'create', gitStack.id, gitStack.stackName, gitStack.environmentId);
|
|
|
|
// Save environment variable overrides before deploying
|
|
if (data.envVars && Array.isArray(data.envVars) && data.envVars.length > 0) {
|
|
// Filter out masked secrets - on initial creation there are no existing secrets
|
|
// If a secret has value '***', it means something went wrong in the UI
|
|
const varsToSave = data.envVars
|
|
.filter((v: any) => v.key?.trim())
|
|
.filter((v: any) => !(v.isSecret && v.value === '***'))
|
|
.map((v: any) => ({
|
|
key: v.key.trim(),
|
|
value: v.value ?? '',
|
|
isSecret: v.isSecret ?? false
|
|
}));
|
|
|
|
if (varsToSave.length > 0) {
|
|
await setStackEnvVars(trimmedStackName, data.environmentId || null, varsToSave);
|
|
}
|
|
}
|
|
|
|
// If deployNow is set, deploy immediately via SSE to keep connection alive
|
|
if (data.deployNow) {
|
|
return createJobResponse(async (send) => {
|
|
try {
|
|
const deployResult = await deployGitStack(gitStack.id);
|
|
await auditGitStack(event, 'deploy', gitStack.id, gitStack.stackName, gitStack.environmentId);
|
|
send('result', {
|
|
...gitStack,
|
|
deployResult: deployResult
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to deploy git stack:', error);
|
|
send('result', {
|
|
...gitStack,
|
|
deployResult: { success: false, error: 'Failed to deploy git stack' }
|
|
});
|
|
}
|
|
}, request);
|
|
}
|
|
|
|
return json(gitStack);
|
|
} catch (error: any) {
|
|
console.error('Failed to create git stack:', error);
|
|
if (error.message?.includes('UNIQUE constraint failed')) {
|
|
if (error.message?.includes('stack_environment_variables')) {
|
|
return json({ error: 'Duplicate environment variable keys detected' }, { status: 400 });
|
|
}
|
|
return json({ error: 'A git stack with this name already exists for this environment' }, { status: 400 });
|
|
}
|
|
return json({ error: 'Failed to create git stack' }, { status: 500 });
|
|
}
|
|
};
|