Skip to content

Commit 6a4df8f

Browse files
committed
add stack rename logic
1 parent d355608 commit 6a4df8f

File tree

3 files changed

+177
-44
lines changed

3 files changed

+177
-44
lines changed

src/lib/services/docker/stack-service.ts

Lines changed: 129 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -530,44 +530,67 @@ export async function getStack(stackId: string): Promise<Stack> {
530530
/**
531531
* Updates a stack with new configuration and/or .env file
532532
*/
533-
export async function updateStack(stackId: string, updates: StackUpdate): Promise<Stack> {
534-
// stackId is dirName
535-
const stackDir = await getStackDir(stackId);
536-
let composePath = await getComposeFilePath(stackId); // May be null if only .env is being updated and compose doesn't exist yet (edge case)
533+
export async function updateStack(currentStackId: string, updates: StackUpdate): Promise<Stack> {
534+
let effectiveStackId = currentStackId;
535+
let stackAfterRename: Stack | null = null;
536+
537+
// 1. Handle potential rename first
538+
if (updates.name) {
539+
const newSlugifiedName = slugify(updates.name, {
540+
lower: true,
541+
strict: true,
542+
replacement: '-',
543+
trim: true
544+
});
537545

538-
// If a name update is provided, it's ignored as the name is the directory name.
539-
if (updates.name && updates.name !== stackId) {
540-
console.warn(`Updating stack name via 'updateStack' is not supported. Stack name is derived from directory: ${stackId}.`);
546+
if (newSlugifiedName !== currentStackId) {
547+
console.log(`Rename requested for stack '${currentStackId}' to '${updates.name}' (slug: '${newSlugifiedName}').`);
548+
// renameStack will throw an error if the stack is running or if other rename conditions are not met.
549+
stackAfterRename = await renameStack(currentStackId, updates.name);
550+
effectiveStackId = stackAfterRename.id;
551+
console.log(`Stack '${currentStackId}' successfully renamed to '${effectiveStackId}'.`);
552+
} else {
553+
// Name provided is the same as current, or slugifies to the same. No rename action needed.
554+
console.log(`Provided name '${updates.name}' is effectively the same as current stack ID '${currentStackId}'. No rename action.`);
555+
}
541556
}
542557

543-
try {
544-
const promises = [];
558+
// 2. Handle content updates (compose or .env)
559+
// These updates will apply to the new directory if a rename occurred.
560+
let contentUpdated = false;
561+
const stackDirForContent = await getStackDir(effectiveStackId);
545562

546-
if (updates.composeContent !== undefined) {
547-
// Determine the correct compose file path to write to, defaulting to compose.yaml
548-
const targetComposePath = composePath || path.join(stackDir, 'compose.yaml');
549-
promises.push(fs.writeFile(targetComposePath, updates.composeContent, 'utf8'));
550-
composePath = targetComposePath; // Update composePath if it was initially null
551-
}
563+
const promises = [];
552564

553-
if (updates.envContent !== undefined) {
554-
promises.push(saveEnvFile(stackId, updates.envContent));
555-
}
565+
if (updates.composeContent !== undefined) {
566+
let currentComposePath = await getComposeFilePath(effectiveStackId); // Check existing, might be null
567+
const targetComposePath = currentComposePath || path.join(stackDirForContent, 'compose.yaml'); // Default to compose.yaml if not found
556568

557-
if (promises.length === 0 && !updates.name) {
558-
// Check if any actual file update was requested
559-
console.warn(`No content updates provided for stack ${stackId}.`);
560-
return getStack(stackId); // Return current stack data
561-
}
569+
promises.push(fs.writeFile(targetComposePath, updates.composeContent, 'utf8'));
570+
contentUpdated = true;
571+
console.log(`Updating composeContent for stack '${effectiveStackId}'.`);
572+
}
562573

574+
if (updates.envContent !== undefined) {
575+
promises.push(saveEnvFile(effectiveStackId, updates.envContent));
576+
contentUpdated = true;
577+
console.log(`Updating envContent for stack '${effectiveStackId}'.`);
578+
}
579+
580+
if (promises.length > 0) {
563581
await Promise.all(promises);
582+
}
564583

565-
// Re-fetch stack data to reflect changes and get updated modification times
566-
// This is simpler than trying to manually update parts of the Stack object.
567-
return getStack(stackId);
568-
} catch (err) {
569-
console.error(`Error updating stack ${stackId}:`, err);
570-
throw new Error(`Failed to update stack ${stackId}`);
584+
// 3. Return the final stack state
585+
if (stackAfterRename && !contentUpdated) {
586+
// Only a rename occurred, no subsequent content changes in this call.
587+
// The stackAfterRename object is fresh from renameStack's getStack call.
588+
return stackAfterRename;
589+
} else {
590+
// Content was updated, or no rename occurred but content might have.
591+
// Or both rename and content update occurred.
592+
// Fetch the latest stack details.
593+
return getStack(effectiveStackId);
571594
}
572595
}
573596

@@ -848,6 +871,83 @@ export async function removeStack(stackId: string): Promise<boolean> {
848871
}
849872
}
850873

874+
export async function renameStack(currentStackId: string, newName: string): Promise<Stack> {
875+
if (!currentStackId || !newName) {
876+
throw new Error('Current stack ID and new name must be provided.');
877+
}
878+
879+
const currentStackDir = await getStackDir(currentStackId);
880+
try {
881+
await fs.access(currentStackDir); // Check if current stack directory exists
882+
} catch (e) {
883+
throw new Error(`Stack with ID '${currentStackId}' not found at ${currentStackDir}.`);
884+
}
885+
886+
// Slugify the new name to create a valid base for the directory name
887+
const newDirBaseName = slugify(newName, {
888+
lower: true,
889+
strict: true,
890+
replacement: '-',
891+
trim: true
892+
});
893+
894+
if (newDirBaseName === currentStackId) {
895+
throw new Error(`The new name '${newName}' (resolves to '${newDirBaseName}') is effectively the same as the current stack ID '${currentStackId}'. No changes made.`);
896+
}
897+
898+
// Check if the stack is running
899+
const running = await isStackRunning(currentStackId);
900+
if (running) {
901+
throw new Error(`Stack '${currentStackId}' is currently running. Please stop it before renaming.`);
902+
}
903+
904+
const stacksDir = await ensureStacksDir();
905+
let newUniqueDirName = newDirBaseName;
906+
let counter = 1;
907+
const MAX_ATTEMPTS = 100; // Safety break for the loop
908+
909+
// Find a unique directory name that is not the currentStackId
910+
while (counter <= MAX_ATTEMPTS) {
911+
const pathToCheck = join(stacksDir, newUniqueDirName);
912+
const exists = await directoryExists(pathToCheck);
913+
914+
if (!exists && newUniqueDirName !== currentStackId) {
915+
break; // Found a suitable unique name
916+
}
917+
918+
// If it exists or it's the same as currentStackId, generate a new one
919+
newUniqueDirName = `${newDirBaseName}-${counter}`;
920+
counter++;
921+
}
922+
923+
if (counter > MAX_ATTEMPTS || newUniqueDirName === currentStackId || (await directoryExists(join(stacksDir, newUniqueDirName)))) {
924+
// This means after MAX_ATTEMPTS, we couldn't find a suitable unique name
925+
throw new Error(`Could not generate a unique directory name for '${newName}' that is different from '${currentStackId}' and does not already exist. Please try a different name.`);
926+
}
927+
928+
const newStackDir = join(stacksDir, newUniqueDirName);
929+
930+
try {
931+
console.log(`Renaming stack directory from '${currentStackDir}' to '${newStackDir}'...`);
932+
await fs.rename(currentStackDir, newStackDir);
933+
console.log(`Stack directory for '${currentStackId}' successfully renamed to '${newUniqueDirName}'.`);
934+
935+
// The stack was stopped. When it's started next using `startStack(newUniqueDirName)`,
936+
// dockerode-compose will use `newUniqueDirName` as the project name,
937+
// effectively creating a "new" project from Docker's perspective with the existing files.
938+
// Old Docker resources (containers, networks, volumes) tied to `currentStackId` will remain
939+
// until manually pruned or if they conflict and Docker handles it.
940+
941+
return await getStack(newUniqueDirName); // Return the stack info under its new ID/name
942+
} catch (err) {
943+
console.error(`Error renaming stack directory for '${currentStackId}' to '${newUniqueDirName}':`, err);
944+
// If fs.rename fails, the original directory should ideally still be there.
945+
// No complex rollback needed for fs.rename itself.
946+
const errorMessage = err instanceof Error ? err.message : String(err);
947+
throw new Error(`Failed to rename stack: ${errorMessage}`);
948+
}
949+
}
950+
851951
/**
852952
* The function `discoverExternalStacks` asynchronously discovers external Docker stacks and their
853953
* services, categorizing them based on their running status.

src/lib/utils/api.util.ts

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,40 @@
1-
import type { Result } from './try-catch';
1+
import type { Result } from './try-catch'; // Assuming Result<T, Error> is { data?: T, error?: Error }
22
import { toast } from 'svelte-sonner';
33
import { extractDockerErrorMessage } from '$lib/utils/errors.util';
44

5-
export function handleApiResultWithCallbacks<T>({ result, message, setLoadingState = () => {}, onSuccess, onError = () => {} }: { result: Result<T, Error>; message: string; setLoadingState?: (value: boolean) => void; onSuccess: (result: Result<T, Error>) => void; onError?: (result: Result<T, Error>) => void }) {
6-
setLoadingState(true);
5+
export function handleApiResultWithCallbacks<T>({
6+
result,
7+
message,
8+
setLoadingState = () => {},
9+
onSuccess,
10+
onError = () => {}
11+
}: {
12+
result: Result<T, Error>;
13+
message: string;
14+
setLoadingState?: (value: boolean) => void;
15+
onSuccess: (data: T) => void; // Changed: Expects unwrapped data T
16+
onError?: (error: Error) => void; // Changed: Expects unwrapped error Error
17+
}) {
18+
// Note: setLoadingState(true) is typically called *before* the async operation
19+
// that produces 'result'. Here, it's called after 'result' is available.
20+
// This means it will set loading to true, then immediately to false.
21+
// Consider moving setLoadingState(true) to before the API call if that's the intent.
22+
// For now, respecting its current placement within this utility.
23+
// setLoadingState(true); // If this was meant to indicate the start of callback processing.
24+
725
if (result.error) {
826
const dockerMsg = extractDockerErrorMessage(result.error);
9-
console.error(`onErrorCallback: ${message}:`, result.error);
27+
console.error(`API Error: ${message}:`, result.error);
1028
toast.error(`${message}: ${dockerMsg}`);
29+
onError(result.error); // Pass the unwrapped error
1130
setLoadingState(false);
12-
onError(result);
13-
return;
14-
} else if (result.data) {
15-
onSuccess(result);
31+
} else {
32+
// If there's no error, assume result.data contains T.
33+
// This handles cases where T might be void (result.data could be undefined)
34+
// or T might be null (result.data could be null).
35+
// The original `else if (result.data)` was a truthy check.
36+
// A more robust assumption is: if no error, it's a success.
37+
onSuccess(result.data as T); // Pass result.data (which should be of type T), casting for safety.
1638
setLoadingState(false);
1739
}
1840
}

src/routes/stacks/[stackId]/+page.svelte

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import StatusBadge from '$lib/components/badges/status-badge.svelte';
1212
import { statusVariantMap } from '$lib/types/statuses';
1313
import { capitalizeFirstLetter } from '$lib/utils/string.utils';
14-
import { invalidateAll } from '$app/navigation';
14+
import { invalidateAll, goto } from '$app/navigation';
1515
import { toast } from 'svelte-sonner';
1616
import YamlEditor from '$lib/components/yaml-editor.svelte';
1717
import EnvEditor from '$lib/components/env-editor.svelte';
@@ -58,19 +58,30 @@
5858
async function handleSaveChanges() {
5959
if (!stack || !hasChanges) return;
6060
61+
// Store the original stack ID before saving, in case it changes
62+
const currentStackId = stack.id;
63+
6164
handleApiResultWithCallbacks({
62-
result: await tryCatch(stackApi.save(stack.id, name, composeContent, envContent)),
65+
result: await tryCatch(stackApi.save(currentStackId, name, composeContent, envContent)),
6366
message: 'Failed to Save Stack',
6467
setLoadingState: (value) => (isLoading.saving = value),
65-
onSuccess: async () => {
66-
originalName = name;
68+
onSuccess: async (updatedStack) => {
69+
console.log('Stack save successful', updatedStack);
70+
toast.success('Stack updated successfully!');
71+
72+
// Update local state for "original" values to reset hasChanges
73+
originalName = updatedStack.name;
6774
originalComposeContent = composeContent;
6875
originalEnvContent = envContent;
6976
70-
console.log('Stack save successful');
71-
toast.success('Stack updated successfully!');
7277
await new Promise((resolve) => setTimeout(resolve, 200));
73-
await invalidateAll();
78+
79+
if (updatedStack && updatedStack.id !== currentStackId) {
80+
console.log(`Stack ID changed from ${currentStackId} to ${updatedStack.id}. Navigating...`);
81+
await goto(`/stacks/${name}`, { invalidateAll: true });
82+
} else {
83+
await invalidateAll();
84+
}
7485
}
7586
});
7687
}

0 commit comments

Comments
 (0)