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:
Anso
2026-04-14 17:58:57 -04:00
committed by GitHub
parent aeb8adc5f5
commit 6275adc6b3
7 changed files with 1088 additions and 136 deletions
+21 -10
View File
@@ -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);