mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-01 05:07:59 +00:00
feat(registries): harden Private Registry Credentials feature (#597)
* feat(registries): add stateless test endpoint, ECR caching, URL and host hardening Adds a POST /api/registries/test endpoint so credentials can be verified before being persisted. Caches ECR authorization tokens in memory until their AWS-reported expiry (minus a safety margin) instead of fetching on every compose invocation. Normalizes registry URLs on save so the stored values match the keys Docker expects in ~/.docker/config.json, fixes a bidirectional host-match bug in getAuthForRegistry that could cross-match overlapping hostnames, and surfaces per-registry decryption failures as warnings in the deploy log stream instead of swallowing them. Also strips the Authorization header on cross-host redirects in the test probe, rejects non-http(s) schemes on save, and validates the shape of returned ECR authorization tokens before use. * refactor(registries): align UI with design system and add in-form test button Swaps the registry type dropdown from shadcn Select to the project's Combobox, applies the canonical card bevel and top-border hover styling to the form container and each registry row, restyles the delete button to the ghost + muted destructive pattern, uses strokeWidth 1.5 on every Lucide icon, and routes all toast errors through the standard defensive chain. Adds a Test connection button inside the form so credentials can be verified before saving. * test(registries): cover RegistryService and deploy warnings surface Adds unit coverage for URL normalization, the encrypt/decrypt round trip through create and resolveDockerConfig, exact-host matching in getAuthForRegistry, resolveDockerConfig warnings on decryption failure, ECR token cache hit/miss and invalidation on update, the stateless testWithCredentials path for 200, 401 with and without a challenge, network errors, and ECR success and failure including malformed tokens. Extends the ComposeService tests to verify that warnings from resolveDockerConfig reach the deploy log stream. * docs(registries): document test-before-save flow and troubleshooting Describes the in-form Test connection button, the two-point testing flow from the registries list, the cached ECR token behavior during deploys, the per-registry warning Sencho emits when a stored secret cannot be decrypted, and adds a Troubleshooting section covering common 401 causes, ECR token handling, warning interpretation, and per-node credential scoping.
This commit is contained in:
@@ -80,7 +80,10 @@ export class ComposeService {
|
||||
});
|
||||
}
|
||||
|
||||
private async withRegistryAuth<T>(fn: (env: Record<string, string | undefined>) => Promise<T>): Promise<T> {
|
||||
private async withRegistryAuth<T>(
|
||||
fn: (env: Record<string, string | undefined>) => Promise<T>,
|
||||
sendOutput?: (data: string) => void,
|
||||
): Promise<T> {
|
||||
const registries = DatabaseService.getInstance().getRegistries();
|
||||
if (registries.length === 0) {
|
||||
return fn({
|
||||
@@ -89,21 +92,29 @@ export class ComposeService {
|
||||
});
|
||||
}
|
||||
|
||||
const dockerConfig = await RegistryService.getInstance().resolveDockerConfig();
|
||||
const { config, warnings } = await RegistryService.getInstance().resolveDockerConfig();
|
||||
if (warnings.length > 0 && sendOutput) {
|
||||
for (const warning of warnings) {
|
||||
sendOutput(`[Sencho] Warning: ${warning}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-docker-'));
|
||||
const configPath = path.join(tmpDir, 'config.json');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(configPath, JSON.stringify(dockerConfig), { mode: 0o600 });
|
||||
fs.writeFileSync(configPath, JSON.stringify(config), { mode: 0o600 });
|
||||
return await fn({
|
||||
...process.env,
|
||||
DOCKER_CONFIG: tmpDir,
|
||||
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
|
||||
});
|
||||
} finally {
|
||||
try { fs.unlinkSync(configPath); fs.rmdirSync(tmpDir); } catch (e) {
|
||||
// Best-effort cleanup: temp config dir may already be removed or locked
|
||||
console.warn('[ComposeService] Could not clean up temp Docker config dir:', (e as Error).message);
|
||||
// Best-effort cleanup; each step runs independently so a file that was never
|
||||
// written (e.g., writeFileSync threw) does not prevent the directory removal.
|
||||
try { fs.unlinkSync(configPath); } catch { /* file may not exist */ }
|
||||
try { fs.rmdirSync(tmpDir); } catch (e) {
|
||||
console.warn('[ComposeService] Could not remove temp Docker config dir:', (e as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,7 +158,7 @@ export class ComposeService {
|
||||
|
||||
await this.withRegistryAuth(async (env) => {
|
||||
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws, true, env);
|
||||
});
|
||||
}, sendOutput);
|
||||
|
||||
// Post-Deploy Health Probe
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
@@ -181,7 +192,7 @@ export class ComposeService {
|
||||
await fsSvc.restoreStackFiles(stackName);
|
||||
await this.withRegistryAuth(async (env) => {
|
||||
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws, true, env);
|
||||
});
|
||||
}, sendOutput);
|
||||
sendOutput('=== Rolled back successfully ===\n');
|
||||
} catch (rollbackError) {
|
||||
console.error(`Rollback failed for ${stackName}:`, rollbackError);
|
||||
@@ -341,7 +352,7 @@ export class ComposeService {
|
||||
|
||||
sendOutput('=== Recreating containers ===\n');
|
||||
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws, true, env);
|
||||
});
|
||||
}, sendOutput);
|
||||
|
||||
// Post-Update Health Probe
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
@@ -377,7 +388,7 @@ export class ComposeService {
|
||||
await fsSvc.restoreStackFiles(stackName);
|
||||
await this.withRegistryAuth(async (env) => {
|
||||
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws, true, env);
|
||||
});
|
||||
}, sendOutput);
|
||||
sendOutput('=== Rolled back successfully ===\n');
|
||||
} catch (rollbackError) {
|
||||
console.error(`Rollback failed for ${stackName}:`, rollbackError);
|
||||
|
||||
Reference in New Issue
Block a user