mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 02:12:59 +00:00
9eb945a6f0
Every filesystem operation against user compose folders (save, create, deploy, update, rollback, template install, fleet snapshot restore) previously failed with EACCES whenever a stack container had chowned its own bind mount to another UID, which is extremely common with linuxserver/* images and anything that runs as root by default. Running Sencho as root eliminates the entire class of permission bugs at the source and matches the default posture of Portainer, Dockge, Komodo, and Yacht. Mounting /var/run/docker.sock is already equivalent to root-on-host, so the previous non-root hardening provided essentially no additional isolation while breaking real features. Changes: - docker-entrypoint.sh: default path stays root, no GID dance, no privilege drop. Opt-out via SENCHO_USER=sencho restores the legacy behavior bit-for-bit (chown data dir, match Docker socket GID, su-exec to the user). Fails fast if SENCHO_USER names a nonexistent account. Kubernetes / OpenShift forced-non-root compat preserved via the existing id -u = 0 guard. - FileSystemService: delete forceDeleteViaDocker (the ~40-line helper that shelled out to an alpine container to work around EACCES during deleteStack) and simplify deleteStack to a single fsPromises.rm call. Tests updated accordingly. - Dockerfile: keep the sencho user+group pre-created so the opt-out path works out of the box; comments updated to document the new default. - Docs: new "Container user" section in configuration.mdx documenting the root default and the SENCHO_USER opt-out; troubleshooting and self-hosting updated to match.
82 lines
2.5 KiB
TypeScript
82 lines
2.5 KiB
TypeScript
/**
|
|
* Unit tests for FileSystemService.deleteStack().
|
|
*
|
|
* Sencho runs as root inside the container by default, so deleteStack only
|
|
* needs to wrap fsPromises.rm and translate ENOENT into a silent no-op.
|
|
* Permission errors are surfaced to the caller like any other failure
|
|
* (no Docker-helper fallback).
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import path from 'path';
|
|
|
|
const { mockRm } = vi.hoisted(() => ({
|
|
mockRm: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('fs', () => ({
|
|
promises: {
|
|
rm: mockRm,
|
|
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';
|
|
|
|
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('throws on EACCES (running as root should make this rare)', async () => {
|
|
const err = Object.assign(new Error('permission denied'), { code: 'EACCES' });
|
|
mockRm.mockRejectedValueOnce(err);
|
|
await expect(service.deleteStack('restricted-stack')).rejects.toThrow(/permission denied/);
|
|
});
|
|
|
|
it('throws on EPERM', async () => {
|
|
const err = Object.assign(new Error('operation not permitted'), { code: 'EPERM' });
|
|
mockRm.mockRejectedValueOnce(err);
|
|
await expect(service.deleteStack('eperm-stack')).rejects.toThrow(/operation not permitted/);
|
|
});
|
|
|
|
it('throws on unexpected errors (e.g. EIO)', 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/);
|
|
});
|
|
});
|