Files
gitops/ts/classes/connectionmanager.ts
Juergen Kunz e3f67d12a3 fix(core): fix secrets scan upserts, connection health checks, and frontend improvements
- Add upsert pattern to SecretsScanService to prevent duplicate key errors on repeated scans
- Auto-test connection health on startup so status reflects reality
- Fix Actions view to read identity from appstate instead of broken localStorage hack
- Fetch both project and group secrets in parallel, add "All Scopes" filter to Secrets view
- Enable noCache on UtilityWebsiteServer to prevent stale browser cache
2026-02-24 22:50:26 +00:00

207 lines
7.6 KiB
TypeScript

import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import type * as interfaces from '../../ts_interfaces/index.ts';
import { BaseProvider, GiteaProvider, GitLabProvider } from '../providers/index.ts';
import type { StorageManager } from '../storage/index.ts';
const LEGACY_CONNECTIONS_FILE = './.nogit/connections.json';
const CONNECTIONS_PREFIX = '/connections/';
const KEYCHAIN_PREFIX = 'keychain:';
/**
* Manages provider connections — persists each connection as an
* individual JSON file via StorageManager. Tokens are stored in
* the OS keychain (or encrypted file fallback) via SmartSecret.
*/
export class ConnectionManager {
private connections: interfaces.data.IProviderConnection[] = [];
private storageManager: StorageManager;
private smartSecret: plugins.smartsecret.SmartSecret;
/** Resolves when background connection health checks complete */
public healthCheckDone: Promise<void> = Promise.resolve();
constructor(storageManager: StorageManager, smartSecret: plugins.smartsecret.SmartSecret) {
this.storageManager = storageManager;
this.smartSecret = smartSecret;
}
async init(): Promise<void> {
await this.migrateLegacyFile();
await this.loadConnections();
// Auto-test all connections in the background
this.healthCheckDone = this.testAllConnections();
}
/**
* Tests all loaded connections in the background and updates their status.
* Fire-and-forget — does not block startup.
*/
private async testAllConnections(): Promise<void> {
for (const conn of this.connections) {
try {
const provider = this.getProvider(conn.id);
const result = await provider.testConnection();
conn.status = result.ok ? 'connected' : 'error';
await this.persistConnection(conn);
} catch {
conn.status = 'error';
}
}
}
/**
* One-time migration from the legacy .nogit/connections.json file.
*/
private async migrateLegacyFile(): Promise<void> {
try {
const text = await Deno.readTextFile(LEGACY_CONNECTIONS_FILE);
const legacy: interfaces.data.IProviderConnection[] = JSON.parse(text);
if (legacy.length > 0) {
logger.info(`Migrating ${legacy.length} connection(s) from legacy file...`);
for (const conn of legacy) {
await this.storageManager.setJSON(`${CONNECTIONS_PREFIX}${conn.id}.json`, conn);
}
// Rename legacy file so migration doesn't repeat
await Deno.rename(LEGACY_CONNECTIONS_FILE, LEGACY_CONNECTIONS_FILE + '.migrated');
logger.success('Legacy connections migrated successfully');
}
} catch {
// No legacy file or already migrated — nothing to do
}
}
private async loadConnections(): Promise<void> {
const keys = await this.storageManager.list(CONNECTIONS_PREFIX);
this.connections = [];
for (const key of keys) {
const conn = await this.storageManager.getJSON<interfaces.data.IProviderConnection>(key);
if (conn) {
if (conn.token.startsWith(KEYCHAIN_PREFIX)) {
// Token is in keychain — retrieve it
const realToken = await this.smartSecret.getSecret(conn.id);
if (realToken) {
conn.token = realToken;
} else {
logger.warn(`Could not retrieve token for connection ${conn.id} from keychain`);
}
} else if (conn.token && conn.token !== '***') {
// Plaintext token found — auto-migrate to keychain
await this.migrateTokenToKeychain(conn);
}
this.connections.push(conn);
}
}
if (this.connections.length > 0) {
logger.info(`Loaded ${this.connections.length} connection(s)`);
} else {
logger.debug('No existing connections found, starting fresh');
}
}
/**
* Migrates a plaintext token to keychain storage.
*/
private async migrateTokenToKeychain(
conn: interfaces.data.IProviderConnection,
): Promise<void> {
try {
await this.smartSecret.setSecret(conn.id, conn.token);
// Save sentinel to JSON file
const jsonConn = { ...conn, token: `${KEYCHAIN_PREFIX}${conn.id}` };
await this.storageManager.setJSON(`${CONNECTIONS_PREFIX}${conn.id}.json`, jsonConn);
logger.info(`Migrated token for connection "${conn.name}" to keychain`);
} catch (err) {
logger.warn(`Failed to migrate token for ${conn.id} to keychain: ${err}`);
}
}
private async persistConnection(conn: interfaces.data.IProviderConnection): Promise<void> {
// Store real token in keychain
await this.smartSecret.setSecret(conn.id, conn.token);
// Save JSON with sentinel value
const jsonConn = { ...conn, token: `${KEYCHAIN_PREFIX}${conn.id}` };
await this.storageManager.setJSON(`${CONNECTIONS_PREFIX}${conn.id}.json`, jsonConn);
}
private async removeConnection(id: string): Promise<void> {
await this.smartSecret.deleteSecret(id);
await this.storageManager.delete(`${CONNECTIONS_PREFIX}${id}.json`);
}
getConnections(): interfaces.data.IProviderConnection[] {
return this.connections.map((c) => ({ ...c, token: '***' }));
}
getConnection(id: string): interfaces.data.IProviderConnection | undefined {
return this.connections.find((c) => c.id === id);
}
async createConnection(
name: string,
providerType: interfaces.data.TProviderType,
baseUrl: string,
token: string,
): Promise<interfaces.data.IProviderConnection> {
const connection: interfaces.data.IProviderConnection = {
id: crypto.randomUUID(),
name,
providerType,
baseUrl: baseUrl.replace(/\/+$/, ''),
token,
createdAt: Date.now(),
status: 'disconnected',
};
this.connections.push(connection);
await this.persistConnection(connection);
logger.success(`Connection created: ${name} (${providerType})`);
return { ...connection, token: '***' };
}
async updateConnection(
id: string,
updates: { name?: string; baseUrl?: string; token?: string },
): Promise<interfaces.data.IProviderConnection> {
const conn = this.connections.find((c) => c.id === id);
if (!conn) throw new Error(`Connection not found: ${id}`);
if (updates.name) conn.name = updates.name;
if (updates.baseUrl) conn.baseUrl = updates.baseUrl.replace(/\/+$/, '');
if (updates.token) conn.token = updates.token;
await this.persistConnection(conn);
return { ...conn, token: '***' };
}
async deleteConnection(id: string): Promise<void> {
const idx = this.connections.findIndex((c) => c.id === id);
if (idx === -1) throw new Error(`Connection not found: ${id}`);
this.connections.splice(idx, 1);
await this.removeConnection(id);
logger.info(`Connection deleted: ${id}`);
}
async testConnection(id: string): Promise<{ ok: boolean; error?: string }> {
const provider = this.getProvider(id);
const result = await provider.testConnection();
const conn = this.connections.find((c) => c.id === id)!;
conn.status = result.ok ? 'connected' : 'error';
await this.persistConnection(conn);
return result;
}
/**
* Factory: returns the correct provider instance for a connection ID
*/
getProvider(connectionId: string): BaseProvider {
const conn = this.connections.find((c) => c.id === connectionId);
if (!conn) throw new Error(`Connection not found: ${connectionId}`);
switch (conn.providerType) {
case 'gitea':
return new GiteaProvider(conn.id, conn.baseUrl, conn.token);
case 'gitlab':
return new GitLabProvider(conn.id, conn.baseUrl, conn.token);
default:
throw new Error(`Unknown provider type: ${conn.providerType}`);
}
}
}