feat(registries): add private registry credential management (Team Pro) (#240)

Add centralized credential storage for private Docker registries with
support for Docker Hub, GHCR, AWS ECR, and self-hosted registries.

- New `registries` table with AES-256-GCM encrypted secrets
- RegistryService with CRUD, test connectivity, Docker config generation
- 5 API endpoints gated by requireTeamPro + requireAdmin
- ComposeService injects credentials via temp DOCKER_CONFIG on deploy/pull
- ImageUpdateService passes stored credentials for private registry checks
- AWS ECR just-in-time token refresh via @aws-sdk/client-ecr
- RegistriesSection UI in Settings Hub with type-aware form
- Documentation with screenshots
This commit is contained in:
Anso
2026-03-29 12:56:30 -04:00
committed by GitHub
parent 362b4a43d0
commit 244c83a0c3
16 changed files with 2317 additions and 20 deletions
+70
View File
@@ -167,6 +167,20 @@ export interface ScheduledTaskRun {
triggered_by: 'scheduler' | 'manual';
}
export type RegistryType = 'dockerhub' | 'ghcr' | 'ecr' | 'custom';
export interface Registry {
id: number;
name: string;
url: string;
type: RegistryType;
username: string;
secret: string;
aws_region: string | null;
created_at: number;
updated_at: number;
}
export class DatabaseService {
private static instance: DatabaseService;
private db: Database.Database;
@@ -186,6 +200,7 @@ export class DatabaseService {
this.migrateAdminToUsersTable();
this.migrateEncryptNodeTokens();
this.migrateSSOColumns();
this.migrateRegistries();
}
public static getInstance(): DatabaseService {
@@ -498,6 +513,22 @@ export class DatabaseService {
`);
}
private migrateRegistries(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS registries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
url TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'custom',
username TEXT NOT NULL DEFAULT '',
secret TEXT NOT NULL DEFAULT '',
aws_region TEXT DEFAULT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
`);
}
// --- Agents ---
public getAgents(): Agent[] {
@@ -1083,6 +1114,45 @@ export class DatabaseService {
this.db.prepare('UPDATE api_tokens SET last_used_at = ? WHERE id = ?').run(Date.now(), id);
}
// --- Registries ---
public getRegistries(): Registry[] {
return this.db.prepare('SELECT * FROM registries ORDER BY name ASC').all() as Registry[];
}
public getRegistry(id: number): Registry | undefined {
return this.db.prepare('SELECT * FROM registries WHERE id = ?').get(id) as Registry | undefined;
}
public addRegistry(reg: Omit<Registry, 'id'>): number {
const result = this.db.prepare(
'INSERT INTO registries (name, url, type, username, secret, aws_region, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).run(reg.name, reg.url, reg.type, reg.username, reg.secret, reg.aws_region, reg.created_at, reg.updated_at);
return result.lastInsertRowid as number;
}
public updateRegistry(id: number, updates: Partial<Omit<Registry, 'id' | 'created_at'>>): void {
const fields: string[] = [];
const values: unknown[] = [];
if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); }
if (updates.url !== undefined) { fields.push('url = ?'); values.push(updates.url); }
if (updates.type !== undefined) { fields.push('type = ?'); values.push(updates.type); }
if (updates.username !== undefined) { fields.push('username = ?'); values.push(updates.username); }
if (updates.secret !== undefined) { fields.push('secret = ?'); values.push(updates.secret); }
if (updates.aws_region !== undefined) { fields.push('aws_region = ?'); values.push(updates.aws_region); }
if (updates.updated_at !== undefined) { fields.push('updated_at = ?'); values.push(updates.updated_at); }
if (fields.length === 0) return;
values.push(id);
this.db.prepare(`UPDATE registries SET ${fields.join(', ')} WHERE id = ?`).run(...values);
}
public deleteRegistry(id: number): void {
this.db.prepare('DELETE FROM registries WHERE id = ?').run(id);
}
// --- Scheduled Tasks ---
public getScheduledTasks(): ScheduledTask[] {