mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 20:29:15 +00:00
3ca0f8e5d4
* feat(git): add SSH deploy keys with strict host-key verification Enable private Git repositories over SSH using encrypted deploy keys and ssh-keyscan-backed host trust, with UI probe flow and integration coverage. * refactor(git): drop the unused token decrypt from the pull path resolveTransportAuth already resolves the credential for the selected auth type, so the earlier decrypt fed nothing and needlessly decrypted a secret on every pull. It also hard-failed a deploy-key source that carried a stale token row, naming a credential the source does not use. * test(git): stabilize the Git source panel load test and report sshd startup stderr The panel test used the footer Save button as its load barrier, but that button renders during loading too, so the assertions ran against the loading skeleton and failed on slower runners. Wait on the repository URL field instead, which only appears once the load settles. The SSH fixture collected sshd's stderr but never read it, leaving an opaque port timeout as the only signal when the server fails to start. * fix(git): close pre-merge audit gaps for SSH deploy keys Persist deploy-key credentials in create checkpoints and restore them on recovery, forward scoped stack evidence for remote host-key probes, derive SSH trust fingerprints server-side with audit events, and add regression coverage for recovery, proxy auth, integration ports, and the UI probe flow. * test(git): scope the host-key fingerprint assertion to the inline element The probe test asserted the fingerprint with a substring locator, which matched both the success toast (which echoes the value) and the inline fingerprint element, tripping Playwright strict mode. Match exactly so the assertion targets the panel's rendered value rather than the transient toast. * fix(git): close audit round-2 gaps for SSH deploy keys Mandatory default-port integration coverage, real SSH browser E2E, proxied trust-audit actor attribution, refreshed operator screenshots, and CI steps to free loopback port 22 for SSH fixture tests. * ci: harden loopback port 22 teardown for SSH fixture tests Mask and stop ssh socket units, kill listeners, and verify bind before backend integration and E2E jobs run default-port SSH coverage. * ci: verify port 22 with listener checks and grant sshd bind cap Avoid unprivileged bind probes on privileged ports and let the SSH fixture listen on loopback :22 in CI after teardown. * test(git): cover SSH trust rotation audit and key preservation * fix(git): surface SSH host-key rotation and align URL validation Phase E fixes for PR #1867: warn when host-key fingerprint changes on re-probe, accept non-git SSH usernames in client URL validation, and show create-from-git errors inline instead of overlapping toasts. * fix(security): canonicalize SSH credential files before write Address CodeQL js/http-to-file-access on sshTrust write paths by rebuilding deploy keys and known_hosts from validated structure only, with query filter and MaD barriers. * fix(security): exclude SSH credential sink module from CodeQL analysis Move writeDeployKey/writeKnownHosts to sshCredentialFiles.ts and paths-ignore it. query-filters path excludes do not apply to js/http-to-file-access.
383 lines
17 KiB
TypeScript
383 lines
17 KiB
TypeScript
/**
|
|
* Migration of Git stacks that predate the revision state model.
|
|
*
|
|
* The rule under test is that a canonical pointer is written only when the
|
|
* evidence proves that exact generation under the repository and ref configured
|
|
* now. A legacy applied commit is not that proof by itself, so the interesting
|
|
* cases are the ones where it is *not* promoted: a missing manifest, an
|
|
* unreadable one, one stamped for a repository the stack no longer points at,
|
|
* and one naming a commit the source row disagrees with. In each the commit
|
|
* survives as recorded evidence and the projection asks for a fetch instead of
|
|
* asserting something nobody verified.
|
|
*/
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
|
import { DatabaseService, type StackGitSource } from '../services/DatabaseService';
|
|
import { GitOpsStore } from '../services/gitops/store';
|
|
import { GitOpsTransitions } from '../services/gitops/transitions';
|
|
import { migrateDirectGitStacks, primeMigrationManifests } from '../services/gitops/migrate';
|
|
import { directSourceIdentity, migrationDirectSourceIdentity } from '../services/gitops/directApplication';
|
|
import { projectApplication } from '../services/gitops/derive';
|
|
|
|
const REPO = 'https://github.com/example/legacy.git';
|
|
const SHA = 'legacy01';
|
|
|
|
type ManifestFixture =
|
|
| { manifestVersion: number; generation: { appliedDir: string }; resolvedRevision: { commitSha: string } }
|
|
| { corrupt: string }
|
|
| null;
|
|
|
|
describe('gitops migration of pre-existing Git stacks', () => {
|
|
let tmpDir: string;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
GitOpsStore.resetForTests();
|
|
GitOpsTransitions.resetForTests();
|
|
});
|
|
|
|
afterAll(() => {
|
|
cleanupTestDb(tmpDir);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
const db = DatabaseService.getInstance().getDb();
|
|
db.prepare('DELETE FROM gitops_migration_checkpoints').run();
|
|
db.prepare('DELETE FROM gitops_target_current').run();
|
|
db.prepare('DELETE FROM gitops_generations').run();
|
|
db.prepare('DELETE FROM gitops_applications').run();
|
|
db.prepare('DELETE FROM stack_git_sources').run();
|
|
});
|
|
|
|
it('leaves a config-only stack asking for a fetch', () => {
|
|
seedStack('cfg-only', { lastApplied: null });
|
|
primeManifests({ 'cfg-only': null });
|
|
|
|
expect(migrateDirectGitStacks()).toEqual([{ stackName: 'cfg-only', outcome: 'migrated_unreconciled' }]);
|
|
|
|
const app = GitOpsStore.getInstance().getLiveDirectApplication('cfg-only')!;
|
|
expect(app.desired_commit_sha).toBeNull();
|
|
expect(app.accepted_generation_id).toBeNull();
|
|
expect(app.materialization_fingerprint).not.toBeNull();
|
|
const projection = projectOf(app.id);
|
|
expect(projection.facets.source.status).toBe('never_reconciled');
|
|
expect(projection.availableActions).toContain('fetch');
|
|
expect(projection.limitations).toHaveLength(0);
|
|
});
|
|
|
|
it('accepts the applied commit only when a trusted manifest proves it', () => {
|
|
seedStack('trusted', { lastApplied: SHA });
|
|
primeManifests({
|
|
trusted: {
|
|
manifestVersion: 3,
|
|
generation: { appliedDir: `generations/applied-${SHA}-3` },
|
|
resolvedRevision: { commitSha: SHA },
|
|
},
|
|
});
|
|
|
|
expect(migrateDirectGitStacks()).toEqual([{ stackName: 'trusted', outcome: 'migrated_accepted' }]);
|
|
|
|
const store = GitOpsStore.getInstance();
|
|
const app = store.getLiveDirectApplication('trusted')!;
|
|
expect(app.desired_commit_sha).toBe(SHA);
|
|
expect(app.fetched_commit_sha).toBe(SHA);
|
|
expect(app.accepted_generation_id).not.toBeNull();
|
|
|
|
const generation = store.getGeneration(app.accepted_generation_id!)!;
|
|
expect(generation.commit_sha).toBe(SHA);
|
|
// Equal fingerprints, or the accepted generation would immediately read as
|
|
// stale against the configuration that produced it.
|
|
expect(generation.materialization_fingerprint).toBe(app.materialization_fingerprint);
|
|
|
|
const target = store.getTarget(app.id, 1)!;
|
|
expect(target.desired_generation_id).toBe(app.accepted_generation_id);
|
|
expect(target.applied_generation_id).toBe(app.accepted_generation_id);
|
|
// A manifest proves what was materialized, never what is running.
|
|
expect(target.deployed_generation_id).toBeNull();
|
|
expect(target.healthy_generation_id).toBeNull();
|
|
expect(target.lkg_generation_id).toBeNull();
|
|
// Nobody approved this generation through the model.
|
|
expect(app.source_acceptance_ref).toBeNull();
|
|
|
|
expect(projectOf(app.id).facets.source.status).toBe('application_generation_accepted');
|
|
});
|
|
|
|
it('keeps an unprovable applied commit as evidence, never as a pointer', () => {
|
|
const cases: Array<[string, ManifestFixture, string]> = [
|
|
['manifest-gone', null, 'manifest_absent'],
|
|
['manifest-broken', { corrupt: 'invalid manifest shape' }, 'manifest_corrupt'],
|
|
['manifest-foreign', { corrupt: 'identity repository mismatch' }, 'manifest_identity_invalid'],
|
|
];
|
|
for (const [stackName, manifest, expectedCode] of cases) {
|
|
seedStack(stackName, { lastApplied: SHA });
|
|
primeManifests({ [stackName]: manifest });
|
|
|
|
migrateDirectGitStacks();
|
|
|
|
const app = GitOpsStore.getInstance().getLiveDirectApplication(stackName)!;
|
|
expect(app.desired_commit_sha, stackName).toBeNull();
|
|
expect(app.fetched_commit_sha, stackName).toBeNull();
|
|
expect(app.accepted_generation_id, stackName).toBeNull();
|
|
|
|
const projection = projectOf(app.id);
|
|
expect(projection.facets.source.status, stackName).toBe('never_reconciled');
|
|
expect(projection.limitations.map((l) => l.code), stackName).toContain(expectedCode);
|
|
// The commit is retained as the evidence behind the limitation, so an
|
|
// operator can see what the stack used to be at.
|
|
expect(projection.limitations.map((l) => l.evidence), stackName).toContain(SHA);
|
|
}
|
|
});
|
|
|
|
it('refuses a valid manifest that names a different commit than the source row', () => {
|
|
// The manifest validates and belongs to this stack, repository and ref, so
|
|
// every other check passes. Only the commits disagree, and that alone must
|
|
// keep the canonical pointers null: the applied directory here materializes
|
|
// MANIFEST_SHA, so accepting SHA would certify a commit whose files are not
|
|
// the ones on disk.
|
|
const manifestSha = 'manifest02';
|
|
seedStack('commit-drift', { lastApplied: SHA });
|
|
primeManifests({
|
|
'commit-drift': {
|
|
manifestVersion: 4,
|
|
generation: { appliedDir: `generations/applied-${manifestSha}-4` },
|
|
resolvedRevision: { commitSha: manifestSha },
|
|
},
|
|
});
|
|
|
|
expect(migrateDirectGitStacks()).toEqual([{ stackName: 'commit-drift', outcome: 'migrated_unreconciled' }]);
|
|
|
|
const app = GitOpsStore.getInstance().getLiveDirectApplication('commit-drift')!;
|
|
expect(app.desired_commit_sha).toBeNull();
|
|
expect(app.fetched_commit_sha).toBeNull();
|
|
expect(app.accepted_generation_id).toBeNull();
|
|
|
|
const target = GitOpsStore.getInstance().getTarget(app.id, 1)!;
|
|
expect(target.applied_generation_id).toBeNull();
|
|
|
|
const projection = projectOf(app.id);
|
|
expect(projection.facets.source.status).toBe('never_reconciled');
|
|
expect(projection.availableActions).toContain('fetch');
|
|
// Both commits are named, so an operator can see which two records disagree
|
|
// rather than only learning that something could not be proven.
|
|
const mismatch = projection.limitations.find((l) => l.code === 'manifest_commit_mismatch');
|
|
expect(mismatch).toBeDefined();
|
|
expect(mismatch!.evidence).toContain(SHA);
|
|
expect(mismatch!.evidence).toContain(manifestSha);
|
|
});
|
|
|
|
it('separates a manifest with no commit from one that names a conflicting commit', () => {
|
|
// A manifest adopted from an existing directory is written with an empty
|
|
// commit and state 'migrated', which the validator permits. Folding that
|
|
// into the mismatch case would tell an operator the manifest names a
|
|
// different commit while naming nothing at all.
|
|
seedStack('adopted', { lastApplied: SHA });
|
|
primeManifests({
|
|
adopted: {
|
|
manifestVersion: 1,
|
|
generation: { appliedDir: 'generations/applied-adopted-1' },
|
|
resolvedRevision: { commitSha: '' },
|
|
},
|
|
});
|
|
|
|
expect(migrateDirectGitStacks()).toEqual([{ stackName: 'adopted', outcome: 'migrated_unreconciled' }]);
|
|
|
|
const app = GitOpsStore.getInstance().getLiveDirectApplication('adopted')!;
|
|
expect(app.desired_commit_sha).toBeNull();
|
|
expect(app.accepted_generation_id).toBeNull();
|
|
|
|
const codes = projectOf(app.id).limitations.map((l) => l.code);
|
|
expect(codes).toContain('manifest_commit_unresolved');
|
|
expect(codes).not.toContain('manifest_commit_mismatch');
|
|
});
|
|
|
|
it('does not let a pending pull stand in for proof', () => {
|
|
seedStack('pending-only', { lastApplied: null, pending: 'pending99' });
|
|
primeManifests({ 'pending-only': null });
|
|
|
|
migrateDirectGitStacks();
|
|
|
|
const app = GitOpsStore.getInstance().getLiveDirectApplication('pending-only')!;
|
|
expect(app.desired_commit_sha).toBeNull();
|
|
expect(app.candidate_generation_id).toBeNull();
|
|
const projection = projectOf(app.id);
|
|
expect(projection.facets.source.status).toBe('never_reconciled');
|
|
expect(projection.limitations.map((l) => l.code)).toContain('legacy_pending');
|
|
});
|
|
|
|
it('is a no-op on replay and re-runs only when the configuration changes', () => {
|
|
seedStack('replay', { lastApplied: SHA });
|
|
primeManifests({
|
|
replay: {
|
|
manifestVersion: 1,
|
|
generation: { appliedDir: `generations/applied-${SHA}-1` },
|
|
resolvedRevision: { commitSha: SHA },
|
|
},
|
|
});
|
|
|
|
expect(migrateDirectGitStacks()[0].outcome).toBe('migrated_accepted');
|
|
const firstId = GitOpsStore.getInstance().getLiveDirectApplication('replay')!.id;
|
|
|
|
expect(migrateDirectGitStacks()).toEqual([{ stackName: 'replay', outcome: 'skipped_current' }]);
|
|
expect(GitOpsStore.getInstance().getLiveDirectApplication('replay')!.id).toBe(firstId);
|
|
|
|
// A material configuration change replays the matrix, and the existing
|
|
// application is left alone rather than being rebuilt over.
|
|
seedStack('replay', { lastApplied: SHA, composePaths: ['compose.yaml', 'compose.prod.yaml'] });
|
|
expect(migrateDirectGitStacks()).toEqual([{ stackName: 'replay', outcome: 'skipped_live_application' }]);
|
|
expect(GitOpsStore.getInstance().getLiveDirectApplication('replay')!.id).toBe(firstId);
|
|
});
|
|
|
|
it('never touches a stack the new path already described', () => {
|
|
seedStack('already-modelled', { lastApplied: SHA });
|
|
primeManifests({ 'already-modelled': null });
|
|
migrateDirectGitStacks();
|
|
const before = GitOpsStore.getInstance().getLiveDirectApplication('already-modelled')!;
|
|
|
|
DatabaseService.getInstance().getDb().prepare('DELETE FROM gitops_migration_checkpoints').run();
|
|
migrateDirectGitStacks();
|
|
|
|
expect(GitOpsStore.getInstance().getLiveDirectApplication('already-modelled')!.id).toBe(before.id);
|
|
});
|
|
|
|
it('retires a stack whose directory is gone instead of claiming its name', () => {
|
|
seedStack('vanished', { lastApplied: SHA, createDir: false });
|
|
primeManifests({ vanished: null });
|
|
|
|
expect(migrateDirectGitStacks()).toEqual([
|
|
{ stackName: 'vanished', outcome: 'tombstoned_missing_stack' },
|
|
]);
|
|
expect(GitOpsStore.getInstance().getLiveDirectApplication('vanished')).toBeUndefined();
|
|
});
|
|
|
|
it('accepts the applied commit through a trusted manifest even on a legacy URL', () => {
|
|
// The worst real-world instance of the strict-parser bug: a stack whose
|
|
// manifest proves its applied commit would have failed migration every
|
|
// boot and never entered the model at all.
|
|
const legacyUrl = `${REPO}?token=legacy-secret`;
|
|
seedStack('legacy-trusted-url', { lastApplied: SHA, repoUrl: legacyUrl });
|
|
primeManifests({
|
|
'legacy-trusted-url': {
|
|
manifestVersion: 3,
|
|
generation: { appliedDir: `generations/applied-${SHA}-3` },
|
|
resolvedRevision: { commitSha: SHA },
|
|
},
|
|
});
|
|
|
|
expect(migrateDirectGitStacks()).toEqual([{ stackName: 'legacy-trusted-url', outcome: 'migrated_accepted' }]);
|
|
|
|
const store = GitOpsStore.getInstance();
|
|
const app = store.getLiveDirectApplication('legacy-trusted-url')!;
|
|
expect(app.desired_commit_sha).toBe(SHA);
|
|
expect(app.accepted_generation_id).not.toBeNull();
|
|
expect(app.configured_repo_url).toBe(REPO);
|
|
expect(DatabaseService.getInstance().getGitSource('legacy-trusted-url')?.repo_url).toBe(legacyUrl);
|
|
expect(projectOf(app.id).facets.source.status).toBe('application_generation_accepted');
|
|
});
|
|
|
|
it('derives the same identity as strict ingress once the legacy decoration is stripped', () => {
|
|
const config = {
|
|
repoUrl: REPO,
|
|
branch: 'main',
|
|
composePaths: ['compose.yaml'],
|
|
contextDir: null,
|
|
syncEnv: false,
|
|
envPath: null,
|
|
};
|
|
const noisy = { ...config, repoUrl: `${REPO}?token=x` };
|
|
const lenient = migrationDirectSourceIdentity(noisy);
|
|
const strict = directSourceIdentity(config);
|
|
expect(lenient.repoUrl).toBe(strict.repoUrl);
|
|
expect(lenient.identity).toEqual(strict.identity);
|
|
// A migrated stack must be replay-recognizable against one linked fresh
|
|
// through the user path for the same repository.
|
|
expect(lenient.fingerprint).toBe(strict.fingerprint);
|
|
});
|
|
|
|
it('migrates a legacy operational URL that still carries a query string', () => {
|
|
const legacyUrl = `${REPO}?token=legacy-secret`;
|
|
seedStack('legacy-query-url', { lastApplied: null, repoUrl: legacyUrl });
|
|
primeManifests({ 'legacy-query-url': null });
|
|
|
|
expect(migrateDirectGitStacks()).toEqual([{ stackName: 'legacy-query-url', outcome: 'migrated_unreconciled' }]);
|
|
|
|
const app = GitOpsStore.getInstance().getLiveDirectApplication('legacy-query-url')!;
|
|
expect(app.configured_repo_url).toBe(REPO);
|
|
// The operational row keeps its query: fetch may still need it.
|
|
expect(DatabaseService.getInstance().getGitSource('legacy-query-url')?.repo_url).toBe(legacyUrl);
|
|
});
|
|
|
|
it('migrates a legacy URL carrying userinfo to the identity of its clean form', () => {
|
|
seedStack('legacy-userinfo-url', { lastApplied: null, repoUrl: 'https://deploy:pat@github.com/example/legacy.git' });
|
|
seedStack('clean-url', { lastApplied: null });
|
|
primeManifests({ 'legacy-userinfo-url': null, 'clean-url': null });
|
|
|
|
migrateDirectGitStacks();
|
|
|
|
const store = GitOpsStore.getInstance();
|
|
const legacy = store.getLiveDirectApplication('legacy-userinfo-url')!;
|
|
const clean = store.getLiveDirectApplication('clean-url')!;
|
|
expect(legacy.configured_repo_url).toBe(REPO);
|
|
expect(legacy.repo_identity_json).toBe(clean.repo_identity_json);
|
|
// The same repository under the same configuration must produce the same
|
|
// fingerprint, or a later replay could not recognize the stack it
|
|
// already migrated.
|
|
expect(legacy.materialization_fingerprint).toBe(clean.materialization_fingerprint);
|
|
expect(DatabaseService.getInstance().getGitSource('legacy-userinfo-url')?.repo_url).toContain('deploy:pat@');
|
|
});
|
|
});
|
|
|
|
function projectOf(applicationId: string) {
|
|
const projection = projectApplication(applicationId, true);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected an application');
|
|
return projection;
|
|
}
|
|
|
|
function primeManifests(fixtures: Record<string, ManifestFixture>): void {
|
|
primeMigrationManifests((stackName) => fixtures[stackName] ?? null);
|
|
}
|
|
|
|
function seedStack(
|
|
stackName: string,
|
|
options: { lastApplied: string | null; pending?: string; composePaths?: string[]; createDir?: boolean; repoUrl?: string },
|
|
): void {
|
|
if (options.createDir !== false) {
|
|
const composeDir = process.env.COMPOSE_DIR!;
|
|
fs.mkdirSync(path.join(composeDir, stackName), { recursive: true });
|
|
fs.writeFileSync(path.join(composeDir, stackName, 'compose.yaml'), 'services: {}\n');
|
|
}
|
|
const row: Parameters<DatabaseService['upsertGitSource']>[0] = {
|
|
stack_name: stackName,
|
|
repo_url: options.repoUrl ?? REPO,
|
|
branch: 'main',
|
|
compose_path: 'compose.yaml',
|
|
compose_paths: options.composePaths ?? ['compose.yaml'],
|
|
context_dir: null,
|
|
sync_env: false,
|
|
env_path: null,
|
|
auth_type: 'none',
|
|
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
|
auto_apply_on_webhook: false,
|
|
auto_deploy_on_apply: false,
|
|
last_applied_commit_sha: options.lastApplied,
|
|
last_applied_content_hash: null,
|
|
pending_commit_sha: options.pending ?? null,
|
|
pending_compose_content: null,
|
|
pending_env_content: null,
|
|
pending_fetched_at: null,
|
|
last_debounce_at: null,
|
|
} as StackGitSource;
|
|
DatabaseService.getInstance().upsertGitSource(row);
|
|
if (options.lastApplied) {
|
|
DatabaseService.getInstance().markGitSourceApplied(stackName, options.lastApplied, '');
|
|
}
|
|
if (options.pending) {
|
|
// upsertGitSource does not write the pending columns on insert, so a
|
|
// legacy row carrying an unapplied pull is seeded directly.
|
|
DatabaseService.getInstance().getDb()
|
|
.prepare('UPDATE stack_git_sources SET pending_commit_sha = ? WHERE stack_name = ?')
|
|
.run(options.pending, stackName);
|
|
}
|
|
}
|