diff --git a/CHANGELOG.md b/CHANGELOG.md index b9278e51..5860788c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +* **stacks:** resolve permission denied error when deleting stacks with root-owned files. Sencho now falls back to Docker-based cleanup when the normal deletion fails due to EACCES/EPERM, handling directories created by Docker Compose as root without requiring elevated privileges. +* **stacks:** `docker compose down` during stack deletion now includes `--volumes --remove-orphans` to let Docker clean up its own resources before filesystem removal. + ## [0.19.0](https://github.com/AnsoCode/Sencho/compare/v0.18.0...v0.19.0) (2026-03-30) diff --git a/backend/src/__tests__/filesystem.test.ts b/backend/src/__tests__/filesystem.test.ts new file mode 100644 index 00000000..a67a86e6 --- /dev/null +++ b/backend/src/__tests__/filesystem.test.ts @@ -0,0 +1,175 @@ +/** + * Unit tests for FileSystemService.deleteStack() including the + * Docker-based fallback for permission-denied scenarios. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import path from 'path'; +import { EventEmitter } from 'events'; + +const { mockSpawn, mockRm, mockRmdir } = vi.hoisted(() => ({ + mockSpawn: vi.fn(), + mockRm: vi.fn(), + mockRmdir: vi.fn(), +})); + +vi.mock('child_process', () => ({ spawn: mockSpawn })); + +vi.mock('fs', () => ({ + promises: { + rm: mockRm, + rmdir: mockRmdir, + mkdir: vi.fn(), + readdir: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), + access: vi.fn(), + stat: vi.fn(), + rename: vi.fn(), + copyFile: vi.fn(), + unlink: vi.fn(), + }, +})); + +vi.mock('../services/NodeRegistry', () => ({ + NodeRegistry: { + getInstance: () => ({ + getComposeDir: () => '/test/compose', + getDefaultNodeId: () => 1, + }), + }, +})); + +import { FileSystemService } from '../services/FileSystemService'; + +function createMockProcess() { + const proc = new EventEmitter() as EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + kill: ReturnType; + }; + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + proc.kill = vi.fn(); + return proc; +} + +const expectedDir = path.join('/test/compose', 'my-stack'); + +describe('FileSystemService.deleteStack', () => { + let service: FileSystemService; + + beforeEach(() => { + vi.clearAllMocks(); + service = FileSystemService.getInstance(); + }); + + it('deletes a stack directory successfully via fsPromises.rm', async () => { + mockRm.mockResolvedValueOnce(undefined); + await service.deleteStack('my-stack'); + expect(mockRm).toHaveBeenCalledWith(expectedDir, { recursive: true, force: true }); + }); + + it('silently ignores ENOENT (directory already gone)', async () => { + const err = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + mockRm.mockRejectedValueOnce(err); + await expect(service.deleteStack('gone-stack')).resolves.toBeUndefined(); + }); + + it('falls back to Docker removal on EACCES', async () => { + const err = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + mockRm.mockRejectedValueOnce(err); + mockRmdir.mockResolvedValueOnce(undefined); + + const proc = createMockProcess(); + mockSpawn.mockReturnValueOnce(proc); + + const promise = service.deleteStack('restricted-stack'); + // Emit close asynchronously so listeners are attached first + await vi.waitFor(() => { + expect(mockSpawn).toHaveBeenCalled(); + }); + proc.emit('close', 0); + + await expect(promise).resolves.toBeUndefined(); + expect(mockSpawn).toHaveBeenCalledWith( + 'docker', + expect.arrayContaining(['run', '--rm', '-v', expect.stringContaining(':/cleanup'), 'alpine', 'rm', '-rf', '/cleanup']), + expect.objectContaining({ env: expect.any(Object) }), + ); + }); + + it('falls back to Docker removal on EPERM', async () => { + const err = Object.assign(new Error('operation not permitted'), { code: 'EPERM' }); + mockRm.mockRejectedValueOnce(err); + mockRmdir.mockResolvedValueOnce(undefined); + + const proc = createMockProcess(); + mockSpawn.mockReturnValueOnce(proc); + + const promise = service.deleteStack('eperm-stack'); + await vi.waitFor(() => { + expect(mockSpawn).toHaveBeenCalled(); + }); + proc.emit('close', 0); + + await expect(promise).resolves.toBeUndefined(); + }); + + it('throws descriptive error when Docker fallback fails', async () => { + const err = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + mockRm.mockRejectedValueOnce(err); + + const proc = createMockProcess(); + mockSpawn.mockReturnValueOnce(proc); + + const promise = service.deleteStack('stuck-stack'); + await vi.waitFor(() => { + expect(mockSpawn).toHaveBeenCalled(); + }); + proc.stderr.emit('data', Buffer.from('container error')); + proc.emit('close', 1); + + await expect(promise).rejects.toThrow(/Docker cleanup exited with code 1/); + }); + + it('throws descriptive error when Docker is unavailable', async () => { + const err = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + mockRm.mockRejectedValueOnce(err); + + const proc = createMockProcess(); + mockSpawn.mockReturnValueOnce(proc); + + const promise = service.deleteStack('no-docker-stack'); + await vi.waitFor(() => { + expect(mockSpawn).toHaveBeenCalled(); + }); + proc.emit('error', new Error('spawn docker ENOENT')); + + await expect(promise).rejects.toThrow(/could not run Docker for cleanup/); + }); + + it('still succeeds if rmdir of empty shell fails after Docker cleanup', async () => { + const err = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + mockRm.mockRejectedValueOnce(err); + mockRmdir.mockRejectedValueOnce(new Error('rmdir failed')); + + const proc = createMockProcess(); + mockSpawn.mockReturnValueOnce(proc); + + const promise = service.deleteStack('partial-cleanup'); + await vi.waitFor(() => { + expect(mockSpawn).toHaveBeenCalled(); + }); + proc.emit('close', 0); + + await expect(promise).resolves.toBeUndefined(); + }); + + it('throws on unexpected errors (not ENOENT/EACCES/EPERM)', async () => { + const err = Object.assign(new Error('disk I/O error'), { code: 'EIO' }); + mockRm.mockRejectedValueOnce(err); + + await expect(service.deleteStack('io-error-stack')).rejects.toThrow(/disk I\/O error/); + expect(mockSpawn).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts index 0f3bc2a8..260dddb0 100644 --- a/backend/src/services/ComposeService.ts +++ b/backend/src/services/ComposeService.ts @@ -378,7 +378,7 @@ export class ComposeService { public async downStack(stackName: string): Promise { const stackPath = path.join(this.baseDir, stackName); try { - await this.execute('docker', ['compose', 'down'], stackPath, undefined, false); + await this.execute('docker', ['compose', 'down', '--volumes', '--remove-orphans'], stackPath, undefined, false); } catch (error) { console.warn(`[Teardown] Docker down failed or nothing to clean up for ${stackName}`); } diff --git a/backend/src/services/FileSystemService.ts b/backend/src/services/FileSystemService.ts index bac6822a..8b07a11f 100644 --- a/backend/src/services/FileSystemService.ts +++ b/backend/src/services/FileSystemService.ts @@ -1,5 +1,6 @@ import path from 'path'; import { promises as fsPromises } from 'fs'; +import { spawn } from 'child_process'; import { NodeRegistry } from './NodeRegistry'; /** @@ -174,14 +175,73 @@ export class FileSystemService { try { await fsPromises.rm(stackDir, { recursive: true, force: true }); console.log('Stack deleted successfully:', stackName); - } catch (error: any) { - if (error.code !== 'ENOENT') { - console.error('Error deleting stack directory:', error.message); - throw new Error(`Failed to delete stack directory: ${error.message}`); + } catch (error: unknown) { + const fsError = error as NodeJS.ErrnoException; + if (fsError.code === 'ENOENT') return; + + if (fsError.code === 'EACCES' || fsError.code === 'EPERM') { + console.warn( + `[FileSystemService] Permission denied deleting ${stackName}, falling back to Docker-based removal` + ); + await this.forceDeleteViaDocker(stackDir); + // Docker removes contents but can't remove its own mount point; clean up the empty shell + try { + await fsPromises.rmdir(stackDir); + } catch { + console.warn(`[FileSystemService] Could not remove empty directory ${stackDir} — may need manual cleanup`); + } + console.log('Stack deleted successfully (via Docker fallback):', stackName); + } else { + console.error('Error deleting stack directory:', fsError.message); + throw new Error(`Failed to delete stack directory: ${fsError.message}`); } } } + private forceDeleteViaDocker(dirPath: string): Promise { + return new Promise((resolve, reject) => { + const timeout = 30_000; + const child = spawn('docker', [ + 'run', '--rm', + '-v', `${dirPath}:/cleanup`, + 'alpine', + 'rm', '-rf', '/cleanup' + ], { + env: { + ...process.env, + PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' + } + }); + + let stderr = ''; + child.stderr.on('data', (data: Buffer) => { stderr += data.toString(); }); + + const timer = setTimeout(() => { + child.kill(); + reject(new Error( + `Docker-based deletion timed out after 30s. You may need to manually remove the directory: ${dirPath}` + )); + }, timeout); + + child.on('close', (code: number | null) => { + clearTimeout(timer); + if (code === 0) resolve(); + else reject(new Error( + `Failed to delete stack directory — Docker cleanup exited with code ${code}${stderr ? ': ' + stderr.trim() : ''}. ` + + `You may need to manually remove the directory: ${dirPath}` + )); + }); + + child.on('error', (err: Error) => { + clearTimeout(timer); + reject(new Error( + `Failed to delete stack directory — could not run Docker for cleanup: ${err.message}. ` + + `You may need to manually remove the directory: ${dirPath}` + )); + }); + }); + } + getBaseDir(): string { return this.baseDir; } diff --git a/docs/features/stack-management.mdx b/docs/features/stack-management.mdx index 168d4c19..86a8e8e4 100644 --- a/docs/features/stack-management.mdx +++ b/docs/features/stack-management.mdx @@ -49,12 +49,16 @@ The stack header exposes four actions: | **Stop** | `docker compose stop` | Stops containers without removing them. State is preserved. | | **Restart** | `docker compose restart` | Restarts all containers in the stack. | | **Update** | `docker compose pull` + `up -d` | Pulls the latest image tags and recreates containers. | -| **Delete** | `down` + removes files | Stops and removes containers, then deletes the stack directory. | +| **Delete** | `down --volumes` + removes files | Stops and removes containers and volumes, then deletes the stack directory. | **Delete** is irreversible. It removes the stack directory - including `compose.yaml`, `.env`, and any bind-mounted files stored there. Back up important files before deleting. + + Stack directories often contain root-owned files — either because Docker Compose creates them, or because the stacks were originally set up outside of Sencho with `sudo`. Sencho handles this automatically: if a normal deletion fails due to permissions, it uses Docker to clean up root-owned files. No manual intervention is needed in the standard setup. + + ## Stack context menu Right-click or use the **⋮** button on any stack in the sidebar to access: diff --git a/docs/operations/troubleshooting.mdx b/docs/operations/troubleshooting.mdx index 55daf2f0..6fe5d7d6 100644 --- a/docs/operations/troubleshooting.mdx +++ b/docs/operations/troubleshooting.mdx @@ -65,6 +65,26 @@ If the group is not `docker`, the auto-detection still works - Sencho reads the --- +## "Permission denied" when deleting a stack + +**Symptom:** Clicking Delete on a stack fails with a permission error mentioning `EACCES`. + +**Cause:** Stack directories often contain files owned by root — for example when Docker Compose was run with `sudo`, or when containers write config/data files into the stack directory. Since Sencho runs as a non-root user, the OS denies the removal. + +This is especially common when installing Sencho on a server where stacks were originally created outside of Sencho (e.g. via `sudo docker compose up`). Those directories and their contents are root-owned, but Sencho can still delete them. + +**How Sencho handles it:** Sencho automatically detects permission errors during deletion and falls back to a Docker-based cleanup. It spawns a short-lived Alpine container that bind-mounts the stack directory and removes the root-owned files. This happens transparently — no manual intervention is needed in the standard Docker setup. + +**If automatic cleanup fails:** The error message will include the directory path. Remove it manually: + +```bash +sudo rm -rf /path/to/your/compose/dir/stack-name +``` + +**Prerequisites:** The Docker socket must be mounted (standard setup). If Sencho cannot access Docker, the fallback will not work — see [Permission denied on the Docker socket](#permission-denied-on-the-docker-socket). + +--- + ## Login page shows "Something went wrong" **Symptom:** You enter credentials and get a generic error instead of being logged in.